# Lowkey Media Server HTTP API - the source of truth for docs/server/api.html.
# Edit this file, then run: node scripts/build-api-docs.js
# media-server/openapi_coverage_test.go fails when a registered route is missing here.
openapi: 3.1.0
info:
  title: Lowkey Media Server HTTP API
  version: 2.37.0
  description: |
    Every endpoint served by Lowkey Media Server. Paths are relative to the
    server's address (port 10111 unless changed with `LOWKEY_PORT` or the
    `port` config key).
  license:
    name: MIT
    url: https://github.com/SteveCastle/loki/blob/master/LICENSE
servers:
  - url: http://localhost:10111
    description: Default local server
components:
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      description: A JWT from `POST /auth/login` or an `lk_` API key.
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: An `lk_` API key.
    cookie:
      type: apiKey
      in: cookie
      name: auth_token
      description: The session cookie set by `POST /auth/login`.
  schemas:
    Error:
      type: object
      description: >-
        Error body returned by handlers written for the web client. Older handlers return a
        plain-text message instead.
      properties:
        error:
          type: string
          description: Human-readable message.
      required:
        - error
    JobState:
      type: string
      enum:
        - pending
        - in_progress
        - paused
        - completed
        - cancelled
        - error
      description: >-
        `completed`, `cancelled` and `error` are terminal. `paused` holds progress and can be
        resumed.
    Job:
      type: object
      description: A job in the queue.
      properties:
        id:
          type: string
          description: Job id (UUID).
        command:
          type: string
          description: Task id.
        arguments:
          type: array
          items:
            type: string
          description: Option tokens passed to the task.
        input:
          type: string
          description: The job input after workflow substitution.
        original_input:
          type: string
          description: The input as submitted.
        host:
          type: string
          description: Concurrency bucket the job runs in.
        resources:
          type: array
          items:
            type: string
          description: Additional concurrency buckets the job occupies.
        dependencies:
          type: array
          items:
            type: string
          description: Ids of jobs that must complete first.
        state:
          $ref: '#/components/schemas/JobState'
        created_at:
          type: string
          format: date-time
        claimed_at:
          type: string
          format: date-time
          description: Zero time (`0001-01-01T00:00:00Z`) until claimed.
        completed_at:
          type: string
          format: date-time
        errored_at:
          type: string
          format: date-time
        output_files:
          type: array
          items:
            type: string
          description: Files the job registered for downstream workflow steps.
        source_files:
          type: array
          items:
            type: string
          description: Parallel to `output_files`; the source of each output.
        workflow_id:
          type: string
          description: Set when the job belongs to a workflow.
        progress_done:
          type: integer
        progress_total:
          type: integer
        interrupt_count:
          type: integer
          description: >-
            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.
      required:
        - id
        - command
        - arguments
        - input
        - original_input
        - host
        - resources
        - dependencies
        - state
        - created_at
        - claimed_at
        - completed_at
        - errored_at
        - output_files
        - source_files
        - workflow_id
        - progress_done
        - progress_total
        - interrupt_count
    AuthUser:
      type: object
      description: A user account.
      required:
        - id
        - username
        - created_at
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
        created_at:
          type: integer
          format: int64
          description: Unix seconds.
    APIKey:
      type: object
      description: An API key's metadata. The secret is never returned after creation.
      required:
        - id
        - username
        - name
        - prefix
        - created_at
        - last_used_at
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
          description: User the key acts as.
        name:
          type: string
        prefix:
          type: string
          description: First 11 characters of the key (`lk_` + 8 hex).
        created_at:
          type: integer
          format: int64
          description: Unix seconds.
        last_used_at:
          type: integer
          format: int64
          description: Unix seconds; `0` if never used.
    HealthStatus:
      type: object
      required:
        - status
        - version
        - timestamp
        - stream
        - jobs
      properties:
        status:
          type: string
          enum:
            - healthy
          description: Always `healthy` when the server answers.
        version:
          type: string
          description: Server version.
        timestamp:
          type: integer
          format: int64
          description: Server time, Unix seconds.
        stream:
          type: object
          description: '`/stream` SSE statistics.'
          required:
            - active_connections
            - total_messages
            - max_connections
            - dropped_broadcasts
            - dropped_client_msgs
            - rejected_connections
          properties:
            active_connections:
              type: integer
            total_messages:
              type: integer
            max_connections:
              type: integer
            dropped_broadcasts:
              type: integer
            dropped_client_msgs:
              type: integer
            rejected_connections:
              type: integer
        jobs:
          type: object
          description: Job counts by state. Paused jobs are in `total` only.
          required:
            - total
            - pending
            - in_progress
            - completed
            - cancelled
            - error
          properties:
            total:
              type: integer
            pending:
              type: integer
            in_progress:
              type: integer
            completed:
              type: integer
            cancelled:
              type: integer
            error:
              type: integer
    SchedulerStatus:
      type: object
      description: Auto-scheduler status. Fields may be zero values before the first tick.
      required:
        - mode
        - forced
        - state
        - reason
        - done
        - total
        - ops
        - signals
      properties:
        mode:
          type: string
          enum:
            - 'off'
            - auto
        forced:
          type: boolean
          description: A one-shot forced run (`POST /api/scheduler/run`) is active.
        state:
          type: string
          enum:
            - disabled
            - waiting
            - running
            - yielding
            - complete
        reason:
          type: string
          description: Human-readable reason for the state, e.g. `the app is in use`, `session locked`.
        jobId:
          type: string
          description: The scheduled job's id, when there is one. Omitted otherwise.
        done:
          type: integer
          description: Items processed by the scheduled job.
        total:
          type: integer
          description: Items the scheduled job will process.
        ops:
          type: string
          description: >-
            Comma-separated per-item ops the job runs (default
            `hash,dimensions,describe,transcribe,autotag,embed,faces`).
        signals:
          type: object
          description: The idle signals behind the decision.
          required:
            - cpuPercent
            - sessionLocked
            - fullscreenBusy
            - onBattery
            - inputIdleSeconds
            - appQuietSeconds
          properties:
            cpuPercent:
              type: number
              description: Total system CPU percent; `-1` when unknown.
            sessionLocked:
              type: string
              enum:
                - 'yes'
                - 'no'
                - unknown
            fullscreenBusy:
              type: string
              enum:
                - 'yes'
                - 'no'
                - unknown
            onBattery:
              type: string
              enum:
                - 'yes'
                - 'no'
                - unknown
            inputIdleSeconds:
              type: number
              description: Seconds since the last keyboard/mouse input.
            appQuietSeconds:
              type: number
              description: Seconds since the last user-intent request to the server.
      example:
        mode: auto
        forced: false
        state: waiting
        reason: the app is in use
        done: 0
        total: 0
        ops: hash,dimensions,describe,transcribe,autotag,embed,faces
        signals:
          cpuPercent: 12.5
          sessionLocked: 'no'
          fullscreenBusy: 'no'
          onBattery: 'no'
          inputIdleSeconds: 3
          appQuietSeconds: 20
    StorageRoot:
      type: object
      description: A storage root (local folder or S3-compatible bucket).
      required:
        - type
        - label
      properties:
        type:
          type: string
          enum:
            - local
            - s3
        path:
          type: string
          description: Local filesystem path (`local`).
        label:
          type: string
          description: Display name.
        default:
          type: boolean
          description: Destination for uploads and downloads.
        endpoint:
          type: string
        region:
          type: string
        bucket:
          type: string
        prefix:
          type: string
        accessKey:
          type: string
          description: '`<redacted>` on read when set.'
        secretKey:
          type: string
          description: '`<redacted>` on read when set.'
        thumbnailPrefix:
          type: string
    ByoFaceModel:
      type: object
      description: A bring-your-own face recognizer (user-supplied ONNX file).
      required:
        - id
        - modelPath
        - dim
      properties:
        id:
          type: string
          description: Unique id; face vectors are keyed by it.
        name:
          type: string
        modelPath:
          type: string
          description: Absolute path to the ONNX file.
        dim:
          type: integer
          description: Embedding dimension.
        inputName:
          type: string
          description: Input tensor name (default `data`).
        outputName:
          type: string
          description: Output tensor name (default `fc1`).
        mean:
          type: array
          items:
            type: number
          description: Per-channel RGB mean on the 0..255 scale.
        std:
          type: array
          items:
            type: number
          description: Per-channel RGB std on the 0..255 scale.
    InferenceConcurrency:
      type: object
      description: Per-provider concurrency caps for vision inference.
      properties:
        ollama:
          type: integer
        runpod:
          type: integer
        lmstudio:
          type: integer
        llamacpp:
          type: integer
        strata:
          type: integer
    ServerConfig:
      type: object
      description: >-
        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.
      properties:
        dbPath:
          type: string
          description: Active SQLite library database.
        port:
          type: integer
          description: HTTP port (default 10111).
        downloadPath:
          type: string
        extractorsPath:
          type: string
        inferenceProvider:
          type: string
          enum:
            - 'off'
            - ollama
            - runpod
            - lmstudio
            - llamacpp
            - strata
        ollamaBaseUrl:
          type: string
        ollamaModel:
          type: string
        describePrompt:
          type: string
        runpodEndpoint:
          type: string
        runpodApiKey:
          type: string
        lmstudioBaseUrl:
          type: string
        lmstudioModel:
          type: string
        lmstudioApiKey:
          type: string
        llamacppBaseUrl:
          type: string
        llamacppModel:
          type: string
        llamacppApiKey:
          type: string
        strataBaseUrl:
          type: string
        strataModel:
          type: string
        strataApiKey:
          type: string
        inferenceConcurrency:
          $ref: '#/components/schemas/InferenceConcurrency'
        autoProcessOps:
          type: string
          description: Comma-separated ops for the auto scheduler's job; empty = all.
        localComputeConcurrency:
          type: integer
        onnxTagger:
          type: object
          properties:
            modelPath:
              type: string
            labelsPath:
              type: string
            configPath:
              type: string
            ortSharedLibraryPath:
              type: string
            generalThreshold:
              type: number
            characterThreshold:
              type: number
        embeddingModel:
          type: string
        embeddingProvider:
          type: string
          enum:
            - cpu
            - directml
        embeddingPerformance:
          type: string
          enum:
            - low
            - balanced
            - max
            - custom
        embeddingWorkers:
          type: integer
        embeddingThreadsPerWorker:
          type: integer
        autotagModel:
          type: string
        autotagProvider:
          type: string
        autotagPerformance:
          type: string
        autotagWorkers:
          type: integer
        autotagThreadsPerWorker:
          type: integer
        faceModel:
          type: string
        faceProvider:
          type: string
        facePerformance:
          type: string
        faceWorkers:
          type: integer
        faceThreadsPerWorker:
          type: integer
        faceRouting:
          type: string
          enum:
            - auto
            - single
        faceClusterThresholdOffset:
          type: number
        faceClusterMinCluster:
          type: integer
        faceClusterMinQuality:
          type: number
        byoFaceModels:
          type: array
          items:
            $ref: '#/components/schemas/ByoFaceModel'
        onnxFileTimeoutSeconds:
          type: integer
          description: Per-file timeout for local ONNX tasks; `<= 0` disables.
        transcriptionProvider:
          type: string
        transcriptionModel:
          type: string
        transcriptionLanguage:
          type: string
        transcriptionVadFilter:
          type: boolean
        transcriptionInitialPrompt:
          type: string
        transcriptionHotwords:
          type: string
        transcriptionVocalExtract:
          type: boolean
        dreamModel:
          type: string
        reshootModels:
          type: array
          items:
            type: object
            properties:
              file:
                type: string
              label:
                type: string
              recipe:
                type: string
                enum:
                  - fast
                  - h3
        fasterWhisperPath:
          type: string
        discordToken:
          type: string
        jwtSecret:
          type: string
          description: Always `<redacted>` when set.
        setupComplete:
          type: boolean
        defaultStartPath:
          type: string
        allowPublicAccess:
          type: boolean
        swipeFeed:
          type: object
          additionalProperties: true
          description: Swipe For You feed tuning.
        engines:
          type: object
          description: GPU engine placement (see the GPU Engines section).
          properties:
            strategy:
              type: string
              enum:
                - local
                - pool
                - hybrid
            pools:
              type:
                - array
                - 'null'
              items:
                type: object
                additionalProperties: true
            routes:
              type:
                - object
                - 'null'
              additionalProperties:
                type: string
            fallback:
              type: boolean
            localOnly:
              type: boolean
        roots:
          type: array
          items:
            $ref: '#/components/schemas/StorageRoot'
        rootPaths:
          type: array
          items:
            type: string
      additionalProperties: true
    ConfigUpdateRequest:
      type: object
      description: >-
        Fields accepted by `POST /config`. Only `dbPath` is required; see the operation description
        for which fields are skipped when empty.
      required:
        - dbPath
      properties:
        dbPath:
          type: string
          description: Required, non-blank. A different value switches the library database.
        port:
          type: integer
          minimum: 1
          maximum: 65535
          description: Applied on restart. Out-of-range or 0 is ignored.
        downloadPath:
          type: string
        ollamaBaseUrl:
          type: string
        ollamaModel:
          type: string
        describePrompt:
          type: string
        inferenceProvider:
          type: string
          enum:
            - 'off'
            - ollama
            - runpod
            - lmstudio
            - llamacpp
            - strata
          description: Lower-cased.
        runpodEndpoint:
          type: string
        runpodApiKey:
          type: string
          description: '`<redacted>` keeps the stored key.'
        lmstudioBaseUrl:
          type: string
        lmstudioModel:
          type: string
        lmstudioApiKey:
          type: string
          description: '`<redacted>` keeps the stored key.'
        llamacppBaseUrl:
          type: string
        llamacppModel:
          type: string
        llamacppApiKey:
          type: string
          description: '`<redacted>` keeps the stored key.'
        strataBaseUrl:
          type: string
        strataModel:
          type: string
        strataApiKey:
          type: string
          description: '`<redacted>` keeps the stored key.'
        inferenceConcurrency:
          $ref: '#/components/schemas/InferenceConcurrency'
        localComputeConcurrency:
          type: integer
        onnxModelPath:
          type: string
          description: Always applied; omitting it clears it.
        onnxLabelsPath:
          type: string
          description: Always applied; omitting it clears it.
        onnxConfigPath:
          type: string
          description: Always applied; omitting it clears it.
        onnxOrtSharedLibPath:
          type: string
          description: Always applied; omitting it clears it.
        onnxGeneralThreshold:
          type: number
        onnxCharacterThreshold:
          type: number
        embeddingModel:
          type: string
        embeddingProvider:
          type: string
        embeddingPerformance:
          type: string
        embeddingWorkers:
          type: integer
        embeddingThreadsPerWorker:
          type: integer
        autotagModel:
          type: string
        autotagProvider:
          type: string
        autotagPerformance:
          type: string
        autotagWorkers:
          type: integer
        autotagThreadsPerWorker:
          type: integer
        onnxFileTimeoutSeconds:
          type: integer
          description: Non-zero values apply; negative disables the timeout.
        faceModel:
          type: string
        faceRouting:
          type: string
          enum:
            - auto
            - single
          description: Other values are ignored.
        faceProvider:
          type: string
        facePerformance:
          type: string
        faceWorkers:
          type: integer
        faceThreadsPerWorker:
          type: integer
        byoFaceModels:
          type: array
          items:
            $ref: '#/components/schemas/ByoFaceModel'
          description: Replaces the list when present; `[]` clears it.
        transcriptionProvider:
          type: string
        transcriptionModel:
          type: string
        transcriptionLanguage:
          type: string
          description: Applied when present; empty means auto-detect.
        transcriptionVadFilter:
          type: boolean
        transcriptionInitialPrompt:
          type: string
        transcriptionHotwords:
          type: string
        transcriptionVocalExtract:
          type: boolean
        allowPublicAccess:
          type: boolean
        defaultStartPath:
          type: string
        fasterWhisperPath:
          type: string
        discordToken:
          type: string
          description: '`<redacted>` keeps the stored token.'
        roots:
          type: array
          items:
            $ref: '#/components/schemas/StorageRoot'
          description: Replaces the storage roots when present.
    CreateJobRequest:
      type: object
      properties:
        input:
          type: string
          description: >-
            Command string: `<task-id> [option tokens...] <input>`, split on spaces with
            double-quote grouping (see the operation description).
        fields:
          type: object
          additionalProperties:
            type: string
          description: >-
            Extra options appended verbatim as `--<key> <value>` token pairs. Empty keys or values
            are skipped.
      required:
        - input
    JobSnapshot:
      type: object
      description: Response of `GET /api/job/{id}`. Every field is always present.
      properties:
        id:
          type: string
          description: Job id.
        command:
          type: string
          description: Task id.
        state:
          $ref: '#/components/schemas/JobState'
        progress_done:
          type: integer
          description: Items finished (including skips).
        progress_total:
          type: integer
          description: Total items; 0 until the input resolves.
        output_files:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Files the job registered so far; `null` when none were ever registered.
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          description: Zero time (`0001-01-01T00:00:00Z`) until completed.
        log:
          type: array
          items:
            type: string
          description: The last `tail` log lines, oldest first.
      required:
        - id
        - command
        - state
        - progress_done
        - progress_total
        - output_files
        - created_at
        - completed_at
        - log
    JobForPathSummary:
      type: object
      properties:
        id:
          type: string
          description: Job id.
        command:
          type: string
          description: Task id.
        arguments:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Option tokens.
        state:
          $ref: '#/components/schemas/JobState'
        progress_done:
          type: integer
        progress_total:
          type: integer
        created_at:
          type: string
          format: date-time
      required:
        - id
        - command
        - arguments
        - state
        - progress_done
        - progress_total
        - created_at
    TaskInfo:
      type: object
      properties:
        id:
          type: string
          description: Task id, used as a job's `command` and as the first token of `POST /create` input.
        name:
          type: string
          description: Display name.
        options:
          type: array
          items:
            $ref: '#/components/schemas/TaskOption'
          description: >-
            Option schema. Always present (possibly empty) on Windows; absent on macOS and Linux
            builds.
      required:
        - id
        - name
    TaskOption:
      type: object
      description: >-
        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.
      properties:
        name:
          type: string
          description: Option name (the flag without `--`).
        label:
          type: string
          description: Display label.
        type:
          type: string
          enum:
            - string
            - bool
            - enum
            - multi-enum
            - number
          description: >
            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.
        choices:
          type: array
          items:
            type: string
          description: Allowed values for `enum` and `multi-enum`. Omitted when empty.
        default:
          description: >-
            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.
        required:
          type: boolean
          description: Marked as required in the UI. Omitted when false.
        description:
          type: string
          description: Help text. Omitted when empty.
      required:
        - name
        - label
        - type
    WorkflowTask:
      type: object
      description: One DAG node; becomes one job.
      properties:
        id:
          type: string
          description: >-
            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.
        command:
          type: string
          description: Task id (see `GET /tasks`).
        arguments:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Option tokens, already split (for example `["--apply", "all"]`).
        input:
          type: string
          description: >-
            The node's own input. At claim time each dependency's output files are appended one per
            line.
        dependencies:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Ids of nodes in the same DAG that must complete first.
        pos_x:
          type: number
          description: Editor canvas position. Omitted when 0.
        pos_y:
          type: number
          description: Editor canvas position. Omitted when 0.
      required:
        - id
        - command
    SavedWorkflowSummary:
      type: object
      properties:
        id:
          type: string
          description: Workflow id (UUID).
        name:
          type: string
          description: Unique workflow name.
      required:
        - id
        - name
    SavedWorkflow:
      type: object
      properties:
        id:
          type: string
          description: Workflow id (UUID).
        name:
          type: string
          description: Unique workflow name.
        dag:
          type: array
          items:
            $ref: '#/components/schemas/WorkflowTask'
          description: The DAG nodes.
      required:
        - id
        - name
        - dag
    SavedWorkflowInput:
      type: object
      description: Body of `POST /workflows/create` and `PUT /workflows/{id}`.
      properties:
        name:
          type: string
          description: Workflow name; must be unique.
        dag:
          type: array
          items:
            $ref: '#/components/schemas/WorkflowTask'
          description: >-
            Non-empty DAG. Every node needs a non-empty, unique `id` and a `command`; every
            dependency must name a node in this DAG.
      required:
        - dag
    WorkflowRunResponse:
      type: object
      properties:
        ids:
          type:
            - array
            - 'null'
          items:
            type: string
          description: >-
            The created job ids, in node order. `null` when the DAG was empty (`POST /workflow`
            only).
      required:
        - ids
    StreamConnectedEvent:
      type: object
      description: Data of the unnamed message sent once when a `/stream` connection opens.
      properties:
        type:
          type: string
          const: connected
        msg:
          type: string
          const: SSE connection established
      required:
        - type
        - msg
    JobListEvent:
      type: object
      description: Data of the `create`, `update` and `delete` events.
      properties:
        updateType:
          type: string
          enum:
            - create
            - update
            - delete
          description: Same as the event name.
        job:
          $ref: '#/components/schemas/Job'
        html:
          type: string
          description: The job rendered as a table row for the admin Jobs page.
      required:
        - updateType
        - job
        - html
    StdoutEvent:
      type: object
      description: Data of the `stdout-<jobId>` event.
      properties:
        updateType:
          type: string
          const: stdout
        line:
          type: string
          description: One log line.
      required:
        - updateType
        - line
    ProgressEvent:
      type: object
      description: Data of the `progress` event.
      properties:
        updateType:
          type: string
          const: progress
        id:
          type: string
          description: Job id.
        done:
          type: integer
          description: Items finished.
        total:
          type: integer
          description: Total items.
      required:
        - updateType
        - id
        - done
        - total
    PeopleUpdatedEvent:
      type: object
      description: Data of the `people-updated` event.
      properties:
        models:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Face model ids whose clustering ran; empty after a manual edit.
      required:
        - models
    DuplicatesUpdatedEvent:
      type: object
      description: Data of the `duplicates-updated` event. Counts are informational; clients refetch.
      properties:
        groups:
          type: integer
          description: Groups written so far by the scan (0 after a manual action).
        items:
          type: integer
          description: Items in those groups (0 after a manual action).
      required:
        - groups
        - items
    MediaPathsEvent:
      type: object
      description: Data of the `media-updated` and `media-created` events (never sent with an empty list).
      properties:
        paths:
          type: array
          items:
            type: string
          description: Affected file paths.
      required:
        - paths
    SchedulerEvent:
      type: object
      description: >-
        Data of the `scheduler` event; the auto scheduler's status. Sent only when `mode`, `state`,
        `reason` or `jobId` changes.
      properties:
        mode:
          type: string
          enum:
            - 'off'
            - auto
        forced:
          type: boolean
          description: A one-shot run-now override is active.
        state:
          type: string
          enum:
            - disabled
            - waiting
            - running
            - yielding
            - complete
        reason:
          type: string
          description: Human-readable explanation of the state.
        jobId:
          type: string
          description: The scheduler's current job. Omitted when none.
        done:
          type: integer
        total:
          type: integer
        ops:
          type: string
          description: Operations the scheduler runs.
        signals:
          type: object
          description: System signals the scheduler reads.
          properties:
            cpuPercent:
              type: number
            sessionLocked:
              type: string
              enum:
                - 'yes'
                - 'no'
                - unknown
            fullscreenBusy:
              type: string
              enum:
                - 'yes'
                - 'no'
                - unknown
            onBattery:
              type: string
              enum:
                - 'yes'
                - 'no'
                - unknown
            inputIdleSeconds:
              type: number
            appQuietSeconds:
              type: number
          required:
            - cpuPercent
            - sessionLocked
            - fullscreenBusy
            - onBattery
            - inputIdleSeconds
            - appQuietSeconds
      required:
        - mode
        - forced
        - state
        - reason
        - done
        - total
        - ops
        - signals
    StatsEvent:
      type: object
      description: >-
        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.
      additionalProperties: true
      properties:
        ready:
          type: boolean
        generatedAt:
          type: integer
          description: Unix seconds the snapshot was computed.
        totalMedia:
          type: integer
        totalVideos:
          type: integer
        totalAudio:
          type: integer
        totalImages:
          type: integer
        totalTranscribable:
          type: integer
          description: Videos plus audio.
        withDescription:
          type: integer
        withHash:
          type: integer
        withSize:
          type: integer
        withTags:
          type: integer
        withDimensions:
          type: integer
        withTranscript:
          type: integer
        withEmbedding:
          type: integer
        withFaceScan:
          type: integer
        autotagModel:
          type: object
          description: Active autotag model info.
        embedModel:
          type: object
          description: Active embedding model info.
        faceModel:
          type: object
          description: Active face model info.
    MediaQueryRequest:
      type: object
      description: >-
        Body of `POST /api/media/query`. There is no paging, limit or cursor; the full result is
        returned.
      properties:
        mode:
          type: string
          enum:
            - AND
            - OR
          default: AND
          description: >-
            Connector for predicates without their own `join`. Anything other than exactly `OR`
            means `AND`.
        predicates:
          type: array
          items:
            $ref: '#/components/schemas/MediaQueryPredicate'
          description: Evaluated left to right. Missing or empty returns the whole library.
    MediaQueryPredicate:
      type: object
      description: >
        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.
      required:
        - type
        - value
      properties:
        type:
          type: string
          enum:
            - tag
            - category
            - path
            - description
            - hash
            - orientation
            - faces
            - dupe
            - similar
            - visual
            - clip
            - face
        value:
          type: string
          description: Operand; meaning depends on `type`. An empty value removes the predicate from the query.
        exclude:
          type: boolean
          default: false
          description: Negate the predicate (`NOT EXISTS`, `NOT LIKE`, `NOT IN`).
        join:
          type: string
          enum:
            - AND
            - OR
            - ''
          description: >-
            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.
        text:
          type: string
          description: >-
            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`.
        textWeight:
          type: number
          minimum: 0
          maximum: 1
          default: 0.5
          description: >-
            Share of `text` in the blend, clamped to 0 to 1 (0 = pure image, 1 = pure text). Missing
            means 0.5.
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/MediaQueryBlendNode'
          description: >-
            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.
        blendMode:
          type: string
          enum:
            - blend
            - shared
            - ''
          default: blend
          description: >-
            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.
    MediaQueryBlendNode:
      type: object
      required:
        - kind
        - value
      properties:
        kind:
          type: string
          enum:
            - image
            - clip
            - text
          description: >-
            `image` = a library media path, `clip` = an image data URL or base64, `text` = free
            text. Any other kind fails the query with `500`.
        value:
          type: string
          description: Path, data URL or text. Blank `text` nodes are skipped.
        weight:
          type: number
          minimum: 0
          maximum: 1
          description: Magnitude, clamped to 0 to 1. Missing means 1; 0 drops the node.
        negative:
          type: boolean
          default: false
          description: Subtract this term, steering results away from it.
    MediaListItem:
      type: object
      description: >-
        One item in a list result. Nullable database columns are omitted when NULL (a real zero is
        still sent).
      required:
        - path
        - mtimeMs
      properties:
        path:
          type: string
          description: Media path.
        mtimeMs:
          type: integer
          description: >-
            The driving tag assignment's `created_at` (milliseconds) when a tag predicate drives the
            row, otherwise `0`. Not the file's modification time.
        elo:
          type: number
          description: Battle Mode rating.
        battles:
          type: integer
          description: Battle Mode bout count.
        height:
          type: integer
          description: Pixel height.
        width:
          type: integer
          description: Pixel width.
        weight:
          type: number
          description: Weight of the driving tag assignment.
        tagLabel:
          type: string
          description: Label of the driving tag assignment.
        timeStamp:
          type: number
          description: Video timestamp (seconds) of the driving tag assignment.
        score:
          type: number
          description: >-
            Similarity score; present on every item, and only then, when the query had a visual
            predicate.
    SimilarHit:
      type: object
      required:
        - path
        - score
      properties:
        path:
          type: string
          description: Media path.
        score:
          type: number
          description: Cosine similarity in the active embedding model's space.
    ScoredMediaItem:
      type: object
      description: >-
        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.
      required:
        - path
        - mtimeMs
        - score
      properties:
        path:
          type: string
        mtimeMs:
          type: integer
          description: Always `0`.
        elo:
          type: number
        battles:
          type: integer
        height:
          type: integer
        width:
          type: integer
        score:
          type: number
          description: Similarity score; items are sorted by it, highest first.
    FaceSearchItem:
      allOf:
        - $ref: '#/components/schemas/ScoredMediaItem'
        - type: object
          required:
            - faceId
            - face
          properties:
            faceId:
              type: integer
              description: Id of the best-matching face in this item.
            face:
              type: object
              description: That face's box, relative to the media (0 to 1).
              required:
                - x
                - 'y'
                - w
                - h
                - frameTs
                - personId
              properties:
                x:
                  type: number
                'y':
                  type: number
                w:
                  type: number
                h:
                  type: number
                frameTs:
                  type: number
                  description: Video frame time the face was detected at; `0` for images.
                personId:
                  type: integer
                  description: Person the face is assigned to; `0` when ungrouped.
    MediaPathRequest:
      type: object
      required:
        - path
      properties:
        path:
          type: string
          description: Media path.
    MediaMetadata:
      type: object
      required:
        - fileMetadata
        - hash
      properties:
        fileMetadata:
          type: object
          required:
            - size
            - modified
            - width
            - height
          properties:
            size:
              type: string
              description: >-
                Human-readable size from the stored byte count, such as `2.41 MB`; `0 Bytes` when
                unknown.
            modified:
              type: string
              description: Always an empty string.
            width:
              type: integer
              description: Stored width; `0` when unknown.
            height:
              type: integer
              description: Stored height; `0` when unknown.
        hash:
          type: string
          description: Stored content hash; empty when unknown.
        description:
          type: string
          description: Present when the item has a description.
        transcript:
          type: string
          description: Present when the item has a transcript.
        textChunks:
          type: object
          additionalProperties:
            type: string
          description: >-
            Generator text embedded in a local file (for example PNG text chunks), keyed by chunk
            name. Omitted when there are none.
    MediaTagAssignment:
      type: object
      required:
        - tag_label
        - category_label
        - weight
        - time_stamp
      properties:
        tag_label:
          type: string
        category_label:
          type: string
        weight:
          type: number
        time_stamp:
          type: number
          description: Video timestamp in seconds; `0` for an untimed assignment.
    GifMetadata:
      type: object
      required:
        - frameCount
        - duration
      properties:
        frameCount:
          type: integer
          description: Frames counted by ffprobe; `0` when unreported.
        duration:
          type: number
          description: Duration in seconds; `0` when unreported.
    ThumbnailCache:
      type: string
      enum:
        - thumbnail_path_100
        - thumbnail_path_600
        - thumbnail_path_1200
      default: thumbnail_path_600
      description: Thumbnail size column (100, 600 or 1200 px).
    MediaEraseResult:
      type: object
      description: Rows removed per table by delete/forget.
      required:
        - path
        - media
        - tags
        - embeddings
        - faces
        - battles
      properties:
        path:
          type: string
        media:
          type: integer
          description: Media rows removed (0 or 1).
        tags:
          type: integer
          description: Tag assignment rows removed.
        embeddings:
          type: integer
          description: Embedding rows removed.
        faces:
          type: integer
          description: Face rows removed.
        battles:
          type: integer
          description: Battle-log rows removed.
    MediaMoveResult:
      type: object
      required:
        - from
        - to
        - prefix
        - dryRun
        - items
        - rows
        - total
      properties:
        from:
          type: string
          description: Normalised source (trimmed of whitespace and trailing separators).
        to:
          type: string
          description: Normalised destination.
        prefix:
          type: boolean
        dryRun:
          type: boolean
        items:
          type: integer
          description: Number of media rows whose path changed.
        rows:
          type: object
          additionalProperties:
            type: integer
          description: >-
            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.
        total:
          type: integer
          description: Sum of `rows`.
        paths:
          type: array
          items:
            type: object
            required:
              - from
              - to
            properties:
              from:
                type: string
              to:
                type: string
          description: Up to 100 sample rewrites, ordered by old path. Omitted when nothing matched.
        truncated:
          type: boolean
          description: True when more than 100 items moved; omitted otherwise.
    MediaMergeResult:
      type: object
      required:
        - target
        - sources
        - tags
        - embeddings
        - transcript
        - transcriptFile
        - deleted
        - failed
        - facesRemoved
      properties:
        target:
          type: string
        sources:
          type: array
          items:
            type: string
        tags:
          type: integer
          description: Tag rows the target gained.
        embeddings:
          type: integer
          description: Embedding rows the target gained.
        transcript:
          type: boolean
          description: True when the target's transcript was filled or a `.vtt` sidecar was moved next to it.
        transcriptFile:
          type: string
          description: Path of the moved sidecar; empty when none.
        deleted:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Sources removed from disk and database. `null` when none.
        failed:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Sources that kept their file and rows (`s3://`, or deletion failed). `null` when none.
        facesRemoved:
          type: integer
          description: Face rows erased with the sources.
    HlsStatus:
      type: object
      required:
        - status
      properties:
        status:
          type: string
          enum:
            - ready
            - processing
            - queued
            - idle
            - error
          description: '`queued` = waiting for the single ffmpeg slot; `idle` = nothing cached and `check=true`.'
        url:
          type: string
          description: Master playlist URL, when `ready`.
        progress:
          type: number
          minimum: 0
          maximum: 1
          description: Fraction done while processing; omitted when 0.
        duration:
          type: number
          description: Source duration in seconds; only on the response that starts generation.
        error:
          type: string
          description: Failure message, when `error`.
    LegacyMediaItem:
      type: object
      required:
        - path
        - description
        - size
        - hash
        - width
        - height
        - tags
        - exists
        - duplicateCount
      properties:
        path:
          type: string
        description:
          type:
            - string
            - 'null'
        size:
          type:
            - integer
            - 'null'
          description: Bytes.
        hash:
          type:
            - string
            - 'null'
        width:
          type:
            - integer
            - 'null'
        height:
          type:
            - integer
            - 'null'
        tags:
          type: array
          items:
            $ref: '#/components/schemas/LegacyMediaTag'
        exists:
          type: boolean
          description: Whether the file currently exists.
        duplicateCount:
          type: integer
          description: Declared but never populated by this endpoint; always `0`.
    LegacyMediaTag:
      type: object
      required:
        - label
        - category
      properties:
        label:
          type: string
        category:
          type: string
    EmptyObject:
      type: object
      description: An empty object, `{}`.
      additionalProperties: false
    LabelBody:
      type: object
      properties:
        label:
          type: string
          description: Tag label.
    RenameBody:
      type: object
      properties:
        label:
          type: string
          description: Current label.
        newLabel:
          type: string
          description: New label.
    WeightBody:
      type: object
      properties:
        label:
          type: string
          description: Tag label.
        weight:
          type: number
          description: Sort weight.
    TaxonomyCategory:
      type: object
      required:
        - label
        - weight
        - tagViewMode
        - tags
      properties:
        label:
          type: string
        weight:
          type: number
          description: Sort weight.
        tagViewMode:
          type: string
          description: How the viewer lists the tags (`card` when unset).
        tags:
          type: array
          items:
            type: object
            required:
              - label
              - category
              - weight
            properties:
              label:
                type: string
              category:
                type: string
              weight:
                type: number
    CategorySummary:
      type: object
      required:
        - label
        - weight
        - description
        - tagViewMode
        - tagGrouping
      properties:
        label:
          type: string
        weight:
          type: number
          description: Sort weight.
        description:
          type: string
          description: Empty string when unset.
        tagViewMode:
          type: string
          description: '`card` when unset.'
        tagGrouping:
          type: string
          enum:
            - 'on'
            - 'off'
          description: Whether the category's tags are listed under their groups.
    TaxonomyTag:
      type: object
      required:
        - label
        - category
        - weight
      properties:
        label:
          type: string
        category:
          type: string
          description: Empty string for a tag with no category.
        weight:
          type: number
        group:
          type:
            - string
            - 'null'
          description: 'Present only with `?category=`: the tag''s group, or `null` when ungrouped.'
    TagDetail:
      type: object
      required:
        - label
        - category
        - weight
        - description
        - thumbnail_path_600
        - group
      properties:
        label:
          type: string
        category:
          type: string
          description: Empty string for a tag with no category.
        weight:
          type: number
        description:
          type: string
          description: Empty string when unset.
        thumbnail_path_600:
          type:
            - string
            - 'null'
          description: Stored preview thumbnail path.
        group:
          type:
            - string
            - 'null'
          description: The tag's group.
    TagWithCount:
      type: object
      required:
        - label
        - category
        - weight
        - media_count
      properties:
        label:
          type: string
        category:
          type: string
          description: Empty string for a tag with no category.
        weight:
          type: number
        media_count:
          type: integer
          description: Distinct media paths carrying the tag.
    CategoryImpact:
      type: object
      description: What deleting a category removes (or removed).
      required:
        - label
        - exists
        - tags
        - groups
        - assignments
        - media
      properties:
        label:
          type: string
        exists:
          type: boolean
          description: Whether the category exists.
        tags:
          type: integer
          description: Tags filed under the category.
        groups:
          type: integer
          description: Tag groups in the category.
        assignments:
          type: integer
          description: Assignment rows removed with them.
        media:
          type: integer
          description: Distinct media items those assignments are on.
    TagGroupRef:
      type: object
      required:
        - categoryLabel
        - label
      properties:
        categoryLabel:
          type: string
          description: The group's category.
        label:
          type: string
          description: Group label.
    TagGroup:
      type: object
      required:
        - label
        - weight
        - hidden
        - viewMode
      properties:
        label:
          type: string
        weight:
          type: number
          description: Sort weight.
        hidden:
          type: boolean
          description: Left out of the taxonomy panel unless hidden groups are shown.
        viewMode:
          type:
            - string
            - 'null'
          enum:
            - card
            - list
            - null
          description: Override of the category's tag view mode; `null` follows the category.
    IndexStatus:
      type: object
      required:
        - index
        - active_model
        - media_total
        - missing_active_model
        - orphaned
        - embeddings
        - total_count
        - total_bytes
      properties:
        index:
          type: object
          description: The in-memory search index.
          required:
            - installed
            - model
            - vectors
          properties:
            installed:
              type: boolean
              description: An index is loaded.
            model:
              type: string
              description: Model the index was built from; empty when none.
            vectors:
              type: integer
              description: Vectors in the index.
        active_model:
          type: string
          description: The configured embedding model.
        media_total:
          type: integer
          description: Rows in the media table.
        missing_active_model:
          type: integer
          description: Media items with no embedding for the active model.
        orphaned:
          type: integer
          description: Embedding rows whose media row is gone.
        embeddings:
          type: array
          description: Stored rows per model.
          items:
            type: object
            required:
              - model
              - count
              - dim
              - bytes
            properties:
              model:
                type: string
              count:
                type: integer
              dim:
                type: integer
              bytes:
                type: integer
                description: Stored vector bytes.
        total_count:
          type: integer
          description: Embedding rows across all models.
        total_bytes:
          type: integer
          description: Stored vector bytes across all models.
    EmbedModelInfo:
      type: object
      required:
        - id
        - display_name
        - dim
        - multimodal
        - active
        - indexed
      properties:
        id:
          type: string
        display_name:
          type: string
        dim:
          type: integer
          description: Vector dimension.
        multimodal:
          type: boolean
          description: Supports text queries in the same space.
        active:
          type: boolean
          description: The configured embedding model.
        indexed:
          type: boolean
          description: The model the in-memory index holds.
    StoredEmbeddingRow:
      type: object
      required:
        - model
        - dim
        - created_at
      properties:
        model:
          type: string
        dim:
          type: integer
        created_at:
          type: integer
          description: Stored creation time; the embed tasks currently write `0`.
        vector:
          type: array
          items:
            type: number
          description: Present only with `vector=true`.
    LibraryStats:
      type: object
      required:
        - ready
        - generatedAt
        - totalMedia
        - totalVideos
        - totalAudio
        - totalImages
        - totalTranscribable
        - withDescription
        - withHash
        - withSize
        - withTags
        - withDimensions
        - withTranscript
        - withEmbedding
        - withFaceScan
        - autotagModel
        - embedModel
        - faceModel
      properties:
        ready:
          type: boolean
          enum:
            - true
        generatedAt:
          type: integer
          description: Unix seconds when the snapshot was computed.
        totalMedia:
          type: integer
        totalVideos:
          type: integer
          description: 'By extension: .mp4 .mov .avi .mkv .wmv .webm.'
        totalAudio:
          type: integer
          description: 'By extension: .mp3 .wav .aac .ogg .m4a .wma .ape .flac .opus .aiff.'
        totalImages:
          type: integer
          description: Everything that is not video or audio.
        totalTranscribable:
          type: integer
          description: Videos plus audio; the transcript denominator.
        withDescription:
          type: integer
        withHash:
          type: integer
        withSize:
          type: integer
        withTags:
          type: integer
        withDimensions:
          type: integer
        withTranscript:
          type: integer
          description: Among videos and audio only.
        withEmbedding:
          type: integer
          description: For the active embedding model.
        withFaceScan:
          type: integer
          description: Scanned by any face model.
        autotagModel:
          $ref: '#/components/schemas/StatsModelInfo'
        embedModel:
          $ref: '#/components/schemas/StatsModelInfo'
        faceModel:
          $ref: '#/components/schemas/StatsModelInfo'
    StatsModelInfo:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: string
        name:
          type: string
          description: Display name.
    DBQueryResult:
      type: object
      required:
        - columns
        - rows
        - row_count
        - truncated
        - elapsed_ms
      properties:
        columns:
          type: array
          items:
            type: string
        rows:
          type: array
          description: 'One array per row, cells in column order. A BLOB cell is `{"blob_bytes": n}`.'
          items:
            type: array
            items: {}
        row_count:
          type: integer
          description: Rows returned.
        truncated:
          type: boolean
          description: More rows existed beyond `limit`.
        elapsed_ms:
          type: integer
          description: Query and read time in milliseconds.
    FSEntry:
      type: object
      required:
        - name
        - path
        - isDir
        - mtimeMs
        - size
      properties:
        name:
          type: string
        path:
          type: string
        isDir:
          type: boolean
        mtimeMs:
          type: number
          description: Modification time in milliseconds since the epoch (`0` for roots).
        size:
          type: integer
          description: Bytes; `0` for directories.
        type:
          type: string
          enum:
            - local
            - s3
          description: Present only when listing the roots.
    UploadResult:
      type: object
      required:
        - success
        - files
      properties:
        success:
          type: boolean
        files:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Stored paths; `null` on failure.
        message:
          type: string
          description: Present on success.
        error:
          type: string
          description: Present on failure.
    ImportResult:
      type: object
      required:
        - imported
        - skipped
        - files
        - dest_root
      properties:
        imported:
          type: integer
          description: New media items merged in.
        skipped:
          type: integer
          description: Items already present, skipped whole.
        files:
          type: integer
          description: File bytes copied into the destination root.
        dest_root:
          type: string
          description: Path of the destination storage root.
        warnings:
          type: array
          items:
            type: string
          description: Non-fatal problems; omitted when empty.
    TreemapNode:
      type: object
      required:
        - name
        - path
        - count
        - covered
      properties:
        name:
          type: string
          description: Folder name (empty at the library root).
        path:
          type: string
          description: Folder path exactly as stored; pass it back as `path` to drill down.
        count:
          type: integer
          description: Metric rows in the subtree.
        covered:
          type: integer
          description: Distinct items in the subtree that have rows.
        direct:
          type: integer
          description: Rows for files directly in this folder. Omitted when 0.
        itemCount:
          type: integer
          description: Items in this folder's subtree (non-`items` metrics only). Omitted when 0.
        children:
          type: array
          items:
            $ref: '#/components/schemas/TreemapNode'
          description: Largest first, up to `limit`. Omitted at the depth limit or when empty.
        otherCount:
          type: integer
          description: Rows in children beyond `limit`. Omitted when 0.
        otherDirs:
          type: integer
          description: Number of children beyond `limit`. Omitted when 0.
    Person:
      type: object
      description: >-
        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.
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          description: >-
            Stored name. May end in `_cluster` when a tag in another category owns the plain name;
            UIs strip the suffix for display.
        coverFaceId:
          type: integer
          format: int64
          description: >-
            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.
        faceCount:
          type: integer
          description: Faces assigned to the person.
        mediaCount:
          type: integer
          description: Distinct media items containing the person.
        lockedCount:
          type: integer
          description: Faces that are user assignments (confirmed); these survive every recluster.
        createdAt:
          type: integer
          format: int64
          description: Unix seconds. Omitted when unknown.
        models:
          type: string
          description: >-
            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.
      required:
        - id
        - name
        - faceCount
        - mediaCount
        - lockedCount
    Face:
      type: object
      description: >-
        One stored face detection. Bounding-box coordinates are relative (0-1 of the image size).
        Crops are served by `/media/facecrop?id=`.
      properties:
        id:
          type: integer
          format: int64
        x:
          type: number
          description: Left edge (relative).
        'y':
          type: number
          description: Top edge (relative).
        w:
          type: number
          description: Width (relative).
        h:
          type: number
          description: Height (relative).
        score:
          type: number
          description: Detection confidence.
        frameTs:
          type: number
          description: Video frame time in seconds (0 for images).
        personId:
          type: integer
          format: int64
          description: Assigned person; 0 when unassigned.
        assignedBy:
          type: string
          enum:
            - auto
            - user
            - ''
          description: '`auto` (clustering), `user` (confirmed by hand), or empty when unassigned.'
      required:
        - id
        - x
        - 'y'
        - w
        - h
        - score
        - frameTs
        - personId
        - assignedBy
    PersonReviewFace:
      type: object
      description: One face in the person review payload.
      properties:
        id:
          type: integer
          format: int64
          description: Face id.
        path:
          type: string
          description: Media path.
        frameTs:
          type: number
          description: Video frame time in seconds (0 for images).
        detScore:
          type: number
          description: Detection confidence.
        assignedBy:
          type: string
          enum:
            - auto
            - user
          description: '`auto` (clustering) or `user` (confirmed).'
        typicality:
          type: number
          description: >-
            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).
      required:
        - id
        - path
        - frameTs
        - detScore
        - assignedBy
        - typicality
    UngroupedFace:
      type: object
      description: One face not yet assigned to any person.
      properties:
        id:
          type: integer
          format: int64
          description: Face id.
        path:
          type: string
          description: Media path.
        frameTs:
          type: number
          description: Video frame time in seconds (0 for images).
        detScore:
          type: number
          description: Detection confidence.
        model:
          type: string
          description: Recognizer id.
      required:
        - id
        - path
        - frameTs
        - detScore
        - model
    FaceHitItem:
      type: object
      description: >-
        A `ScoredMediaItem` (one per media path) with the best matching face attached. `score` is
        that face's cosine similarity.
      allOf:
        - $ref: '#/components/schemas/ScoredMediaItem'
        - type: object
          properties:
            faceId:
              type: integer
              format: int64
              description: The matching face.
            face:
              type: object
              description: The matching face's relative bounding box.
              properties:
                x:
                  type: number
                'y':
                  type: number
                w:
                  type: number
                h:
                  type: number
                frameTs:
                  type: number
                  description: Video frame time in seconds.
                personId:
                  type: integer
                  format: int64
                  description: Assigned person; 0 when unassigned.
              required:
                - x
                - 'y'
                - w
                - h
                - frameTs
                - personId
          required:
            - faceId
            - face
    FaceTuning:
      type: object
      description: Face-grouping tuner (the People panel Tune sliders), stored in the server config.
      properties:
        thresholdOffset:
          type: number
          description: Added to the clustering similarity threshold, -0.2 to 0.3. Default 0.
        minCluster:
          type: integer
          description: Minimum faces for a new group, 1-50. Default 3.
        minQuality:
          type: number
          description: Minimum face quality, between 0 and 1 exclusive. Default 0.75.
      required:
        - thresholdOffset
        - minCluster
        - minQuality
    FaceStats:
      type: object
      properties:
        faces:
          type: array
          description: Per recognizer, ordered by model id.
          items:
            type: object
            properties:
              model:
                type: string
                description: Recognizer id.
              count:
                type: integer
                description: Stored faces.
              bytes:
                type: integer
                format: int64
                description: Total vector blob bytes.
            required:
              - model
              - count
              - bytes
        total_faces:
          type: integer
        total_bytes:
          type: integer
          format: int64
        scans:
          type: integer
          description: Face-scan markers (items scanned, including those with no faces).
        people:
          type: object
          properties:
            total:
              type: integer
            named:
              type: integer
              description: People with a non-empty name.
            unnamed:
              type: integer
          required:
            - total
            - named
            - unnamed
        index:
          type: object
          description: The in-memory face search index.
          properties:
            model:
              type: string
              description: Recognizer the index holds; empty when not built.
            vectors:
              type: integer
              description: Vectors in the index.
          required:
            - model
            - vectors
      required:
        - faces
        - total_faces
        - total_bytes
        - scans
        - people
        - index
    DuplicateGroup:
      type: object
      description: >-
        A cluster of media whose visual embeddings are at least `threshold` similar to the anchor
        item.
      properties:
        id:
          type: integer
          format: int64
        model:
          type: string
          description: Embedding model the scan used.
        threshold:
          type: number
          description: Similarity floor the scan used, 0-1.
        anchorPath:
          type: string
          description: The item every member score is measured against.
        keeperPath:
          type: string
          description: >-
            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.
        status:
          type: string
          enum:
            - pending
            - dismissed
        createdAt:
          type: integer
          format: int64
          description: Unix seconds.
        updatedAt:
          type: integer
          format: int64
          description: Unix seconds.
        memberCount:
          type: integer
          description: Every member row, excluded ones included.
        activeCount:
          type: integer
          description: Members not excluded.
        minScore:
          type: number
          description: Lowest active member similarity to the anchor (how tight the group is).
        members:
          type: array
          items:
            $ref: '#/components/schemas/DuplicateMember'
          description: Anchor first, then by similarity, excluded members last.
      required:
        - id
        - model
        - threshold
        - anchorPath
        - keeperPath
        - status
        - createdAt
        - updatedAt
        - memberCount
        - activeCount
        - minScore
        - members
      example:
        id: 311
        model: siglip2
        threshold: 0.97
        anchorPath: D:/photos/IMG_0001.jpg
        keeperPath: D:/photos/IMG_0001.jpg
        status: pending
        createdAt: 1761900000
        updatedAt: 1761900000
        memberCount: 2
        activeCount: 2
        minScore: 0.985
        members:
          - path: D:/photos/IMG_0001.jpg
            mtimeMs: 0
            width: 4032
            height: 3024
            battles: 0
            size: 3311221
            score: 1
            excluded: false
            anchor: true
            keeper: true
          - path: D:/photos/IMG_0001 (1).jpg
            mtimeMs: 0
            width: 1600
            height: 1200
            size: 402113
            score: 0.985
            excluded: false
            anchor: false
            keeper: false
    DuplicateMember:
      type: object
      description: >-
        A group member in the flat renderer item shape plus review flags. `elo`, `battles`, `width`,
        `height` and `size` are omitted when unknown.
      properties:
        path:
          type: string
        mtimeMs:
          type: integer
          description: Always 0.
        elo:
          type: number
        battles:
          type: integer
        width:
          type: integer
        height:
          type: integer
        size:
          type: integer
          format: int64
          description: File size in bytes; present only when known and non-zero.
        score:
          type: number
          description: Cosine similarity to the anchor (the anchor scores 1).
        excluded:
          type: boolean
          description: Pulled out of the group ("not a duplicate").
        anchor:
          type: boolean
          description: This member is the anchor.
        keeper:
          type: boolean
          description: This member is the default merge target.
      required:
        - path
        - mtimeMs
        - score
        - excluded
        - anchor
        - keeper
    DuplicateStats:
      type: object
      properties:
        pending:
          type: integer
          description: Pending groups.
        dismissed:
          type: integer
          description: Dismissed groups.
        members:
          type: integer
          description: Member rows across all groups.
        pendingItems:
          type: integer
          description: 'Active members of pending groups: library items still awaiting a decision.'
      required:
        - pending
        - dismissed
        - members
        - pendingItems
    DuplicateMergeResult:
      type: object
      description: What a merge changed. The same shape as the `POST /api/media/merge-metadata` response.
      properties:
        target:
          type: string
          description: The kept path.
        sources:
          type: array
          items:
            type: string
          description: Paths merged in.
        tags:
          type: integer
          format: int64
          description: Tag rows the target gained.
        embeddings:
          type: integer
          format: int64
          description: Embedding rows the target gained.
        transcript:
          type: boolean
          description: >-
            The target's transcript was filled from a source, or a source's `.vtt` sidecar was moved
            next to it.
        transcriptFile:
          type: string
          description: The moved `.vtt` path; empty when none.
        deleted:
          type:
            - array
            - 'null'
          items:
            type: string
          description: Sources removed from disk and database. `null` when none.
        failed:
          type:
            - array
            - 'null'
          items:
            type: string
          description: >-
            Sources that could not be deleted (s3:// paths, delete errors); they keep their rows.
            `null` when none.
        facesRemoved:
          type: integer
          format: int64
          description: Face rows erased with the sources.
      required:
        - target
        - sources
        - tags
        - embeddings
        - transcript
        - transcriptFile
        - deleted
        - failed
        - facesRemoved
    SwipeMediaTag:
      type: object
      properties:
        label:
          type: string
        category:
          type: string
      required:
        - label
        - category
    SwipeMediaItem:
      type: object
      description: A media row as the Swipe app receives it.
      properties:
        path:
          type: string
        description:
          type:
            - string
            - 'null'
          description: AI description; `null` when none.
        size:
          type:
            - integer
            - 'null'
          format: int64
          description: File size in bytes.
        hash:
          type:
            - string
            - 'null'
          description: Content hash.
        width:
          type:
            - integer
            - 'null'
        height:
          type:
            - integer
            - 'null'
        tags:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/SwipeMediaTag'
          description: May be `null` when the item has no tags.
        exists:
          type: boolean
          description: Whether the file exists on disk.
        duplicateCount:
          type: integer
          format: int64
          description: Other items with the same hash.
      required:
        - path
        - description
        - size
        - hash
        - width
        - height
        - tags
        - exists
        - duplicateCount
    SwipeResponse:
      type: object
      properties:
        items:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/SwipeMediaItem'
          description: The page. The shuffle mode returns `null` (not an empty array) when the pool is empty.
        has_more:
          type: boolean
          description: More items exist past this page.
        total_count:
          type: integer
          description: Not populated by this endpoint; always 0.
      required:
        - items
        - has_more
        - total_count
    SwipeManifest:
      type: object
      description: Web app manifest (fixed content).
      properties:
        name:
          type: string
        short_name:
          type: string
        description:
          type: string
        start_url:
          type: string
        display:
          type: string
        orientation:
          type: string
        background_color:
          type: string
        theme_color:
          type: string
        icons:
          type: array
          items:
            type: object
            properties:
              src:
                type: string
              sizes:
                type: string
              type:
                type: string
              purpose:
                type: string
            required:
              - src
              - sizes
              - type
              - purpose
      required:
        - name
        - short_name
        - description
        - start_url
        - display
        - orientation
        - background_color
        - theme_color
        - icons
    PromptReference:
      type: object
      description: One image, clip or audio file the model sees, with the token the prompt uses for it.
      properties:
        token:
          type: string
          description: For example `<image1>` (retouch) or `<Picture 1>`, `<Video 1>`, `<Audio 1>` (reshoot).
        path:
          type: string
        kind:
          type: string
          enum:
            - image
            - video-frame
            - video
            - audio
      required:
        - token
        - path
        - kind
    PromptPreview:
      type: object
      description: The expanded prompt of the first run of a job.
      properties:
        engine:
          type: string
          enum:
            - loki-retouch
            - loki-reshoot
        prompt:
          type: string
          description: >-
            The instruction as the engine expands it (empty when a preset needs the engine and it is
            not installed).
        modelInput:
          type: string
          description: >-
            Retouch only: the whole text-encoder turn, every reference announced by its token, then
            the prompt. Omitted when empty.
        references:
          type: array
          items:
            $ref: '#/components/schemas/PromptReference'
        runs:
          type: integer
          description: Engine runs the job makes (one per image in a retouch batch; reshoot is always 1).
        fromEngine:
          type: boolean
          description: False when the engine is not installed and the prompt was assembled by the server.
        note:
          type: string
          description: >-
            Explanation when the prompt is partial or shown for the first of several images. Omitted
            when empty.
      required:
        - engine
        - prompt
        - references
        - runs
        - fromEngine
    ReshootModelChoice:
      type: object
      properties:
        file:
          type: string
          description: Model file name; pass it as the job's `dit` option.
        label:
          type: string
          description: Display name.
        recipe:
          type: string
          enum:
            - h3
            - fast
          description: Sampling recipe the model uses.
      required:
        - file
        - label
        - recipe
    DirectMLInstallResult:
      type: object
      properties:
        installed:
          type: boolean
          description: Whether the DirectML runtime is present in the data directory.
        error:
          type: string
          description: 'POST only: why the install failed.'
      required:
        - installed
    EnginesConfig:
      type: object
      description: The `engines` section of the server config.
      properties:
        strategy:
          type: string
          enum:
            - local
            - pool
            - hybrid
          default: local
          description: >-
            Where engines run unless routed otherwise. Omitted when empty; anything unknown is saved
            as `local`.
        pools:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/WorkerPool'
          description: Worker pools; `null` when there are none.
        routes:
          type:
            - object
            - 'null'
          additionalProperties:
            type: string
          description: >-
            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.
        fallback:
          type: boolean
          description: >-
            With strategy `pool`, lets a run use this machine's GPU when no pool worker is reachable
            (`hybrid` always may).
        localOnly:
          type: boolean
          description: >-
            Overrides the strategy and every route so all engines run on this machine while pools
            stay configured.
      required:
        - pools
        - routes
        - fallback
        - localOnly
    WorkerPool:
      type: object
      description: A group of interchangeable GPU workers.
      properties:
        name:
          type: string
          description: Unique (case-insensitive) pool name. Pools with a blank name are dropped.
        kind:
          type: string
          enum:
            - nodes
            - runpod
          description: '`nodes` = loki-worker HTTP servers; `runpod` = a RunPod queue-based serverless endpoint.'
        disabled:
          type: boolean
          description: Kept in the config but never used.
        engines:
          type: array
          items:
            type: string
          description: Engines this pool serves; empty means every engine its workers report.
        nodes:
          type: array
          items:
            $ref: '#/components/schemas/WorkerNode'
          description: '`nodes` pools: the workers.'
        endpointId:
          type: string
          description: '`runpod` pools: the endpoint id. A RunPod pool without one is ignored.'
        apiKey:
          type: string
          description: '`runpod` pools: the RunPod API key. **Redacted** (`<redacted>`) in responses.'
        maxJobs:
          type: integer
          default: 4
          description: >-
            `runpod` pools: jobs in flight on the endpoint (match its max workers). <= 0 is saved as
            4.
        transfer:
          $ref: '#/components/schemas/PoolTransfer'
        provider:
          $ref: '#/components/schemas/PoolProvider'
        fromEnv:
          type: boolean
          description: >-
            Read-only: the pool comes from `LOWKEY_ENGINES` or `LOWKEY_WORKER_NODES` and is never
            written to the config file.
      required:
        - name
        - kind
    WorkerNode:
      type: object
      description: One loki-worker HTTP server.
      properties:
        name:
          type: string
          description: Display name; defaults to the URL.
        url:
          type: string
          description: Base URL (trailing `/` removed). Nodes without one are dropped.
        token:
          type: string
          description: Bearer token for the worker. **Redacted** in responses.
        disabled:
          type: boolean
          description: Stays listed but gets no work (draining).
      required:
        - url
    PoolTransfer:
      type: object
      description: >-
        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).
      properties:
        root:
          type: string
          description: Label of an S3 storage root to use.
        endpoint:
          type: string
          description: S3 endpoint URL.
        region:
          type: string
        bucket:
          type: string
        prefix:
          type: string
          description: Key prefix inside the bucket.
        accessKey:
          type: string
          description: '**Redacted** in responses.'
        secretKey:
          type: string
          description: '**Redacted** in responses.'
    PoolProvider:
      type: object
      description: What `lokictl pool create` provisioned, so `lokictl pool destroy` can take it down.
      properties:
        name:
          type: string
          description: Provider, for example `runpod`.
        podIds:
          type: array
          items:
            type: string
        templateId:
          type: string
        endpointId:
          type: string
      required:
        - name
    EngineStatus:
      type: object
      description: The dispatcher's state.
      properties:
        strategy:
          type: string
          enum:
            - local
            - pool
            - hybrid
        routes:
          type: object
          additionalProperties:
            type: string
          description: The configured routes; omitted when empty.
        fallback:
          type: boolean
        localOnly:
          type: boolean
        placement:
          type: object
          additionalProperties:
            type: string
          description: >-
            Effective rule per engine (`loki-retouch`, `loki-reshoot`, `loki-dream`,
            `loki-segment`): `local`, `pool`, `pool:<name>` or `hybrid`.
        targets:
          type: array
          items:
            $ref: '#/components/schemas/EngineTargetStatus'
          description: >-
            Every place engines can run; the first is always this machine (`key` `local`). Disabled
            pools and nodes are not listed.
      required:
        - strategy
        - fallback
        - localOnly
        - placement
        - targets
    EngineTargetStatus:
      type: object
      description: One place engines run.
      properties:
        key:
          type: string
          description: '`local`, `<pool>/<node name>` for a node, or `<pool>` for a RunPod endpoint.'
        pool:
          type: string
          description: Pool name; omitted for `local`.
        kind:
          type: string
          enum:
            - local
            - node
            - runpod
        url:
          type: string
          description: Node base URL (nodes only).
        healthy:
          type: boolean
          description: Last probe succeeded; always true for `local`.
        checked:
          type: string
          format: date-time
          description: Last probe time; omitted until probed.
        error:
          type: string
          description: Last probe error.
        running:
          type: integer
          description: Jobs this server has in flight on the target.
        slots:
          type: integer
          description: >-
            Jobs it runs at once: 1 for local, `maxJobs` for RunPod, the worker's reported slots
            (default 1) for a node.
        info:
          $ref: '#/components/schemas/WorkerInfo'
        runpod:
          $ref: '#/components/schemas/RunPodHealth'
        engines:
          type: array
          items:
            type: string
          description: The pool's `engines` restriction, when set.
        transfer:
          type: string
          description: >-
            How files reach a RunPod worker: `storage root <label>`, `bucket <name>` or `inline
            (files up to 7 MB)`.
      required:
        - key
        - kind
        - healthy
        - running
        - slots
    WorkerInfo:
      type: object
      description: A loki-worker's self-description (its `GET /v1/info`).
      properties:
        worker:
          type: string
          description: Worker name.
        version:
          type: string
        protocol:
          type: integer
          description: Wire protocol version.
        mode:
          type: string
        ready:
          type: boolean
        gpus:
          type: array
          items:
            type: object
            properties:
              index:
                type: integer
              name:
                type: string
              totalMb:
                type: integer
              freeMb:
                type: integer
              busy:
                type: string
                description: Id of the job running on it.
            required:
              - index
        tools:
          type: array
          items:
            type: object
            properties:
              name:
                type: string
                description: Engine name.
              version:
                type: string
              model:
                type: string
              path:
                type: string
                description: Executable on the worker; empty when not installed.
              states:
                type: array
                items:
                  type: string
                  enum:
                    - stopped
                    - starting
                    - idle
                    - busy
                description: State per GPU slot.
              resident:
                type: array
                items:
                  type: object
                  properties:
                    key:
                      type: string
                    mb:
                      type: integer
                  required:
                    - key
                    - mb
                description: Models resident on the GPU.
              help:
                type: string
                description: The engine's `--help` text (only when requested).
            required:
              - name
        slots:
          type: integer
          description: GPU jobs run at once (one per GPU).
        running:
          type: integer
        queued:
          type: integer
        uptime:
          type: number
          description: Seconds.
        sessions:
          type: integer
          description: Open interactive sessions.
      required:
        - worker
        - version
        - protocol
        - mode
        - ready
        - gpus
        - tools
        - slots
        - running
        - queued
        - uptime
        - sessions
    RunPodHealth:
      type: object
      description: A RunPod endpoint's queue and worker counts.
      properties:
        jobs:
          type: object
          properties:
            completed:
              type: integer
            failed:
              type: integer
            inProgress:
              type: integer
            inQueue:
              type: integer
            retried:
              type: integer
          required:
            - completed
            - failed
            - inProgress
            - inQueue
            - retried
        workers:
          type: object
          properties:
            idle:
              type: integer
            initializing:
              type: integer
            ready:
              type: integer
            running:
              type: integer
            throttled:
              type: integer
            unhealthy:
              type: integer
          required:
            - idle
            - initializing
            - ready
            - running
            - throttled
            - unhealthy
      required:
        - jobs
        - workers
    LocalEngine:
      type: object
      description: One loki-* engine as installed on this machine.
      properties:
        engine:
          type: string
          description: Engine executable name.
        tool:
          $ref: '#/components/schemas/EngineDependencyItem'
        version:
          type: string
          description: The binary's `--version`, when installed.
        models:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/EngineDependencyItem'
          description: The model dependencies it runs with.
      required:
        - engine
        - tool
        - models
    EngineDependencyItem:
      type: object
      description: A dependency (tool or model) and its install state, as on the Dependencies page.
      properties:
        id:
          type: string
        category:
          type: string
        name:
          type: string
        feature:
          type: string
        description:
          type: string
        state:
          type: string
          description: Install state, for example `installed` or `missing`.
        version:
          type: string
        size_bytes:
          type: integer
        path:
          type: string
        error:
          type: string
        detail:
          description: Extra state detail (any JSON).
        source:
          type: string
          description: '`user` when it is the user''s own copy; omitted when managed.'
      required:
        - id
        - category
        - name
        - state
    SceneRect:
      type: object
      description: A rectangle in pixels (base-frame pixels unless stated otherwise).
      properties:
        x:
          type: integer
        'y':
          type: integer
        w:
          type: integer
        h:
          type: integer
      required:
        - x
        - 'y'
        - w
        - h
    SceneMask:
      type: object
      description: A mask as the editor draws it (opaque to the server).
      properties:
        shapes:
          description: The editor's strokes (any JSON).
        maskOpts:
          description: The editor's whole-mask settings (invert, expand, opacity; any JSON).
        feather:
          type: number
          description: Feather radius in pixels.
      required:
        - feather
    SceneGenMask:
      type: object
      description: >-
        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.
      properties:
        shapes:
          description: Editor strokes (any JSON).
        maskOpts:
          description: Editor mask settings (any JSON).
        feather:
          type: number
          description: Clamped to 0-512.
        mask:
          type: string
          description: Full-frame PNG data URL (`data:image/png;base64,...`); alpha = coverage.
        region:
          $ref: '#/components/schemas/SceneRect'
      required:
        - mask
        - region
    SceneTrack:
      type: object
      description: A motion version's roto (server-owned).
      properties:
        jobId:
          type: string
          description: The `scene-track` job.
        state:
          $ref: '#/components/schemas/JobState'
        done:
          type: integer
        total:
          type: integer
        error:
          type: string
        prompts:
          type: array
          items:
            type: object
          description: The loki-segment `--prompts` entries it was tracked from.
        dir:
          type: string
          description: Folder of matte frames `00000.png`, ... (set once completed).
        frames:
          type: integer
          description: Number of matte frames (set once completed).
        video:
          type: string
          description: The clip with the matte as alpha (VP9 webm), for the stage.
      required:
        - jobId
        - state
        - done
        - total
        - prompts
    StitchReport:
      type: object
      description: Measurements of a stitch.
      properties:
        band:
          type: integer
        drift:
          type: number
          description: Mean displacement corrected near the edge, px.
        driftMax:
          type: number
          description: 95th percentile of the drift, px.
        tone:
          type: number
          description: Overall colour shift corrected (0-255 RGB distance).
        grainAdded:
          type: number
          description: Noise added (0-255 standard deviation).
        seamLength:
          type: integer
          description: px.
        residual:
          type: number
          description: What the seam still adds (0-255 excess gradient, mean along it).
        healPixels:
          type: integer
          description: Pixels where the seam still shows.
        skipped:
          type: string
          description: Why the stitch was skipped.
      required:
        - band
        - drift
        - driftMax
        - tone
        - grainAdded
        - seamLength
        - residual
        - healPixels
    SceneStitch:
      type: object
      description: The active still version blended into the picture under it (derived on every read).
      properties:
        path:
          type: string
          description: The stitched patch.
        seam:
          type: string
          description: Image drawing the seam for the editor.
        heal:
          type: string
          description: Where the seam still shows (input for `heal`).
        key:
          type: string
          description: Hash of the version, mask and everything below it.
        report:
          $ref: '#/components/schemas/StitchReport'
      required:
        - path
        - key
        - report
    SceneVersion:
      type: object
      description: One generation of a layer. Server-owned except `mask`.
      properties:
        path:
          type: string
          description: The file the engine produced (PNG for stills, MP4 for motion).
        region:
          $ref: '#/components/schemas/SceneRect'
        prompt:
          type: string
        duration:
          type: number
          description: 'Motion: seconds.'
        audio:
          type: boolean
          description: 'Motion: has a soundtrack.'
        note:
          type: string
          description: For example `boomerang of v2`, `re-finished v1`, `healed v3`.
        raw:
          type: string
          description: The engine's own output a finished (scene-lock) version was made from.
        jobId:
          type: string
        below:
          type: array
          items:
            type: string
          description: >-
            The stills the version was generated from, so the editor can tell when the stack under
            it changed.
        inputs:
          type: array
          items:
            type: string
          description: Images the engine was given (the crop, then references).
        track:
          $ref: '#/components/schemas/SceneTrack'
        mask:
          $ref: '#/components/schemas/SceneMask'
        stitch:
          $ref: '#/components/schemas/SceneStitch'
        created:
          type: string
          format: date-time
      required:
        - path
        - region
        - prompt
        - created
    SceneJob:
      type: object
      description: A layer's generation in flight (server-owned).
      properties:
        id:
          type: string
          description: The last job of the chain, whose output is the result.
        state:
          $ref: '#/components/schemas/JobState'
        done:
          type: integer
        total:
          type: integer
        region:
          $ref: '#/components/schemas/SceneRect'
        prompt:
          type: string
        input:
          type: string
          description: The crop sent to the engine.
        chain:
          type: array
          items:
            type: string
          description: 'Every job of the generation in order (motion: `reshoot`, then `scene-lock`).'
        requeued:
          type: integer
          description: Times a later step was queued again after being cancelled before it ran.
        step:
          type: string
          description: Command of the step running now, for example `reshoot` or `scene-lock`.
        note:
          type: string
        raw:
          type: string
        line:
          type: string
          description: The job's latest log line.
        below:
          type: array
          items:
            type: string
        refs:
          type: array
          items:
            type: string
          description: Reference files the engine was given.
        mask:
          $ref: '#/components/schemas/SceneMask'
      required:
        - id
        - state
        - done
        - total
        - region
        - prompt
        - input
    SceneLayer:
      type: object
      description: |
        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.
      properties:
        id:
          type: string
        name:
          type: string
        kind:
          type: string
          enum:
            - still
            - motion
            - paint
        hidden:
          type: boolean
        region:
          $ref: '#/components/schemas/SceneRect'
        mask:
          type: string
          description: >-
            Full-frame PNG data URL (long edge <= 2048) whose alpha is the coverage, feather
            included. For paint layers, the painting itself (RGBA).
        shapes:
          description: The editor's strokes (any JSON).
        maskOpts:
          description: The editor's whole-mask settings (any JSON).
        feather:
          type: number
        prompt:
          type: string
        options:
          type: object
          additionalProperties: true
          description: >
            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)
        versions:
          type: array
          items:
            $ref: '#/components/schemas/SceneVersion'
        active:
          type: integer
          minimum: -1
          description: Index into `versions` of the one shown; -1 = none.
        job:
          $ref: '#/components/schemas/SceneJob'
        queued:
          type: array
          items:
            $ref: '#/components/schemas/SceneJob'
          description: Generations started while `job` was in flight, oldest first.
        error:
          type: string
      required:
        - id
        - name
        - kind
        - region
        - mask
        - feather
        - prompt
        - versions
        - active
    SceneRender:
      type: object
      description: The latest export (server-owned).
      properties:
        jobId:
          type: string
        format:
          type: string
          enum:
            - video
            - still
        state:
          $ref: '#/components/schemas/JobState'
        done:
          type: integer
        total:
          type: integer
        output:
          type: string
          description: The exported file, once completed.
        error:
          type: string
      required:
        - jobId
        - format
        - state
        - done
        - total
    Scene:
      type: object
      description: A scene document.
      properties:
        id:
          type: string
        name:
          type: string
          description: Defaults to the source file name without extension.
        source:
          type: string
          description: Absolute path of the source image or video.
        sourceTime:
          type: number
          description: 'Video sources: seconds of the base frame.'
        base:
          type: string
          description: Path of the base frame PNG.
        width:
          type: integer
        height:
          type: integer
        layers:
          type: array
          items:
            $ref: '#/components/schemas/SceneLayer'
          description: Bottom to top.
        duration:
          type: number
          description: Export length in seconds; 0 = the longest visible motion layer (5 s when none).
        freeLoops:
          type: boolean
          description: >-
            Each motion layer loops at its own length instead of being retimed so the export loops
            seamlessly.
        render:
          $ref: '#/components/schemas/SceneRender'
        stitching:
          type: boolean
          description: Stitches are being made; poll until false.
        created:
          type: string
          format: date-time
        updated:
          type: string
          format: date-time
      required:
        - id
        - name
        - source
        - base
        - width
        - height
        - layers
        - duration
        - created
        - updated
      example:
        id: 0b7e4c1a-3d2f-4a8e-9c61-5f2d8e7a1b90
        name: beach
        source: D:\photos\beach.jpg
        base: C:\ProgramData\Lowkey Media Server\scenes\0b7e4c1a-3d2f-4a8e-9c61-5f2d8e7a1b90\base.png
        width: 1920
        height: 1080
        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
            versions: []
            active: -1
            job:
              id: 9a1c0d2e-7b3f-4e55-8a6d-2c4b1e0f9a77
              state: in_progress
              done: 3
              total: 8
              region:
                x: 0
                'y': 600
                w: 1920
                h: 480
              prompt: gentle waves roll in
              input: ...\motion-l1-1.png
              step: reshoot
        duration: 0
        created: '2026-10-31T12:00:00Z'
        updated: '2026-10-31T12:05:00Z'
    SceneUpdate:
      type: object
      description: >-
        The fields `PUT /api/scenes/{id}` reads (a full `Scene` may be sent; other fields are
        ignored).
      properties:
        name:
          type: string
          description: Blank keeps the current name.
        duration:
          type: number
          minimum: 0
          maximum: 60
          description: Clamped to 0-60.
        freeLoops:
          type: boolean
        layers:
          type: array
          description: >-
            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.
          items:
            type: object
            properties:
              id:
                type: string
                maxLength: 64
              name:
                type: string
              kind:
                type: string
                enum:
                  - still
                  - motion
                  - paint
                description: Anything else becomes `still`.
              hidden:
                type: boolean
              region:
                $ref: '#/components/schemas/SceneRect'
              mask:
                type: string
                description: Empty, or a `data:image/png;base64,` URL; any other value keeps the stored mask.
              shapes:
                description: Any JSON.
              maskOpts:
                description: Any JSON.
              feather:
                type: number
                description: Clamped to 0-512.
              prompt:
                type: string
              options:
                type: object
                additionalProperties: true
              active:
                type: integer
                minimum: -1
                description: Ignored when out of range.
              versions:
                type: array
                description: Only each entry's `mask` is taken, matched to a stored version by `path`.
                items:
                  type: object
                  properties:
                    path:
                      type: string
                    mask:
                      $ref: '#/components/schemas/SceneMask'
            required:
              - id
    SceneSummary:
      type: object
      description: A scene in a list (no masks).
      properties:
        id:
          type: string
        name:
          type: string
        source:
          type: string
        base:
          type: string
        layers:
          type: integer
          description: Number of layers.
        motion:
          type: integer
          description: Number of motion layers.
        updated:
          type: string
          format: date-time
      required:
        - id
        - name
        - source
        - base
        - layers
        - motion
        - updated
    SceneRegionMap:
      type: object
      properties:
        dataUrl:
          type: string
          description: PNG data URL covering `region`; alpha = change or motion.
        region:
          $ref: '#/components/schemas/SceneRect'
      required:
        - dataUrl
        - region
    ScenePose:
      type: object
      properties:
        joints:
          type: array
          description: >-
            OpenPose's 18 joints in its order (nose, neck, right shoulder, ... left ear), frame
            pixels.
          items:
            type: object
            properties:
              x:
                type: number
              'y':
                type: number
              score:
                type: number
            required:
              - x
              - 'y'
              - score
      required:
        - joints
    SegmentRequest:
      type: object
      description: >-
        One pick. Coordinates are in the pixels of what is segmented: the picture (frame size), or
        with `layer` + `version` the clip.
      properties:
        text:
          type: string
          description: Text prompt (find objects by description).
        points:
          type: array
          items:
            type: array
            items:
              type: number
            minItems: 3
            maxItems: 3
          description: '`[x, y, 1|0]` (1 = this, 0 = not this).'
        box:
          type: array
          items:
            type: number
          description: '`[x, y, w, h]`; used only with exactly 4 numbers.'
        everything:
          type: boolean
          description: Segment every object.
        multimask:
          type: boolean
          description: Also return candidate readings (part / object / whole) for clicks and boxes.
        layer:
          type: string
          description: Motion layer id to segment a clip frame of.
        version:
          type: integer
          description: Version index of `layer`.
        frame:
          type: integer
          description: Clip frame (with `layer` + `version`).
    SegmentObject:
      type: object
      properties:
        id:
          type: integer
        label:
          type: string
        score:
          type: number
        bbox:
          $ref: '#/components/schemas/SceneRect'
        mask:
          type: string
          description: PNG data URL (white, alpha = coverage) covering `rect`.
        rect:
          $ref: '#/components/schemas/SceneRect'
        candidates:
          type: array
          items:
            $ref: '#/components/schemas/SegmentObject'
          description: Alternative readings, with `multimask`.
      required:
        - id
        - label
        - score
        - bbox
        - mask
        - rect
    SegmentResult:
      type: object
      properties:
        width:
          type: integer
          description: Width of what was segmented.
        height:
          type: integer
        objects:
          type:
            - array
            - 'null'
          items:
            $ref: '#/components/schemas/SegmentObject'
          description: '`null` when nothing was found.'
        ms:
          type: integer
          description: Segmentation time in milliseconds.
      required:
        - width
        - height
        - objects
        - ms
    SceneDreamRequest:
      type: object
      properties:
        prompt:
          type: string
          description: The picture to generate. Required (non-blank).
        negative:
          type: string
        size:
          type: string
          pattern: ^\d{3,4}x\d{3,4}$
          description: '`WxH`; ignored when it does not match.'
        turbo:
          type: boolean
        seed:
          type: integer
          description: Used when > 0.
      required:
        - prompt
    SceneDreamStatus:
      type: object
      properties:
        jobId:
          type: string
        state:
          $ref: '#/components/schemas/JobState'
        done:
          type: integer
        total:
          type: integer
        output:
          type: string
          description: The generated image, once completed.
        error:
          type: string
      required:
        - jobId
        - state
        - done
        - total
    DepStatusItem:
      type: object
      description: One dependency in `GET /api/deps/status`.
      properties:
        id:
          type: string
          description: Dependency id, e.g. `ffmpeg`, `yt-dlp`, `siglip2-base-patch16-224`, `loki-retouch`.
        category:
          type: string
          enum:
            - bundled
            - optional
            - model
            - tool
          description: >-
            `bundled` ships with the server; `optional` is a user-installed PATH tool; `model` and
            `tool` are downloadable from the models manifest.
        name:
          type: string
          description: Display name.
        feature:
          type: string
          description: >-
            User-facing capability this unlocks (optional, model and tool items; omitted when
            empty).
        description:
          type: string
          description: Longer explanation (optional, model and tool items; omitted when empty).
        state:
          type: string
          description: >
            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`).
          enum:
            - ready
            - missing
            - broken
            - installed
            - not_installed
            - queued
            - downloading
            - extracting
            - verifying
            - failed
            - cancelled
        version:
          type: string
          description: >-
            First line of the tool's version output (bundled and optional items; omitted when
            unknown).
        size_bytes:
          type: integer
          format: int64
          description: Download size for this OS (model and tool items).
        path:
          type: string
          description: >-
            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).
        error:
          type: string
          description: Why a bundled item is missing or broken, or why a tracked install failed.
        detail:
          description: |
            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.
          oneOf:
            - $ref: '#/components/schemas/OptionalToolHint'
            - $ref: '#/components/schemas/ModelInstall'
            - type: object
              properties:
                source:
                  type: string
                  enum:
                    - path
              required:
                - source
        source:
          type: string
          enum:
            - user
          description: >-
            `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.
      required:
        - id
        - category
        - name
        - state
    OptionalToolHint:
      type: object
      description: How to install an optional tool.
      properties:
        description:
          type: string
        commands:
          type: array
          description: Per-OS install commands (omitted when none).
          items:
            type: object
            properties:
              os:
                type: string
                enum:
                  - windows
                  - darwin
                  - linux
              label:
                type: string
                description: Installer name, e.g. `winget`, `Homebrew`.
              command:
                type: string
                description: Command line or instruction.
            required:
              - os
              - label
              - command
        docs_url:
          type: string
          description: Installation docs link.
    ModelInstall:
      type: object
      description: Snapshot of one model or tool install attempt (also each progress SSE event).
      properties:
        id:
          type: string
          description: Model id.
        state:
          type: string
          enum:
            - queued
            - downloading
            - extracting
            - verifying
            - installed
            - failed
            - cancelled
          description: >-
            `extracting` means archive members are being unpacked; byte counts then refer to the
            unpacked data, not the download.
        current_file:
          type: string
          description: >-
            File being processed; an archive being unpacked is reported as `<name> (extracting)`.
            Omitted when empty.
        bytes_done:
          type: integer
          format: int64
          description: Bytes processed of the current step.
        bytes_total:
          type: integer
          format: int64
          description: Total bytes of the current step (0 when unknown).
        error:
          type: string
          description: Failure message when `state` is `failed`. Omitted otherwise.
        started_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
      required:
        - id
        - state
        - bytes_done
        - bytes_total
        - started_at
        - updated_at
    ModelVerifyResult:
      type: object
      description: Result of `POST /api/deps/models/{id}/verify`.
      properties:
        id:
          type: string
        source:
          type: string
          enum:
            - user
          description: Set when the checked copy is the user's own. Omitted for managed downloads.
        location:
          type: string
          description: Where the user's copy lives. Only with `source`.
        note:
          type: string
          description: Explanation that only presence and size are checked for a user copy. Only with `source`.
        files:
          type: object
          description: |
            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.
          additionalProperties:
            type: string
      required:
        - id
        - files
    SetupModelGroup:
      type: object
      description: A feature card in the wizard's model step, mapping to downloadable model ids.
      properties:
        id:
          type: string
          description: |
            `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.
        title:
          type: string
        desc:
          type: string
        recommended:
          type: boolean
          description: Pre-selected in the wizard.
        sizeBytes:
          type: integer
          format: int64
          description: Sum of the models' download sizes for this OS, from the manifest.
        models:
          type: array
          items:
            type: string
          description: Model ids to download via `POST /api/deps/models/{id}/download`.
      required:
        - id
        - title
        - desc
        - recommended
        - sizeBytes
        - models
    SetupStorageRoot:
      type: object
      description: A storage root as saved in the config (`roots`).
      properties:
        type:
          type: string
          enum:
            - local
            - s3
          description: Empty is treated as `local` on save.
        path:
          type: string
          description: Absolute folder (local roots).
        label:
          type: string
          description: Display name.
        default:
          type: boolean
          description: Destination for uploads and downloads.
        endpoint:
          type: string
          description: S3 endpoint URL.
        region:
          type: string
        bucket:
          type: string
          description: Bucket name (required for S3 roots).
        prefix:
          type: string
          description: Key prefix inside the bucket.
        accessKey:
          type: string
        secretKey:
          type: string
          description: Redacted to `•••` in `GET /setup/api/state`.
        thumbnailPrefix:
          type: string
          description: Key prefix for thumbnails.
    SetupState:
      type: object
      description: Response of `GET /setup/api/state`.
      properties:
        setupComplete:
          type: boolean
          description: Whether first-run setup has been completed.
        defaultDBPath:
          type: string
          description: The database path the server uses when none is configured.
        activeDBPath:
          type: string
          description: The configured `dbPath` (may be empty).
        dataDir:
          type: string
          description: The server's data directory.
        homeDir:
          type: string
          description: Home directory of the user the server runs as (empty if unknown).
        os:
          type: string
          description: 'Go `GOOS`: `windows`, `darwin`, `linux`.'
        roots:
          type: array
          description: >-
            Configured storage roots (empty array when none), with any S3 `secretKey` replaced by
            `•••`.
          items:
            $ref: '#/components/schemas/SetupStorageRoot'
        hasRealUsers:
          type: boolean
          description: The active database has an account other than the bootstrap default admin.
        authed:
          type: boolean
          description: The request carried a valid credential.
        deps:
          type: array
          description: Same as `GET /api/deps/status`.
          items:
            $ref: '#/components/schemas/DepStatusItem'
        modelGroups:
          type: array
          items:
            $ref: '#/components/schemas/SetupModelGroup'
      required:
        - setupComplete
        - defaultDBPath
        - activeDBPath
        - dataDir
        - homeDir
        - os
        - roots
        - hasRealUsers
        - authed
        - deps
        - modelGroups
    SetupBrowseResult:
      type: object
      description: A folder listing for the wizard's picker.
      properties:
        path:
          type: string
          description: The cleaned path listed (empty for the starting locations).
        parent:
          type:
            - string
            - 'null'
          description: >-
            Folder to go "up" to. Empty string at a drive or filesystem root (meaning the starting
            locations); `null` when listing the starting locations.
        entries:
          type: array
          description: Subfolders (or starting locations), sorted by name.
          items:
            type: object
            properties:
              name:
                type: string
              path:
                type: string
                description: Absolute path.
            required:
              - name
              - path
        exists:
          type: boolean
          description: The folder exists (always true for the starting locations).
        writable:
          type: boolean
          description: A temp file could be created in the folder. False for the starting locations.
      required:
        - path
        - parent
        - entries
        - exists
        - writable
    OnboardingState:
      type: object
      properties:
        shown:
          type: boolean
          description: The tour has been shown and dismissed.
        dismissed_at:
          type: string
          format: date-time
          description: When it was dismissed (UTC). Omitted when not dismissed.
      required:
        - shown
  parameters:
    JobIdPath:
      name: id
      in: path
      required: true
      description: Job id (UUID, or the node id given to `POST /workflow`).
      schema:
        type: string
    DepModelId:
      name: id
      in: path
      required: true
      description: >-
        Model or tool id from the models manifest (the `id` of a `model` or `tool` item in `GET
        /api/deps/status`).
      schema:
        type: string
        example: siglip2-base-patch16-224
  responses:
    DepsUnauthorized:
      description: Only while *Allow Public Access* is on, when the request has no admin credential.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unauthorized
    SetupForbidden:
      description: Setup is complete and the request carries no valid credential.
      content:
        text/plain:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: setup is complete; authentication required
security:
  - bearer: []
  - apiKey: []
  - cookie: []
paths:
  /auth/login:
    post:
      tags:
        - Authentication
      operationId: login
      summary: Exchange a username and password for a JWT.
      description: >
        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.
      x-access: public
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username:
                  type: string
                password:
                  type: string
            example:
              username: alice
              password: correct-horse
      responses:
        '200':
          description: Logged in.
          headers:
            Set-Cookie:
              description: '`auth_token=<jwt>; Path=/; HttpOnly; SameSite=Lax`'
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - token
                  - setup_required
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  token:
                    type: string
                    description: 'The JWT. Use it as `Authorization: Bearer <token>`.'
                  setup_required:
                    type: boolean
                    description: >-
                      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
        '400':
          description: Body is not valid JSON. Plain text `Invalid JSON`.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: >-
            Wrong username or password (or a database error during lookup). The body is the text
            `{"error":"Invalid credentials"}` sent with a `text/plain` content type.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST or OPTIONS. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /auth/logout:
    post:
      tags:
        - Authentication
      operationId: logout
      summary: Clear the session cookie.
      description: >
        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`.
      x-access: public
      security: []
      responses:
        '200':
          description: Cookie cleared.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - ok
        '302':
          description: Returned for non-POST methods. Redirects to `/login`.
  /auth/status:
    get:
      tags:
        - Authentication
      operationId: getAuthStatus
      summary: Report whether the request carries a valid credential.
      description: >
        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.
      x-access: public
      security: []
      responses:
        '200':
          description: Credential status.
          content:
            application/json:
              schema:
                type: object
                required:
                  - loggedIn
                  - publicAccess
                properties:
                  loggedIn:
                    type: boolean
                  username:
                    type: string
                    description: Present when `loggedIn` is true.
                  publicAccess:
                    type: boolean
                    description: Whether Allow Public Access is on.
                  defaultStartPath:
                    type: string
                    description: >-
                      The configured default start path for the viewer. Present when `loggedIn` is
                      true.
                  token:
                    type: string
                    description: The cookie session's JWT. Present only when the credential was the cookie.
              examples:
                header:
                  value:
                    loggedIn: true
                    username: alice
                    publicAccess: false
                    defaultStartPath: D:\Media
                anonymous:
                  value:
                    loggedIn: false
                    publicAccess: false
  /auth/users:
    get:
      tags:
        - Authentication
      operationId: listUsers
      summary: List user accounts.
      description: >-
        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.
      x-access: admin
      security: []
      responses:
        '200':
          description: Users, ordered by username.
          content:
            application/json:
              schema:
                type: object
                required:
                  - users
                properties:
                  users:
                    type:
                      - array
                      - 'null'
                    description: '`null` when the users table is empty.'
                    items:
                      $ref: '#/components/schemas/AuthUser'
              example:
                users:
                  - id: 2
                    username: alice
                    created_at: 1735689600
        '401':
          description: No valid credential (header or cookie).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: unauthorized
        '500':
          description: Database error. Plain text.
          content:
            text/plain:
              schema:
                type: string
    post:
      tags:
        - Authentication
      operationId: createUser
      summary: Create a user account.
      description: >
        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.
      x-access: public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - username
                - password
              properties:
                username:
                  type: string
                  description: Must not be `admin` or an existing username.
                password:
                  type: string
            example:
              username: bob
              password: s3cret-pass
      responses:
        '201':
          description: Created. The body is `{"status":"created"}` (no `Content-Type` header is set).
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - created
        '400':
          description: Invalid JSON, or `username`/`password` empty. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '403':
          description: >-
            Setup is complete and the request has no valid credential. Body is the text
            `{"error":"unauthorized"}` with a `text/plain` content type.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Username taken, reserved name `admin`, or database error. Plain-text message.
          content:
            text/plain:
              schema:
                type: string
    delete:
      tags:
        - Authentication
      operationId: deleteUser
      summary: Delete a user account and revoke its API keys.
      description: >-
        Requires a valid credential (header or cookie). The last remaining user cannot be deleted.
        Deleting a username that does not exist still answers `200`.
      x-access: admin
      security: []
      parameters:
        - name: username
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Deleted. The body is `{"status":"deleted"}` (no `Content-Type` header is set).
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - deleted
        '400':
          description: '`username` missing. Plain text.'
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: No valid credential (header or cookie).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: unauthorized
        '500':
          description: '`cannot delete the last user`, or a database error. Plain text.'
          content:
            text/plain:
              schema:
                type: string
  /auth/keys:
    get:
      tags:
        - Authentication
      operationId: listAPIKeys
      summary: List API keys (without their secret values).
      x-access: admin
      responses:
        '200':
          description: Every key, for every user.
          content:
            application/json:
              schema:
                type: object
                required:
                  - keys
                properties:
                  keys:
                    type: array
                    items:
                      $ref: '#/components/schemas/APIKey'
              example:
                keys:
                  - id: 3
                    username: alice
                    name: ci
                    prefix: lk_ab12cd34
                    created_at: 1735689600
                    last_used_at: 1735776000
        '500':
          description: Database error. Plain text.
          content:
            text/plain:
              schema:
                type: string
    post:
      tags:
        - Authentication
      operationId: createAPIKey
      summary: Create an API key. The plaintext key is returned only once.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
              properties:
                name:
                  type: string
                  description: Label for the key. Must not be blank.
                username:
                  type: string
                  description: >-
                    User the key acts as. Defaults to the authenticated caller. Any existing user
                    may be named.
            example:
              name: ci
              username: alice
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - key
                  - id
                  - name
                  - username
                  - prefix
                properties:
                  status:
                    type: string
                    enum:
                      - created
                  key:
                    type: string
                    description: 'The full key: `lk_` followed by 64 hex characters. Not retrievable later.'
                  id:
                    type: integer
                    format: int64
                  name:
                    type: string
                  username:
                    type: string
                  prefix:
                    type: string
                    description: 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
        '400':
          description: >-
            Invalid JSON, blank `name`, no username resolvable, or the named user does not exist.
            Plain text.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            Database error, or the user is the temporary `admin` account (keys cannot be created for
            it). Plain text.
          content:
            text/plain:
              schema:
                type: string
    delete:
      tags:
        - Authentication
      operationId: deleteAPIKey
      summary: Revoke an API key.
      x-access: admin
      parameters:
        - name: id
          in: query
          required: true
          description: Key id from the listing.
          schema:
            type: integer
            format: int64
      responses:
        '200':
          description: Revoked (also returned when no key had that id).
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - deleted
        '400':
          description: '`id` missing or not an integer. Plain text `Key id required`.'
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Database error. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /auth/cli/authorize:
    get:
      tags:
        - Authentication
      operationId: cliAuthorizePage
      summary: Browser approval page for the CLI loopback login flow.
      description: >
        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`.
      x-access: public
      security:
        - cookie: []
      parameters:
        - name: port
          in: query
          required: true
          description: Loopback port the client listens on.
          schema:
            type: integer
            minimum: 1
            maximum: 65535
        - name: state
          in: query
          required: true
          description: Opaque client state, echoed back on the callback.
          schema:
            type: string
            pattern: ^[A-Za-z0-9_-]{8,128}$
        - name: code_challenge
          in: query
          required: true
          description: base64url (no padding) SHA-256 of the PKCE verifier (S256).
          schema:
            type: string
            pattern: ^[A-Za-z0-9_-]{43}$
        - name: name
          in: query
          required: false
          description: >-
            Name for the API key that will be minted. Control characters are stripped; truncated to
            64 bytes.
          schema:
            type: string
            default: lokictl
            maxLength: 64
      responses:
        '200':
          description: 'The approval page (`Cache-Control: no-store`).'
          content:
            text/html:
              schema:
                type: string
        '302':
          description: No browser session. Redirects to `/login?redirect=...`.
        '400':
          description: '`invalid port`, `invalid state` or `invalid code_challenge`. Plain text.'
          content:
            text/plain:
              schema:
                type: string
        '403':
          description: The session belongs to the temporary `admin` account. Plain text.
          content:
            text/plain:
              schema:
                type: string
    post:
      tags:
        - Authentication
      operationId: cliAuthorizeDecision
      summary: Approve or deny the CLI login and redirect to the loopback callback.
      description: >
        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`.
      x-access: public
      security:
        - cookie: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required:
                - port
                - state
                - code_challenge
                - action
              properties:
                port:
                  type: integer
                  minimum: 1
                  maximum: 65535
                state:
                  type: string
                  pattern: ^[A-Za-z0-9_-]{8,128}$
                code_challenge:
                  type: string
                  pattern: ^[A-Za-z0-9_-]{43}$
                name:
                  type: string
                  default: lokictl
                  maxLength: 64
                action:
                  type: string
                  enum:
                    - approve
                    - deny
      responses:
        '303':
          description: >-
            Redirect to the loopback callback with `code` and `state`, or `error=access_denied` and
            `state`.
        '400':
          description: Unparseable form or invalid `port`/`state`/`code_challenge`. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Approve without a valid `auth_token` cookie. Plain text `Not logged in`.
          content:
            text/plain:
              schema:
                type: string
        '403':
          description: Cross-origin post, or the session is the temporary `admin` account. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Could not create the code. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /auth/cli/token:
    post:
      tags:
        - Authentication
      operationId: cliExchangeToken
      summary: Exchange a CLI authorization code and PKCE verifier for a new API key.
      description: >
        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.
      x-access: public
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - code
                - code_verifier
              properties:
                code:
                  type: string
                  description: The code from the callback.
                code_verifier:
                  type: string
                  pattern: ^[A-Za-z0-9._~-]{43,128}$
                  description: >-
                    PKCE verifier; SHA-256 of it, base64url-encoded without padding, must equal the
                    `code_challenge`.
            example:
              code: 9f2c...e1
              code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
      responses:
        '200':
          description: Key minted.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - key
                  - username
                  - name
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  key:
                    type: string
                    description: The plaintext `lk_` API key.
                  username:
                    type: string
                  name:
                    type: string
                    description: The key's name.
              example:
                status: ok
                key: lk_ab12cd34...
                username: alice
                name: lokictl@host
        '400':
          description: >-
            `Invalid JSON`, `Invalid code_verifier`, `Invalid or expired code`, or `code_verifier
            does not match`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Not POST.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Key creation failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /health:
    get:
      tags:
        - System
      operationId: getHealth
      summary: Liveness, server version, SSE connection stats and job counts.
      description: >
        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.
      x-access: public
      security: []
      responses:
        '200':
          description: Health report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthStatus'
              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
        '405':
          description: Method other than GET or OPTIONS. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/config:
    get:
      tags:
        - System
      operationId: getConfig
      summary: The active configuration as JSON, with secrets redacted.
      description: >
        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.
      x-access: admin
      responses:
        '200':
          description: The configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerConfig'
        '405':
          description: Not GET. Body `{"error":"Use GET"}` sent as `text/plain`.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            Config file could not be loaded. Body `{"error":"failed to load config"}` sent as
            `text/plain`.
          content:
            text/plain:
              schema:
                type: string
  /open:
    post:
      tags:
        - System
      operationId: openPath
      summary: Open a file or folder in the server host's default application.
      description: >-
        Runs on the machine the server runs on. Only useful when the server is on the same desktop
        as the user.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - path
              properties:
                path:
                  type: string
                  description: Absolute path on the server host. Trimmed.
            example:
              path: D:\Media\clip.mp4
      responses:
        '200':
          description: Opened.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - ok
        '400':
          description: '`bad json`, `path cannot be empty`, or `path must be absolute`. Plain text.'
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Not POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The OS could not open the path. Plain text `failed to open path`.
          content:
            text/plain:
              schema:
                type: string
  /api/stats:
    get:
      tags:
        - Library Statistics
      operationId: getLibraryStats
      summary: Library totals and metadata coverage counts.
      description: >
        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.
      x-access: read
      responses:
        '200':
          description: 'A snapshot, or `{"ready": false}` before the first count.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/LibraryStats'
                  - type: object
                    description: No snapshot yet.
                    required:
                      - ready
                    properties:
                      ready:
                        type: boolean
                        enum:
                          - false
              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)
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /create:
    post:
      tags:
        - Jobs
      operationId: createJob
      summary: Create a job from a command string.
      description: >
        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).
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateJobRequest'
            example:
              input: describe --overwrite "D:/photos/2024 trip"
              fields:
                prompt: Describe the scene in one sentence.
      responses:
        '201':
          description: Job queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: The new job's id (UUID).
                required:
                  - id
              example:
                id: 3f0c8f5e-6a0e-4c41-9a7a-2f1f3d0b8c11
        '400':
          description: The body is not valid JSON (`bad json`) or `input` contains no tokens (`Invalid input`).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            The queue rejected the job, or the `create` broadcast failed to render (job may already
            be queued).
          content:
            text/plain:
              schema:
                type: string
  /jobs/list:
    get:
      tags:
        - Jobs
      operationId: listJobs
      summary: List every job in the queue.
      description: >
        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.
      x-access: admin
      responses:
        '200':
          description: All jobs, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Job'
        '401':
          description: >-
            Missing or invalid credential (API-style request). Browser-style requests are redirected
            to `/login`.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET (`Use GET`).
          content:
            text/plain:
              schema:
                type: string
  /job/{id}:
    get:
      tags:
        - Jobs
      operationId: getJobDetailPage
      summary: Job detail page (HTML).
      description: >
        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.
      x-access: admin
      parameters:
        - $ref: '#/components/parameters/JobIdPath'
      responses:
        '200':
          description: The rendered page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Not signed in (browser request); redirects to `/login?redirect=...`.
        '401':
          description: 'Invalid credential header or `Accept: application/json` without a credential.'
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: No job with that id (`404 page not found`).
          content:
            text/plain:
              schema:
                type: string
  /job/{id}/cancel:
    post:
      tags:
        - Jobs
      operationId: cancelJob
      summary: Cancel a job.
      description: >
        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).
      x-access: admin
      parameters:
        - $ref: '#/components/parameters/JobIdPath'
      responses:
        '200':
          description: Always `Job cancelled successfully`.
          content:
            text/plain:
              schema:
                type: string
              example: Job cancelled successfully
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
  /job/{id}/pause:
    post:
      tags:
        - Jobs
      operationId: pauseJob
      summary: Pause a job.
      description: >
        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.
      x-access: admin
      parameters:
        - $ref: '#/components/parameters/JobIdPath'
      responses:
        '200':
          description: Pause applied or requested (`Job pause requested`).
          content:
            text/plain:
              schema:
                type: string
              example: Job pause requested
        '400':
          description: Unknown id (`job not found`) or the job is not pending or in progress.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
  /job/{id}/resume:
    post:
      tags:
        - Jobs
      operationId: resumeJob
      summary: Resume a paused job.
      description: >
        Returns a `paused` job to `pending` so it is claimed again. The task re-resolves its input
        and skips items it already processed.
      x-access: admin
      parameters:
        - $ref: '#/components/parameters/JobIdPath'
      responses:
        '200':
          description: Job re-queued (`Job resumed`).
          content:
            text/plain:
              schema:
                type: string
              example: Job resumed
        '400':
          description: Unknown id (`job not found`) or the job is not paused.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
  /job/{id}/copy:
    post:
      tags:
        - Jobs
      operationId: copyJob
      summary: Clone a job into a new pending job.
      description: >
        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.
      x-access: admin
      parameters:
        - $ref: '#/components/parameters/JobIdPath'
      responses:
        '201':
          description: Copy queued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: The new job's id.
                  message:
                    type: string
                    description: Always `Job copied successfully`.
                required:
                  - id
                  - message
              example:
                id: 9b6a1d2e-0f43-4c1e-8e57-0d3c5b7e2a90
                message: Job copied successfully
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Unknown id (`job not found`) or the broadcast failed.
          content:
            text/plain:
              schema:
                type: string
  /job/{id}/remove:
    post:
      tags:
        - Jobs
      operationId: removeJob
      summary: Delete a job record.
      description: >
        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.
      x-access: admin
      parameters:
        - $ref: '#/components/parameters/JobIdPath'
      responses:
        '200':
          description: Removed (`Job removed successfully`).
          content:
            text/plain:
              schema:
                type: string
              example: Job removed successfully
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Unknown id (`job not found`) or the broadcast failed.
          content:
            text/plain:
              schema:
                type: string
  /jobs/clear:
    post:
      tags:
        - Jobs
      operationId: clearFinishedJobs
      summary: Delete every finished job.
      description: >
        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.
      x-access: admin
      responses:
        '200':
          description: Jobs cleared.
          content:
            application/json:
              schema:
                type: object
                properties:
                  cleared_count:
                    type: integer
                    description: Number of jobs removed.
                  message:
                    type: string
                    description: '`Cleared <n> finished jobs`.'
                required:
                  - cleared_count
                  - message
              example:
                cleared_count: 12
                message: Cleared 12 finished jobs
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: A `delete` broadcast failed part way (the jobs are already removed).
          content:
            text/plain:
              schema:
                type: string
  /api/job/{id}:
    get:
      tags:
        - Jobs
      operationId: getJob
      summary: One job's state, progress, outputs and log tail.
      description: >
        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`.
      x-access: admin
      parameters:
        - $ref: '#/components/parameters/JobIdPath'
        - name: tail
          in: query
          required: false
          description: >-
            Number of most recent log lines to return. Values outside 0 to 500, or not an integer,
            fall back to the default.
          schema:
            type: integer
            minimum: 0
            maximum: 500
            default: 20
      responses:
        '200':
          description: The job snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobSnapshot'
              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'
        '401':
          description: Missing or invalid credential.
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: No job with that id (`job not found`).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET (`Use GET`).
          content:
            text/plain:
              schema:
                type: string
  /api/jobs/for-path:
    get:
      tags:
        - Jobs
      operationId: listJobsForPath
      summary: Active jobs operating on one media file.
      description: >
        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.
      x-access: admin
      parameters:
        - name: path
          in: query
          required: true
          description: Media file path, exactly as stored in the library.
          schema:
            type: string
      responses:
        '200':
          description: Matching jobs (possibly none).
          content:
            application/json:
              schema:
                type: object
                properties:
                  path:
                    type: string
                    description: The path that was asked about, echoed.
                  jobs:
                    type: array
                    items:
                      $ref: '#/components/schemas/JobForPathSummary'
                required:
                  - path
                  - jobs
              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'
        '400':
          description: '`path` is missing or empty (`missing path parameter`).'
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET (`Use GET`).
          content:
            text/plain:
              schema:
                type: string
  /tasks:
    get:
      tags:
        - Jobs
      operationId: listTasks
      summary: Registered tasks and their option schemas.
      description: >
        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.
      x-access: admin
      responses:
        '200':
          description: The task list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  tasks:
                    type: array
                    items:
                      $ref: '#/components/schemas/TaskInfo'
                required:
                  - tasks
              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: []
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET (`Use GET`).
          content:
            text/plain:
              schema:
                type: string
  /workflow:
    post:
      tags:
        - Workflows
      operationId: runAdHocWorkflow
      summary: Run a DAG once without saving it.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tasks:
                  type: array
                  description: The DAG nodes.
                  items:
                    $ref: '#/components/schemas/WorkflowTask'
            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
      responses:
        '201':
          description: Jobs queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowRunResponse'
        '400':
          description: Body is not valid JSON (`bad json`).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            A node id already exists as a job (`job with given ID already exists`), or a broadcast
            failed.
          content:
            text/plain:
              schema:
                type: string
  /workflows:
    get:
      tags:
        - Workflows
      operationId: listSavedWorkflows
      summary: List saved workflows.
      description: Ids and names only, ordered by name. Empty library returns `[]`.
      x-access: admin
      responses:
        '200':
          description: Saved workflow summaries.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SavedWorkflowSummary'
              example:
                - id: 1c2e7d0a-5b8e-4f3e-9d61-2c4b8a7f0e13
                  name: Ingest and tag
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET (`Use GET`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                type: string
  /workflows/create:
    post:
      tags:
        - Workflows
      operationId: createSavedWorkflow
      summary: Save a workflow template.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SavedWorkflowInput'
            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
      responses:
        '201':
          description: The saved workflow.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SavedWorkflow'
        '400':
          description: Body is not valid JSON (`bad json`).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            DAG 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.
          content:
            text/plain:
              schema:
                type: string
  /workflows/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Saved workflow id (UUID).
        schema:
          type: string
    get:
      tags:
        - Workflows
      operationId: getSavedWorkflow
      summary: Get one saved workflow with its DAG.
      x-access: admin
      responses:
        '200':
          description: The saved workflow.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SavedWorkflow'
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: >-
            Unknown id (`workflow not found: <id>`) or any other load error (the handler maps every
            error to 404).
          content:
            text/plain:
              schema:
                type: string
    put:
      tags:
        - Workflows
      operationId: updateSavedWorkflow
      summary: Replace a saved workflow's name and DAG.
      description: >-
        Both `name` and `dag` are replaced (an omitted `name` becomes empty). The DAG is validated
        as for create.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SavedWorkflowInput'
      responses:
        '200':
          description: The updated workflow, re-read from the database.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SavedWorkflow'
        '400':
          description: Body is not valid JSON (`bad json`).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            DAG validation failed, the id does not exist (`workflow not found: <id>`, returned as
            500, not 404), the name is taken, or another database error.
          content:
            text/plain:
              schema:
                type: string
    delete:
      tags:
        - Workflows
      operationId: deleteSavedWorkflow
      summary: Delete a saved workflow.
      description: Jobs already started from the workflow are not affected.
      x-access: admin
      responses:
        '204':
          description: Deleted.
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            The id does not exist (`workflow not found: <id>`, returned as 500, not 404) or a
            database error.
          content:
            text/plain:
              schema:
                type: string
  /workflows/{id}/run:
    post:
      tags:
        - Workflows
      operationId: runSavedWorkflow
      summary: Instantiate a saved workflow as jobs.
      description: >
        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.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Saved workflow id (UUID).
          schema:
            type: string
      requestBody:
        required: true
        description: >-
          A JSON body is required (an empty body is rejected as `bad json`); `input` may be omitted
          or empty.
        content:
          application/json:
            schema:
              type: object
              properties:
                input:
                  type: string
                  description: Runtime input for the root nodes.
            example:
              input: D:/photos/2024
      responses:
        '201':
          description: Jobs queued; ids in DAG order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowRunResponse'
              example:
                ids:
                  - 0a7f3c2e-91b4-4d0e-8c55-3e1b2a9d7f01
                  - 5e2d8b1c-7a63-4f9e-b2d0-6c4a1e8f9b02
        '400':
          description: Body is not valid JSON (`bad json`).
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Missing or invalid credential (API-style request).
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST (`Use POST`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            Unknown workflow id (`workflow not found: <id>`, returned as 500, not 404), or queuing
            failed.
          content:
            text/plain:
              schema:
                type: string
  /stream:
    get:
      tags:
        - Real-Time Events
      operationId: streamEvents
      summary: Server-Sent Events feed of job and library changes.
      description: >
        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).
      x-access: public
      security: []
      responses:
        '200':
          description: The event stream. It stays open until the client disconnects or the server shuts down.
          content:
            text/event-stream:
              schema:
                description: >-
                  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.
                oneOf:
                  - $ref: '#/components/schemas/StreamConnectedEvent'
                  - $ref: '#/components/schemas/JobListEvent'
                  - $ref: '#/components/schemas/StdoutEvent'
                  - $ref: '#/components/schemas/ProgressEvent'
                  - $ref: '#/components/schemas/PeopleUpdatedEvent'
                  - $ref: '#/components/schemas/DuplicatesUpdatedEvent'
                  - $ref: '#/components/schemas/MediaPathsEvent'
                  - $ref: '#/components/schemas/SchedulerEvent'
                  - $ref: '#/components/schemas/StatsEvent'
              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
        '500':
          description: The response writer cannot stream (`Streaming unsupported`).
          content:
            text/plain:
              schema:
                type: string
        '503':
          description: >-
            The 5000-connection limit is reached (`Server at capacity, please try again later`).
            Rejections are counted as `rejected_connections` in `GET /health`.
          content:
            text/plain:
              schema:
                type: string
  /api/media/query:
    post:
      tags:
        - Media Library
      operationId: queryMedia
      summary: Run a composable predicate query, with optional similarity ranking.
      description: >
        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`.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaQueryRequest'
            examples:
              filters:
                summary: Tag, exclusion and path filters
                value:
                  mode: AND
                  predicates:
                    - type: tag
                      value: sunset
                    - type: tag
                      value: blurry
                      exclude: true
                    - type: path
                      value: /2024/
                    - type: orientation
                      value: landscape
                      join: OR
              similarBlend:
                summary: Similarity with a composite blend, inside a tag
                value:
                  mode: AND
                  predicates:
                    - type: tag
                      value: beach
                    - type: similar
                      value: D:/photos/x.jpg
                      blendMode: blend
                      nodes:
                        - kind: text
                          value: at night
                          weight: 0.5
                        - kind: text
                          value: blurry
                          weight: 0.3
                          negative: true
                        - kind: image
                          value: D:/photos/y.jpg
                          weight: 0.7
      responses:
        '200':
          description: Matching items, streamed as one JSON array.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MediaListItem'
              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
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            A 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.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media:
    post:
      tags:
        - Media Library
      operationId: listMediaByTags
      summary: List items by tag intersection or union (predecessor of the predicate query).
      description: >
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                tags:
                  type: array
                  items:
                    type: string
                  description: Tag labels to match exactly.
                mode:
                  type: string
                  description: >-
                    `EXCLUSIVE` (default) requires every tag; anything else (for example
                    `INCLUSIVE`) matches any tag.
            example:
              tags:
                - sunset
                - beach
              mode: EXCLUSIVE
      responses:
        '200':
          description: Matching items, streamed as one JSON array.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MediaListItem'
              example:
                - path: D:/photos/a.jpg
                  mtimeMs: 1717171717000
                  elo: 1500
                  battles: 0
                  height: 1080
                  width: 1920
                  weight: 1
                  tagLabel: sunset
                  timeStamp: 0
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The query failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/search:
    post:
      tags:
        - Media Library
      operationId: searchMediaDescriptions
      summary: Substring search over item descriptions.
      description: >
        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`.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                description:
                  type: string
                  description: Text to look for in item descriptions.
                tags:
                  type: array
                  items:
                    type: string
                  description: Accepted for compatibility; ignored.
                filteringMode:
                  type: string
                  description: Accepted for compatibility; ignored.
            example:
              description: red car
      responses:
        '200':
          description: Matching items, streamed as one JSON array. Tag fields are empty strings and `0`.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/MediaListItem'
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The query failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/similar:
    get:
      tags:
        - Media Library
      operationId: findSimilarMedia
      summary: Items visually similar to a library item.
      description: >
        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.
      x-access: read
      parameters:
        - name: path
          in: query
          required: true
          description: Media path of the anchor item.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: >-
            Maximum hits. Values that are not a positive integer fall back to the default. No upper
            bound is enforced.
          schema:
            type: integer
            default: 50
            minimum: 1
      responses:
        '200':
          description: Hits, best first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SimilarHit'
              example:
                - path: D:/photos/x.jpg
                  score: 1
                - path: D:/photos/y.jpg
                  score: 0.91
        '400':
          description: '`path` is missing.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Embedding or search failed (for example no embedding model installed, or the file cannot
            be embedded).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/search/visual:
    get:
      tags:
        - Media Library
      operationId: searchMediaByText
      summary: Text-to-image search.
      description: >
        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.
      x-access: read
      parameters:
        - name: q
          in: query
          required: true
          description: Free-text description of what to find.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: >-
            Maximum hits. Values that are not a positive integer fall back to the default. No upper
            bound is enforced.
          schema:
            type: integer
            default: 50
            minimum: 1
      responses:
        '200':
          description: Hits, best first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SimilarHit'
              example:
                - path: D:/photos/dog.jpg
                  score: 0.31
        '400':
          description: '`q` is missing.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The text could not be embedded or the search failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/search/image:
    post:
      tags:
        - Media Library
      operationId: searchMediaByImage
      summary: Image-to-image search with an uploaded image.
      description: >
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: Raw image bytes, at most 32 MB.
          image/*:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Up to 50 items, best first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ScoredMediaItem'
              example:
                - path: D:/photos/x.jpg
                  mtimeMs: 0
                  elo: 1500
                  battles: 2
                  height: 1080
                  width: 1920
                  score: 0.88
        '400':
          description: The body is empty or could not be read.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: The body is larger than 32 MB.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The image could not be embedded or the search failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/search/face:
    post:
      tags:
        - Media Library
      operationId: searchMediaByFace
      summary: Find media containing the person whose face is in an uploaded image.
      description: >
        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`.
      x-access: read
      parameters:
        - name: limit
          in: query
          required: false
          description: >-
            Number of face hits to search. Non-positive or invalid values use the default; values
            above 500 are capped to 500.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: Raw image bytes, at most 32 MB.
          image/*:
            schema:
              type: string
              format: binary
      responses:
        '200':
          description: Media items, best first, each with the best-matching face attached.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/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
        '400':
          description: The body is empty or could not be read.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: The method is not `POST`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: The body is larger than 32 MB.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: No face was detected in the uploaded image.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Face detection or search failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/metadata:
    post:
      tags:
        - Media Library
      operationId: getMediaMetadata
      summary: Stored metadata for one item, plus any AI-generation text embedded in the file.
      description: >
        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`.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaPathRequest'
            example:
              path: D:/photos/x.png
      responses:
        '200':
          description: The item's metadata.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaMetadata'
              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'
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            Anonymous caller and the path is outside every storage root and not a library row (or is
            an `http(s)://` URL).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/tags:
    post:
      tags:
        - Media Library
      operationId: getMediaTags
      summary: Tag assignments of one item.
      description: >
        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`.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaPathRequest'
            example:
              path: D:/photos/x.jpg
      responses:
        '200':
          description: The item's tags.
          content:
            application/json:
              schema:
                type: object
                required:
                  - tags
                properties:
                  tags:
                    type: array
                    items:
                      $ref: '#/components/schemas/MediaTagAssignment'
              example:
                tags:
                  - tag_label: sunset
                    category_label: Scene
                    weight: 1
                    time_stamp: 0
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The query failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/gif-metadata:
    post:
      tags:
        - Media Library
      operationId: getGifMetadata
      summary: Frame count and duration of an animated image or video, via ffprobe.
      description: >
        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`.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaPathRequest'
            example:
              path: D:/gifs/loop.gif
      responses:
        '200':
          description: The probe result, or `null`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/GifMetadata'
                  - type: 'null'
              example:
                frameCount: 48
                duration: 1.92
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            Anonymous caller and the path is outside every storage root and not a library row (or is
            an `http(s)://` URL).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/preview:
    post:
      tags:
        - Media Library
      operationId: getMediaPreviewPath
      summary: Server-side path of an item's thumbnail, generating it if missing.
      description: >
        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`.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - path
              properties:
                path:
                  type: string
                  description: Media path.
                cache:
                  description: Thumbnail column, or `false` to skip.
                  oneOf:
                    - $ref: '#/components/schemas/ThumbnailCache'
                    - type: boolean
                timeStamp:
                  type: number
                  description: >-
                    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
      responses:
        '200':
          description: The thumbnail path, or `null`.
          content:
            application/json:
              schema:
                type:
                  - string
                  - 'null'
              example: C:\ProgramData\Lowkey Media Server\thumbnail_path_600\3b5d...e1.mp4
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            Anonymous caller and the path is outside every storage root and not a library row (or is
            an `http(s)://` URL).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Thumbnail generation failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/description:
    post:
      tags:
        - Media Library
      operationId: setMediaDescription
      summary: Set an item's description.
      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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: Media path.
                description:
                  type: string
                  description: New description text.
            example:
              path: D:/photos/x.jpg
              description: A red car parked by the sea.
      responses:
        '200':
          description: Always an empty object.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
              example: {}
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The update failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/transcript:
    post:
      tags:
        - Media Library
      operationId: setMediaTranscript
      summary: Set or clear an item's transcript.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - path
              properties:
                path:
                  type: string
                  description: Media path.
                transcript:
                  type: string
                  description: >-
                    New transcript (for example WebVTT text). An empty or missing value stores an
                    empty string.
            example:
              path: D:/videos/talk.mp4
              transcript: |-
                WEBVTT

                00:00.000 --> 00:02.000
                Hello
      responses:
        '200':
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - ok
        '400':
          description: Invalid JSON or `path` missing.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No library item has this path.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: The method is not `POST`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The update failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/rating:
    post:
      tags:
        - Media Library
      operationId: readOrSetMediaRating
      summary: Read or update an item's rating counters.
      description: >
        Only fields present in the body are written; a body with just `path` reads. The response
        always reflects the row after the update.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - path
              properties:
                path:
                  type: string
                  description: Media path.
                elo:
                  type: number
                  description: New Elo rating.
                views:
                  type: integer
                  description: New view count.
                wins:
                  type: integer
                  description: New win count.
                losses:
                  type: integer
                  description: New loss count.
            example:
              path: D:/photos/x.jpg
              views: 12
      responses:
        '200':
          description: The item's current rating columns. Each is `null` when unset.
          content:
            application/json:
              schema:
                type: object
                required:
                  - path
                  - elo
                  - views
                  - wins
                  - losses
                properties:
                  path:
                    type: string
                  elo:
                    type:
                      - number
                      - 'null'
                  views:
                    type:
                      - integer
                      - 'null'
                  wins:
                    type:
                      - integer
                      - 'null'
                  losses:
                    type:
                      - integer
                      - 'null'
              example:
                path: D:/photos/x.jpg
                elo: 1532.4
                views: 12
                wins: 4
                losses: 3
        '400':
          description: Invalid JSON or `path` missing.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No library item has this path.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: The method is not `POST`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The database failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/battle:
    post:
      tags:
        - Media Library
      operationId: recordMediaBattle
      summary: Record a Battle Mode vote and update both Elo ratings.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - winnerPath
                - loserPath
              properties:
                winnerPath:
                  type: string
                  description: Path of the item that won.
                loserPath:
                  type: string
                  description: Path of the item that lost. Must differ from `winnerPath`.
                outcome:
                  type: number
                  enum:
                    - 1
                    - 0.5
                  default: 1
                  description: The winner's score. `1` is a win, `0.5` a draw.
            example:
              winnerPath: D:/photos/a.jpg
              loserPath: D:/photos/b.jpg
      responses:
        '200':
          description: The new ratings and battle counts.
          content:
            application/json:
              schema:
                type: object
                required:
                  - winnerPath
                  - winnerElo
                  - winnerMatches
                  - loserPath
                  - loserElo
                  - loserMatches
                properties:
                  winnerPath:
                    type: string
                  winnerElo:
                    type: number
                    description: Winner's rating after the vote.
                  winnerMatches:
                    type: integer
                    description: Winner's battle count including this one.
                  loserPath:
                    type: string
                  loserElo:
                    type: number
                  loserMatches:
                    type: integer
              example:
                winnerPath: D:/photos/a.jpg
                winnerElo: 1524
                winnerMatches: 1
                loserPath: D:/photos/b.jpg
                loserElo: 1476
                loserMatches: 1
        '400':
          description: Invalid JSON, a path missing, both paths equal, or `outcome` not `1` or `0.5`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: The method is not `POST`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The database failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/delete:
    post:
      tags:
        - Media Library
      operationId: deleteMediaItem
      summary: Remove an item from the library (the file is not touched).
      description: >
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaPathRequest'
            example:
              path: D:/photos/x.jpg
      responses:
        '200':
          description: Rows removed per table.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaEraseResult'
              example:
                path: D:/photos/x.jpg
                media: 1
                tags: 4
                embeddings: 2
                faces: 1
                battles: 3
        '400':
          description: Invalid JSON or `path` empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The database failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/forget:
    post:
      tags:
        - Media Library
      operationId: forgetMediaItem
      summary: Erase every database reference to a path (the file is not touched).
      description: >
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaPathRequest'
            example:
              path: D:/photos/missing.jpg
      responses:
        '200':
          description: Rows removed per table.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaEraseResult'
        '400':
          description: Invalid JSON or `path` empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The database failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/move:
    post:
      tags:
        - Media Library
      operationId: moveMediaPath
      summary: Re-point every database reference after moving a file or folder on disk.
      description: >
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - from
                - to
              properties:
                from:
                  type: string
                  description: Current path (or folder with `prefix`). Required.
                to:
                  type: string
                  description: >-
                    New path (or folder with `prefix`). Required, must differ from `from`, and with
                    `prefix` must not be inside `from`.
                prefix:
                  type: boolean
                  default: false
                  description: Treat `from` and `to` as folders and move the whole subtree.
                dryRun:
                  type: boolean
                  default: false
                  description: Compute the result and roll back.
            example:
              from: C:/pics/2023
              to: D:/archive/2023
              prefix: true
              dryRun: true
      responses:
        '200':
          description: What the move changed (or would change, for a dry run).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaMoveResult'
              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
        '400':
          description: >-
            Invalid JSON, `from` or `to` empty, both the same, `to` inside `from` with `prefix`, or
            a database error during the move.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >-
            One or more destination paths already belong to another library item. Nothing was
            changed. Unlike the other errors, this body is sent as `application/json`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - error
                  - conflicts
                properties:
                  error:
                    type: string
                    description: Human-readable message naming the first conflicts.
                  conflicts:
                    type: array
                    items:
                      type: string
                    description: Every destination path that is already taken.
              example:
                error: 'destination already exists in the library: D:/archive/2023/a.jpg'
                conflicts:
                  - D:/archive/2023/a.jpg
  /api/media/merge-metadata:
    post:
      tags:
        - Media Library
      operationId: mergeMediaMetadata
      summary: Merge several items into the first one, then delete the others.
      description: >
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - paths
              properties:
                paths:
                  type: array
                  items:
                    type: string
                  minItems: 2
                  description: >-
                    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
      responses:
        '200':
          description: What the merge changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaMergeResult'
              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
        '400':
          description: Invalid JSON, or no target plus at least one source.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: A database error before anything was deleted.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/assign-person:
    post:
      tags:
        - Media Library
      operationId: assignMediaToPerson
      summary: Assign a face in a media item to a person, or found a new person from it.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - path
              properties:
                path:
                  type: string
                  description: Media path. Required.
                personId:
                  type: integer
                  description: Existing person id. Required unless `newPerson` is true.
                setCover:
                  type: boolean
                  default: false
                  description: Also make the chosen face the person's cover crop.
                newPerson:
                  type: boolean
                  default: false
                  description: Create a new person seeded by this item's face.
                name:
                  type: string
                  description: Name for the new person (with `newPerson`). Blank picks an automatic name.
            examples:
              existing:
                value:
                  path: D:/photos/x.jpg
                  personId: 12
                  setCover: true
              newPerson:
                value:
                  path: D:/photos/x.jpg
                  newPerson: true
                  name: Alex
      responses:
        '200':
          description: The face that was assigned.
          content:
            application/json:
              schema:
                type: object
                required:
                  - faceId
                  - personId
                  - faces
                  - setCover
                  - created
                  - name
                properties:
                  faceId:
                    type: integer
                    description: Id of the assigned face.
                  personId:
                    type: integer
                    description: The person it was assigned to (the new id with `newPerson`).
                  faces:
                    type: integer
                    description: Number of faces found in the item.
                  setCover:
                    type: boolean
                    description: Whether the face became the cover.
                  created:
                    type: boolean
                    description: True when a new person was created.
                  name:
                    type: string
                    description: 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
        '400':
          description: >-
            Invalid 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.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: '`personId` does not exist.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: The method is not `POST`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: No face was detected in the media item.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Face scan or database failure.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/media/reject-person:
    post:
      tags:
        - Media Library
      operationId: rejectPersonFromMedia
      summary: Reject every face of a person on one media item.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - path
              properties:
                path:
                  type: string
                  description: Media path. Required.
                personId:
                  type: integer
                  description: Person id. Required unless `name` is given.
                name:
                  type: string
                  description: Person display name, used when `personId` is not given.
            example:
              path: D:/photos/x.jpg
              personId: 12
      responses:
        '200':
          description: The faces that were rejected (possibly none).
          content:
            application/json:
              schema:
                type: object
                required:
                  - personId
                  - rejectedFaceIds
                properties:
                  personId:
                    type: integer
                  rejectedFaceIds:
                    type:
                      - array
                      - 'null'
                    items:
                      type: integer
                    description: Ids of the rejected faces. May be `null` when nothing was rejected.
              example:
                personId: 12
                rejectedFaceIds:
                  - 4411
        '400':
          description: Invalid JSON, `path` empty, or neither `personId` nor `name`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No person has the given `name`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: The method is not `POST`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The database failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/thumbnails:
    post:
      tags:
        - Media Library
      operationId: listMediaThumbnails
      summary: Recorded thumbnail files of one item.
      description: >
        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`.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaPathRequest'
            example:
              path: D:/photos/x.jpg
      responses:
        '200':
          description: Zero, one or two entries.
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  required:
                    - cache
                    - path
                    - exists
                    - size
                  properties:
                    cache:
                      type: string
                      enum:
                        - thumbnail_path_600
                        - thumbnail_path_1200
                    path:
                      type: string
                      description: Thumbnail file path on the server.
                    exists:
                      type: boolean
                      description: Whether the file is on local disk.
                    size:
                      type: integer
                      description: 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
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/thumbnails/regenerate:
    post:
      tags:
        - Media Library
      operationId: regenerateMediaThumbnail
      summary: Delete and regenerate one thumbnail of a local item.
      description: >
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: Media path.
                cache:
                  $ref: '#/components/schemas/ThumbnailCache'
                timeStamp:
                  type: number
                  default: 0
                  description: Video frame time in seconds.
            example:
              path: D:/videos/clip.mp4
              cache: thumbnail_path_1200
              timeStamp: 30
      responses:
        '200':
          description: The new thumbnail's path on the server, as a JSON string.
          content:
            application/json:
              schema:
                type: string
        '400':
          description: The body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: No database configured, or generation failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /media/file:
    get:
      tags:
        - File Serving
      operationId: getMediaFile
      summary: Serve a media file.
      description: >
        - 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.
      x-access: read
      parameters:
        - name: path
          in: query
          required: true
          description: >-
            Absolute local path, `s3://` URL, or `http(s)://` URL. Percent-encode it; `+` is not
            decoded as a space.
          schema:
            type: string
        - name: Range
          in: header
          required: false
          description: Byte range, for local files.
          schema:
            type: string
        - name: If-None-Match
          in: header
          required: false
          description: An `ETag` from a previous response.
          schema:
            type: string
      responses:
        '200':
          description: The file bytes.
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
            image/*:
              schema:
                type: string
                format: binary
            video/*:
              schema:
                type: string
                format: binary
            audio/*:
              schema:
                type: string
                format: binary
        '206':
          description: Partial content for a `Range` request.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '302':
          description: '`s3://` path: redirect to a presigned URL.'
          headers:
            Location:
              schema:
                type: string
        '304':
          description: '`If-None-Match` matched the current `ETag`.'
        '400':
          description: >-
            `path` missing, empty or badly encoded, a local path that is not absolute, a directory,
            or an invalid remote URL.
          content:
            text/plain:
              schema:
                type: string
        '403':
          description: >-
            Anonymous caller and the path is outside every storage root and not a library row, or is
            an `http(s)://` URL.
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: Local file not found, or no storage backend for the `s3://` path.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: The method is not `GET`.
          content:
            text/plain:
              schema:
                type: string
        '413':
          description: Remote (`http(s)://`) file larger than 100 MB according to its `Content-Length`.
          content:
            text/plain:
              schema:
                type: string
        '415':
          description: HEIC conversion failed.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The file could not be stat'ed, or a presigned URL could not be generated.
          content:
            text/plain:
              schema:
                type: string
        '502':
          description: >-
            The remote URL could not be fetched. A remote non-200 status is passed through with a
            plain-text body.
          content:
            text/plain:
              schema:
                type: string
  /media/thumbnail:
    get:
      tags:
        - File Serving
      operationId: getMediaThumbnail
      summary: Serve a thumbnail image, generating it on first request.
      description: >
        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`.
      x-access: read
      parameters:
        - name: path
          in: query
          required: true
          description: >-
            Media path (absolute local path or `s3://` URL). Read from the raw query string like
            `/media/file`.
          schema:
            type: string
        - name: cache
          in: query
          required: false
          description: Thumbnail size column. Any other value means `thumbnail_path_600`.
          schema:
            $ref: '#/components/schemas/ThumbnailCache'
        - name: ts
          in: query
          required: false
          description: >-
            Video frame time in seconds. Invalid values are ignored. A non-zero value gets its own
            cache file.
          schema:
            type: number
            default: 0
        - name: If-None-Match
          in: header
          required: false
          schema:
            type: string
      responses:
        '200':
          description: Thumbnail bytes.
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
            video/mp4:
              schema:
                type: string
                format: binary
        '302':
          description: S3 media or an S3 thumbnail; redirect to a presigned URL.
          headers:
            Location:
              schema:
                type: string
        '304':
          description: '`If-None-Match` matched.'
        '400':
          description: '`path` missing, empty or badly encoded, or an `s3://` path whose root is not S3 storage.'
          content:
            text/plain:
              schema:
                type: string
        '403':
          description: >-
            Anonymous caller and the local path is outside every configured storage root. (Unlike
            `/media/file`, a library row outside the roots is not enough.)
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: Source file not found, or no storage backend for the `s3://` path.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: The method is not `GET`.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: No database configured, generation failed, or a presigned URL could not be generated.
          content:
            text/plain:
              schema:
                type: string
  /media/hls:
    get:
      tags:
        - File Serving
      operationId: getHlsStatus
      summary: HLS rendition status for a video, starting the transcode if needed.
      description: >
        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: *`.
      x-access: read
      parameters:
        - name: path
          in: query
          required: true
          description: Media path of the video.
          schema:
            type: string
        - name: check
          in: query
          required: false
          description: '`true` reports `idle` instead of starting a transcode when nothing is cached.'
          schema:
            type: string
            enum:
              - 'true'
      responses:
        '200':
          description: Current status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HlsStatus'
              examples:
                ready:
                  value:
                    status: ready
                    url: /media/hls/6f1c...9a/master.m3u8
                processing:
                  value:
                    status: processing
                    progress: 0.45
                started:
                  value:
                    status: processing
                    duration: 120.5
                idle:
                  value:
                    status: idle
        '400':
          description: '`path` is missing.'
          content:
            text/plain:
              schema:
                type: string
        '403':
          description: Anonymous caller and the path is outside every configured storage root.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than `GET` or `DELETE`.
          content:
            text/plain:
              schema:
                type: string
    delete:
      tags:
        - File Serving
      operationId: clearHlsCache
      summary: Delete cached HLS renditions.
      description: >
        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).
      x-access: admin
      parameters:
        - name: path
          in: query
          required: false
          description: Media path whose renditions to delete. Omit to clear everything.
          schema:
            type: string
      responses:
        '204':
          description: Deleted (also when nothing was cached).
        '401':
          description: Allow Public Access is on and the caller is not an admin.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: unauthorized
  /media/hls/{hash}/{file}:
    get:
      tags:
        - File Serving
      operationId: getHlsMasterPlaylist
      summary: Serve a top-level HLS playlist from the cache.
      description: >
        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: *`.
      x-access: read
      parameters:
        - name: hash
          in: path
          required: true
          description: Lowercase hex cache key.
          schema:
            type: string
            pattern: ^[0-9a-f]+$
        - name: file
          in: path
          required: true
          description: '`master.m3u8` or `stream.m3u8`.'
          schema:
            type: string
            pattern: ^(master|stream)\.m3u8$|^segment_\d{3,}\.ts$
      responses:
        '200':
          description: The playlist.
          content:
            application/vnd.apple.mpegurl:
              schema:
                type: string
        '400':
          description: Invalid hash or file name, or a path with the wrong number of segments.
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: Not in the cache.
          content:
            text/plain:
              schema:
                type: string
  /media/hls/{hash}/{preset}/{file}:
    get:
      tags:
        - File Serving
      operationId: getHlsRenditionFile
      summary: Serve a rendition playlist or segment from the cache.
      description: >
        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: *`.
      x-access: read
      parameters:
        - name: hash
          in: path
          required: true
          description: Lowercase hex cache key.
          schema:
            type: string
            pattern: ^[0-9a-f]+$
        - name: preset
          in: path
          required: true
          description: Rendition name.
          schema:
            type: string
            enum:
              - passthrough
              - 480p
              - 720p
              - 1080p
        - name: file
          in: path
          required: true
          description: '`stream.m3u8` or `segment_NNN.ts` (three or more digits).'
          schema:
            type: string
            pattern: ^(master|stream)\.m3u8$|^segment_\d{3,}\.ts$
      responses:
        '200':
          description: Playlist or segment bytes.
          content:
            application/vnd.apple.mpegurl:
              schema:
                type: string
            video/MP2T:
              schema:
                type: string
                format: binary
        '400':
          description: Invalid hash, preset or file name.
          content:
            text/plain:
              schema:
                type: string
        '404':
          description: Not in the cache.
          content:
            text/plain:
              schema:
                type: string
  /media/facecrop:
    get:
      tags:
        - File Serving
      operationId: getFaceCrop
      summary: JPEG crop of one detected face.
      description: >
        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.
      x-access: read
      parameters:
        - name: id
          in: query
          required: true
          description: Face id.
          schema:
            type: integer
        - name: size
          in: query
          required: false
          description: Longer edge in pixels. Values outside 32 to 1024, or not a number, use the default.
          schema:
            type: integer
            default: 160
            minimum: 32
            maximum: 1024
      responses:
        '200':
          description: The crop.
          headers:
            Cache-Control:
              schema:
                type: string
          content:
            image/jpeg:
              schema:
                type: string
                format: binary
        '400':
          description: '`id` missing or not an integer.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            Anonymous caller and the face's media is outside every storage root and not a library
            row.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No face with this id, or its media (and thumbnail) cannot be decoded.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: The method is not `GET`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The database failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/taxonomy:
    get:
      tags:
        - Taxonomy
      operationId: getTaxonomy
      summary: Every category with all of its tags.
      description: >
        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.
      x-access: read
      responses:
        '200':
          description: Map of category label to category.
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  $ref: '#/components/schemas/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: []
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/taxonomy/categories:
    get:
      tags:
        - Taxonomy
      operationId: listCategories
      summary: The category list, without tags.
      description: >-
        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.
      x-access: read
      responses:
        '200':
          description: Categories in weight order.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/CategorySummary'
              example:
                - label: People
                  weight: 0
                  description: ''
                  tagViewMode: card
                  tagGrouping: 'on'
                - label: Suggested
                  weight: 5
                  description: Autotagger output
                  tagViewMode: list
                  tagGrouping: 'off'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/taxonomy/tags:
    get:
      tags:
        - Taxonomy
      operationId: listTaxonomyTags
      summary: Tags in one category, or every tag.
      description: >
        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.
      x-access: read
      parameters:
        - name: category
          in: query
          required: false
          schema:
            type: string
          description: Return only this category's tags (adds `group` to each row).
        - name: excludeCategory
          in: query
          required: false
          schema:
            type: string
          description: >-
            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.
      responses:
        '200':
          description: Tag rows.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TaxonomyTag'
              example:
                - label: Alice
                  category: People
                  weight: 0
                  group: Family
                - label: Bob
                  category: People
                  weight: 1
                  group: null
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/taxonomy/tag:
    get:
      tags:
        - Taxonomy
      operationId: getTag
      summary: Full detail for a single tag.
      description: >-
        Includes the description, preview thumbnail and group that the bulk tag lists leave out. The
        method is not checked.
      x-access: read
      parameters:
        - name: label
          in: query
          required: true
          schema:
            type: string
          description: Tag label.
      responses:
        '200':
          description: The tag.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TagDetail'
              example:
                label: Alice
                category: People
                weight: 0
                description: My sister
                thumbnail_path_600: D:\Library\.thumbnails\abc_600.webp
                group: Family
        '400':
          description: '`label` missing.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No tag with that label.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/taxonomy/category-count:
    get:
      tags:
        - Taxonomy
      operationId: getCategoryMediaCount
      summary: Number of distinct media items tagged in a category.
      description: >-
        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.
      x-access: read
      parameters:
        - name: category
          in: query
          required: false
          schema:
            type: string
          description: Category label. Missing or unknown yields `0`.
        - name: cap
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
          description: Stop counting at this many items. Non-numeric or non-positive values count exactly.
      responses:
        '200':
          description: The count.
          content:
            application/json:
              schema:
                type: integer
              example: 1342
  /api/tags/list:
    get:
      tags:
        - Taxonomy
      operationId: listTagsWithCounts
      summary: Every tag with its usage count.
      description: >-
        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.
      x-access: read
      parameters:
        - name: category
          in: query
          required: false
          schema:
            type: string
          description: Only this category's tags.
      responses:
        '200':
          description: Tags with counts.
          content:
            application/json:
              schema:
                type: object
                required:
                  - tags
                properties:
                  tags:
                    type: array
                    items:
                      $ref: '#/components/schemas/TagWithCount'
              example:
                tags:
                  - label: Alice
                    category: People
                    weight: 0
                    media_count: 212
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/count:
    post:
      tags:
        - Taxonomy
      operationId: countTagMedia
      summary: Number of distinct media items carrying a tag.
      description: The method is not checked. Database errors are not reported (the count is `0`).
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LabelBody'
            example:
              label: Alice
      responses:
        '200':
          description: The count.
          content:
            application/json:
              schema:
                type: object
                required:
                  - count
                properties:
                  count:
                    type: integer
              example:
                count: 212
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/preview:
    post:
      tags:
        - Taxonomy
      operationId: getTagPreview
      summary: The tag's preview thumbnail path.
      description: >-
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LabelBody'
            example:
              label: Alice
      responses:
        '200':
          description: Thumbnail path or null.
          content:
            application/json:
              schema:
                type:
                  - string
                  - 'null'
              example: D:\Library\.thumbnails\abc_600.webp
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags:
    post:
      tags:
        - Taxonomy
      operationId: createTag
      summary: Create a tag.
      description: >-
        Inserts the tag as given; no field is validated. A label that already exists fails with
        `500` (unique constraint).
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  description: New tag label.
                categoryLabel:
                  type: string
                  description: Category to file it under.
                weight:
                  type: number
                  description: Sort weight within the category. Default `0`.
            example:
              label: Alice
              categoryLabel: People
              weight: 3
      responses:
        '200':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - label
                properties:
                  label:
                    type: string
              example:
                label: Alice
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Insert failed (for example the label already exists).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Taxonomy
      operationId: deleteTag
      summary: Delete a tag and all of its assignments.
      description: >-
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - label
              properties:
                label:
                  type: string
                  description: Tag to delete.
                categoryLabel:
                  type: string
                  description: Optional guard; must match the tag's category.
            example:
              label: Alice
              categoryLabel: People
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                  - label
                  - assignments
                properties:
                  label:
                    type: string
                  assignments:
                    type: integer
                    description: Number of assignment rows removed.
              example:
                label: Alice
                assignments: 212
        '400':
          description: Body is not valid JSON or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The tag belongs to a different category than `categoryLabel`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/rename:
    put:
      tags:
        - Taxonomy
      operationId: renameTag
      summary: Rename a tag.
      description: Renames the tag and its assignments in one transaction. The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RenameBody'
            example:
              label: Alice
              newLabel: Alice Smith
      responses:
        '200':
          description: Renamed (also when no tag matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: '`newLabel` is already taken; nothing changed.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/move:
    put:
      tags:
        - Taxonomy
      operationId: moveTag
      summary: Move a tag to another category.
      description: >-
        The tag's assignments move with it, and the tag leaves its tag group (groups belong to a
        category). The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  description: Tag to move.
                categoryLabel:
                  type: string
                  description: Destination category.
            example:
              label: Alice
              categoryLabel: Family
      responses:
        '200':
          description: Moved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Unique-constraint conflict; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database error (for example a destination category that does not exist); nothing
            changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/order:
    put:
      tags:
        - Taxonomy
      operationId: orderTags
      summary: Set tag order by assigning ascending weights.
      description: >-
        The tag at index `i` of `labels` gets weight `i`. Tags not listed keep their weight. The
        method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                labels:
                  type: array
                  items:
                    type: string
                  description: Tag labels in the desired order.
            example:
              labels:
                - Bob
                - Alice
                - Carol
      responses:
        '200':
          description: Reordered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/weight:
    put:
      tags:
        - Taxonomy
      operationId: setTagWeight
      summary: Set one tag's sort weight.
      description: Database errors are not reported. The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WeightBody'
            example:
              label: Alice
              weight: 2.5
      responses:
        '200':
          description: Updated (also when no tag matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/timestamp:
    put:
      tags:
        - Taxonomy
      operationId: moveTagTimestamp
      summary: Move a timestamped assignment to a new time.
      description: >-
        Updates the assignment of `tagLabel` on `mediaPath` whose time is exactly `oldTimestamp`.
        Database errors are not reported.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mediaPath:
                  type: string
                tagLabel:
                  type: string
                oldTimestamp:
                  type: number
                  description: Current time in seconds.
                newTimestamp:
                  type: number
                  description: New time in seconds.
            example:
              mediaPath: D:\Videos\clip.mp4
              tagLabel: Alice
              oldTimestamp: 12.5
              newTimestamp: 14
      responses:
        '200':
          description: Updated (also when nothing matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Taxonomy
      operationId: removeTagTimestamp
      summary: Remove the timestamp from a timestamped assignment.
      description: >-
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mediaPath:
                  type: string
                tagLabel:
                  type: string
                timestamp:
                  type: number
                  description: Time in seconds of the assignment to change.
            example:
              mediaPath: D:\Videos\clip.mp4
              tagLabel: Alice
              timestamp: 12.5
      responses:
        '200':
          description: Done (also when nothing matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/categories:
    post:
      tags:
        - Taxonomy
      operationId: createCategory
      summary: Create a category.
      description: No field is validated. A label that already exists fails with `500` (unique constraint).
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  description: New category label.
                weight:
                  type: number
                  description: Sort weight. Default `0`.
            example:
              label: Places
              weight: 4
      responses:
        '200':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                required:
                  - label
                properties:
                  label:
                    type: string
              example:
                label: Places
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Insert failed (for example the label already exists).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Taxonomy
      operationId: deleteCategory
      summary: Delete a category with its tags, tag groups and their assignments.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - label
              properties:
                label:
                  type: string
                  description: Category to delete.
            example:
              label: Suggested
      responses:
        '200':
          description: Deleted; what was removed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryImpact'
              example:
                label: Suggested
                exists: true
                tags: 1800
                groups: 0
                assignments: 92000
                media: 41000
        '400':
          description: Body is not valid JSON or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Unique-constraint conflict; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/categories/impact:
    get:
      tags:
        - Taxonomy
      operationId: getCategoryDeleteImpact
      summary: What deleting a category would remove, without removing it.
      description: Same counts `DELETE /api/categories` returns. The method is not checked.
      x-access: read
      parameters:
        - name: label
          in: query
          required: true
          schema:
            type: string
          description: Category label.
      responses:
        '200':
          description: Impact counts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoryImpact'
              example:
                label: Suggested
                exists: true
                tags: 1800
                groups: 0
                assignments: 92000
                media: 41000
        '400':
          description: '`label` missing.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/categories/rename:
    put:
      tags:
        - Taxonomy
      operationId: renameCategory
      summary: Rename a category.
      description: >-
        Renames the category and re-points its tags, tag groups and assignments in one transaction.
        The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RenameBody'
            example:
              label: Places
              newLabel: Locations
      responses:
        '200':
          description: Renamed (also when no category matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: '`newLabel` is already taken; nothing changed.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/categories/tag-view-mode:
    put:
      tags:
        - Taxonomy
      operationId: setCategoryTagViewMode
      summary: Set how the viewer lists a category's tags.
      description: >-
        `mode` is stored as given (the viewer uses `card` and `list`; an unset mode reads back as
        `card`). The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - label
              properties:
                label:
                  type: string
                  description: Category label.
                mode:
                  type: string
                  description: View mode, normally `card` or `list`.
            example:
              label: People
              mode: list
      responses:
        '200':
          description: Updated (also when no category matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/assignments:
    post:
      tags:
        - Taxonomy
      operationId: createAssignments
      summary: Tag one or many media items.
      description: >
        - `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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tagLabel
              properties:
                mediaPaths:
                  type: array
                  items:
                    type: string
                  description: Items to tag. One of `mediaPaths` or `mediaPath` is required.
                mediaPath:
                  type: string
                  description: Single item to tag, used when `mediaPaths` is empty.
                tagLabel:
                  type: string
                  description: Tag to assign.
                categoryLabel:
                  type: string
                  description: The tag's category. Required only when the tag does not exist yet.
                timeStamp:
                  type: number
                  description: Video time in seconds. Default `0` (untimed).
                applyTagPreview:
                  type: boolean
                  description: 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
      responses:
        '200':
          description: Assigned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: >-
            Body 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.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The tag belongs to a different category than `categoryLabel`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error; nothing was assigned.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Taxonomy
      operationId: deleteAssignments
      summary: Remove a tag from one or many media items.
      description: >
        - `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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mediaPaths:
                  type: array
                  items:
                    type: string
                  description: Items to untag.
                mediaPath:
                  type: string
                  description: Single item, used when `mediaPaths` is empty.
                tag:
                  type: object
                  properties:
                    tag_label:
                      type: string
                      description: Tag to remove.
                    time_stamp:
                      type: number
                      description: >-
                        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
      responses:
        '200':
          description: Done.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/assignments/weight:
    put:
      tags:
        - Taxonomy
      operationId: setAssignmentWeight
      summary: Set the weight of an assignment.
      description: >-
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                mediaPath:
                  type: string
                tagLabel:
                  type: string
                weight:
                  type: number
                mediaTimeStamp:
                  type: number
                  description: Time in seconds of the assignment; default `0`.
            example:
              mediaPath: D:\Photos\a.jpg
              tagLabel: Alice
              weight: 0
      responses:
        '200':
          description: Updated (also when nothing matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/taxonomy/groups:
    get:
      tags:
        - Tag Groups
      operationId: listTagGroups
      summary: A category's tag groups in order.
      description: >-
        Ordered by weight, then label. Without `category` the result is an empty array. The method
        is not checked.
      x-access: read
      parameters:
        - name: category
          in: query
          required: false
          schema:
            type: string
          description: Category label.
      responses:
        '200':
          description: Groups.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/TagGroup'
              example:
                - label: Family
                  weight: 1
                  hidden: false
                  viewMode: null
                - label: Work
                  weight: 2
                  hidden: true
                  viewMode: list
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tag-groups:
    post:
      tags:
        - Tag Groups
      operationId: createTagGroup
      summary: Create a tag group.
      description: >-
        The group goes to the end of the category's list. Creating a group that already exists is a
        no-op that still succeeds.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TagGroupRef'
            example:
              categoryLabel: People
              label: Family
      responses:
        '200':
          description: Created (or already present).
          content:
            application/json:
              schema:
                type: object
                required:
                  - label
                properties:
                  label:
                    type: string
              example:
                label: Family
        '400':
          description: Body is not valid JSON, or `categoryLabel` or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Tag Groups
      operationId: deleteTagGroup
      summary: Delete a tag group; its tags become ungrouped.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TagGroupRef'
            example:
              categoryLabel: People
              label: Family
      responses:
        '200':
          description: Deleted (also when no group matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON, or `categoryLabel` or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Unique-constraint conflict; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tag-groups/rename:
    put:
      tags:
        - Tag Groups
      operationId: renameTagGroup
      summary: Rename a tag group.
      description: >-
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/TagGroupRef'
                - type: object
                  properties:
                    newLabel:
                      type: string
                      description: New group label.
            example:
              categoryLabel: People
              label: Family
              newLabel: Relatives
      responses:
        '200':
          description: Renamed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON, or `categoryLabel` or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: A group with `newLabel` already exists in the category; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tag-groups/weight:
    put:
      tags:
        - Tag Groups
      operationId: setTagGroupWeight
      summary: Set a tag group's sort weight.
      description: The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/TagGroupRef'
                - type: object
                  properties:
                    weight:
                      type: number
                      description: Sort weight. Default `0`.
            example:
              categoryLabel: People
              label: Family
              weight: 0
      responses:
        '200':
          description: Updated (also when no group matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON, or `categoryLabel` or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tag-groups/hidden:
    put:
      tags:
        - Tag Groups
      operationId: setTagGroupHidden
      summary: Hide or show a tag group.
      description: >-
        A hidden group is left out of the taxonomy panel unless the viewer chooses to show hidden
        groups. The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/TagGroupRef'
                - type: object
                  properties:
                    hidden:
                      type: boolean
                      description: Default `false` (shows the group).
            example:
              categoryLabel: People
              label: Work
              hidden: true
      responses:
        '200':
          description: Updated (also when no group matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON, or `categoryLabel` or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tag-groups/view-mode:
    put:
      tags:
        - Tag Groups
      operationId: setTagGroupViewMode
      summary: Override the category's tag view mode for one group.
      description: >-
        `card` or `list` sets the override; any other value (normally `""`) clears it so the group
        follows the category. The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/TagGroupRef'
                - type: object
                  properties:
                    viewMode:
                      type: string
                      description: '`card`, `list`, or anything else to clear.'
            example:
              categoryLabel: People
              label: Work
              viewMode: list
      responses:
        '200':
          description: Updated (also when no group matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON, or `categoryLabel` or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/tags/group:
    put:
      tags:
        - Tag Groups
      operationId: setTagGroup
      summary: Put a tag in a group, or ungroup it.
      description: >-
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - label
              properties:
                label:
                  type: string
                  description: Tag label.
                groupLabel:
                  type: string
                  description: Group within the tag's category; empty ungroups.
            example:
              label: Alice
              groupLabel: Family
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON, `label` is empty, or the tag has no category to group under.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No tag with that label.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Unique-constraint conflict; nothing changed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/categories/tag-grouping:
    put:
      tags:
        - Tag Groups
      operationId: setCategoryTagGrouping
      summary: Turn tag grouping on or off for a category.
      description: >-
        `off` turns grouping off; any other value turns it on. Groups and group labels are kept
        either way. The method is not checked.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - label
              properties:
                label:
                  type: string
                  description: Category label.
                mode:
                  type: string
                  description: '`off`, or anything else for on.'
            example:
              label: People
              mode: 'off'
      responses:
        '200':
          description: Updated (also when no category matched).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body is not valid JSON or `label` is empty.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/index/status:
    get:
      tags:
        - Embeddings Index
      operationId: getIndexStatus
      summary: In-memory index state and per-model storage statistics.
      x-access: admin
      responses:
        '200':
          description: Index status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IndexStatus'
              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
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/index/models:
    get:
      tags:
        - Embeddings Index
      operationId: listEmbedModels
      summary: The embedding model registry.
      x-access: admin
      responses:
        '200':
          description: Models.
          content:
            application/json:
              schema:
                type: object
                required:
                  - models
                properties:
                  models:
                    type: array
                    items:
                      $ref: '#/components/schemas/EmbedModelInfo'
              example:
                models:
                  - id: siglip2-base-patch16-224
                    display_name: SigLIP 2 base (multimodal — image + text)
                    dim: 768
                    multimodal: true
                    active: true
                    indexed: true
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/index/rebuild:
    post:
      tags:
        - Embeddings Index
      operationId: rebuildIndex
      summary: Rebuild the in-memory index for the active model.
      description: >-
        Reloads every stored vector of the active embedding model into a fresh index and swaps it
        in. Blocks until done.
      x-access: admin
      responses:
        '200':
          description: Rebuilt.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - model
                  - vectors
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  model:
                    type: string
                    description: Active model id.
                  vectors:
                    type: integer
                    description: Vectors in the new index.
              example:
                status: ok
                model: siglip2-base-patch16-224
                vectors: 51234
        '405':
          description: Method other than POST.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Loading vectors failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/index/missing:
    get:
      tags:
        - Embeddings Index
      operationId: listMissingEmbeddings
      summary: Media items without an embedding for a model.
      x-access: admin
      parameters:
        - name: model
          in: query
          required: false
          schema:
            type: string
          description: Model id from the registry. Default the active model.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            maximum: 10000
            default: 100
          description: >-
            Paths to return, sorted by path. Values above 10000 are clamped; `0` returns only the
            count.
      responses:
        '200':
          description: Missing items.
          content:
            application/json:
              schema:
                type: object
                required:
                  - model
                  - total_missing
                  - paths
                properties:
                  model:
                    type: string
                  total_missing:
                    type: integer
                    description: All items lacking an embedding, regardless of `limit`.
                  paths:
                    type: array
                    items:
                      type: string
              example:
                model: siglip2-base-patch16-224
                total_missing: 766
                paths:
                  - D:\Photos\new1.jpg
        '400':
          description: Unknown `model`, or `limit` is not a non-negative integer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/embeddings:
    get:
      tags:
        - Embeddings Index
      operationId: getEmbeddings
      summary: Stored embedding rows for one media item.
      description: One row per model unless `model` is given. Rows that fail to scan are skipped.
      x-access: admin
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
          description: Media path.
        - name: model
          in: query
          required: false
          schema:
            type: string
          description: Only this model's row.
        - name: vector
          in: query
          required: false
          schema:
            type: string
            enum:
              - 'true'
          description: '`true` includes the decoded float vector in each row.'
      responses:
        '200':
          description: Embedding rows.
          content:
            application/json:
              schema:
                type: object
                required:
                  - path
                  - embeddings
                properties:
                  path:
                    type: string
                  embeddings:
                    type: array
                    items:
                      $ref: '#/components/schemas/StoredEmbeddingRow'
              example:
                path: D:\Photos\a.jpg
                embeddings:
                  - model: siglip2-base-patch16-224
                    dim: 768
                    created_at: 0
        '400':
          description: '`path` missing.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than GET or DELETE (checked after `path`).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Embeddings Index
      operationId: deleteEmbeddings
      summary: Delete stored embedding rows for one media item.
      description: >-
        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.
      x-access: admin
      parameters:
        - name: path
          in: query
          required: true
          schema:
            type: string
          description: Media path.
        - name: model
          in: query
          required: false
          schema:
            type: string
          description: Only this model's row.
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                required:
                  - deleted
                properties:
                  deleted:
                    type: integer
                    description: Rows removed.
              example:
                deleted: 2
        '400':
          description: '`path` missing.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/embeddings/prune:
    post:
      tags:
        - Embeddings Index
      operationId: pruneEmbeddings
      summary: Delete embeddings whose media row is gone.
      description: >-
        Removes orphaned rows (for every model) left by out-of-band deletions and drops those paths
        from the live index.
      x-access: admin
      responses:
        '200':
          description: Pruned.
          content:
            application/json:
              schema:
                type: object
                required:
                  - pruned_rows
                  - pruned_paths
                properties:
                  pruned_rows:
                    type: integer
                    description: Embedding rows deleted.
                  pruned_paths:
                    type: integer
                    description: Distinct media paths they belonged to.
              example:
                pruned_rows: 24
                pruned_paths: 12
        '405':
          description: Method other than POST.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/embeddings/all:
    delete:
      tags:
        - Embeddings Index
      operationId: wipeAllEmbeddings
      summary: Delete every stored embedding, for all models.
      description: >-
        Also clears the in-memory index. Not undoable, though re-running the embed task rebuilds
        everything. Requires `confirm=true`.
      x-access: admin
      parameters:
        - name: confirm
          in: query
          required: true
          schema:
            type: string
            enum:
              - 'true'
          description: Must be `true`.
      responses:
        '200':
          description: Wiped.
          content:
            application/json:
              schema:
                type: object
                required:
                  - deleted
                properties:
                  deleted:
                    type: integer
                    description: Rows removed.
              example:
                deleted: 102468
        '400':
          description: '`confirm=true` missing.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than DELETE.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people:
    get:
      tags:
        - People and Faces
      operationId: listPeople
      summary: List every person.
      description: >-
        Returns every person with face and media counts, most faces first. Always an array (empty
        when there are no people).
      x-access: read
      responses:
        '200':
          description: All people.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Person'
              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
        '405':
          description: >-
            Method other than GET or POST. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags:
        - People and Faces
      operationId: createPerson
      summary: Create an empty named person.
      description: >-
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: Person name; trimmed. Must be non-empty and not already used by another person.
              required:
                - name
            example:
              name: Ada
      responses:
        '200':
          description: Created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    format: int64
                    description: New person id.
                  name:
                    type: string
                    description: >-
                      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.
                required:
                  - id
                  - name
              example:
                id: 14
                name: Ada
        '400':
          description: >-
            Body 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`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: >-
            Allow Public Access is on and the request carries no admin credential (anonymous
            visitors may read but not write here).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: unauthorized
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/lock-all:
    post:
      tags:
        - People and Faces
      operationId: lockAllNamedPeople
      summary: Confirm every auto-assigned face of every named person.
      description: >-
        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.
      x-access: admin
      responses:
        '200':
          description: Counts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  people:
                    type: integer
                    description: Named people that had faces locked.
                  locked:
                    type: integer
                    description: Faces promoted to user assignments.
                required:
                  - people
                  - locked
              example:
                people: 9
                locked: 412
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}:
    delete:
      tags:
        - People and Faces
      operationId: deletePerson
      summary: Delete a person.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
        - name: deleteFaces
          in: query
          required: false
          description: Exactly `true` also deletes the person's faces. Any other value is ignored.
          schema:
            type: string
            enum:
              - 'true'
        - name: ban
          in: query
          required: false
          description: Exactly `false` skips recording the group ban. Ignored when `deleteFaces=true`.
          schema:
            type: string
            enum:
              - 'false'
      responses:
        '200':
          description: >-
            Deleted. With `deleteFaces=true` the body is `{deleted, facesDeleted}`; otherwise
            `{deleted, banned}`.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    title: Faces kept
                    properties:
                      deleted:
                        type: integer
                        format: int64
                        description: The deleted person id.
                      banned:
                        type: integer
                        description: Faces recorded in the group ban (0 with `ban=false`).
                    required:
                      - deleted
                      - banned
                  - type: object
                    title: Faces deleted
                    properties:
                      deleted:
                        type: integer
                        format: int64
                        description: The deleted person id.
                      facesDeleted:
                        type: integer
                        description: Face rows deleted.
                    required:
                      - deleted
                      - facesDeleted
              example:
                deleted: 13
                banned: 6
        '400':
          description: >-
            Invalid person id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No person with that id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than DELETE. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}/rename:
    post:
      tags:
        - People and Faces
      operationId: renamePerson
      summary: Rename a person.
      description: >-
        Renames the person and cascades the new name to their People-category tag rows. Broadcasts a
        `people-updated` event on `/stream`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: New name; trimmed, must be non-empty.
              required:
                - name
            example:
              name: Ada Lovelace
      responses:
        '200':
          description: Renamed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    format: int64
                  name:
                    type: string
                    description: >-
                      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.
                required:
                  - id
                  - name
              example:
                id: 12
                name: Ada Lovelace
        '400':
          description: >-
            Invalid 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`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No person with that id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}/merge:
    post:
      tags:
        - People and Faces
      operationId: mergePerson
      summary: Merge this person into another.
      description: >-
        Moves every face and tag row of person `{id}` into `intoId`, then deletes `{id}`. Broadcasts
        a `people-updated` event on `/stream`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Source person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                intoId:
                  type: integer
                  format: int64
                  minimum: 1
                  description: Target person id.
              required:
                - intoId
            example:
              intoId: 12
      responses:
        '200':
          description: Merged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  merged:
                    type: integer
                    format: int64
                    description: The source (now deleted) person id.
                  into:
                    type: integer
                    format: int64
                    description: The target person id.
                required:
                  - merged
                  - into
              example:
                merged: 13
                into: 12
        '400':
          description: >-
            Invalid id, missing/invalid `intoId` (`intoId required`), or merging a person into
            itself. The body is `{"error": "..."}` but is sent with `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            Either person does not exist. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}/media:
    get:
      tags:
        - People and Faces
      operationId: listPersonMedia
      summary: List the media items containing a person.
      description: >-
        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).
      x-access: read
      parameters:
        - name: id
          in: path
          required: true
          description: Person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Ranked items.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ScoredMediaItem'
              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
        '400':
          description: >-
            Invalid person id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No such person. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}/faces:
    get:
      tags:
        - People and Faces
      operationId: listPersonFaces
      summary: List a person's faces for review, least typical first.
      description: >-
        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.
      x-access: read
      parameters:
        - name: id
          in: path
          required: true
          description: Person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Faces with typicality.
          content:
            application/json:
              schema:
                type: object
                properties:
                  personId:
                    type: integer
                    format: int64
                  faces:
                    type: array
                    items:
                      $ref: '#/components/schemas/PersonReviewFace'
                required:
                  - personId
                  - faces
              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
        '400':
          description: >-
            Invalid person id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No such person. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}/lock:
    post:
      tags:
        - People and Faces
      operationId: lockPerson
      summary: Confirm every auto-assigned face of one person.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Locked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  personId:
                    type: integer
                    format: int64
                  locked:
                    type: integer
                    description: Faces promoted.
                required:
                  - personId
                  - locked
              example:
                personId: 12
                locked: 67
        '400':
          description: >-
            Invalid person id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No person with that id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}/curate:
    post:
      tags:
        - People and Faces
      operationId: curatePerson
      summary: Keep the listed faces, reject the rest.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                keepFaceIds:
                  type: array
                  items:
                    type: integer
                    format: int64
                  description: Face ids to keep. An empty or missing list rejects every face of the person.
            example:
              keepFaceIds:
                - 4031
                - 4032
                - 4107
      responses:
        '200':
          description: Curated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  personId:
                    type: integer
                    format: int64
                  kept:
                    type: integer
                    description: Faces locked.
                  rejected:
                    type: integer
                    description: Faces rejected.
                required:
                  - personId
                  - kept
                  - rejected
              example:
                personId: 12
                kept: 3
                rejected: 2
        '400':
          description: >-
            Invalid id or invalid JSON body. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No person with that id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/people/{id}/cover:
    post:
      tags:
        - People and Faces
      operationId: regeneratePersonCover
      summary: Pick a renderable cover face for a person.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Person id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Cover set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  personId:
                    type: integer
                    format: int64
                  coverFaceId:
                    type: integer
                    format: int64
                    description: The face now used as the cover crop.
                required:
                  - personId
                  - coverFaceId
              example:
                personId: 12
                coverFaceId: 4031
        '400':
          description: >-
            Invalid person id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No such person. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: >-
            The 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`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces:
    get:
      tags:
        - People and Faces
      operationId: getFacesForPath
      summary: Stored faces for one media item.
      description: >-
        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).
      x-access: read
      parameters:
        - name: path
          in: query
          required: true
          description: Media path.
          schema:
            type: string
      responses:
        '200':
          description: Face scan status and faces.
          content:
            application/json:
              schema:
                type: object
                properties:
                  model:
                    type: string
                    description: Recognizer id.
                  scanned:
                    type: boolean
                    description: Whether the item has been face-scanned.
                  scannedAt:
                    type: integer
                    format: int64
                    description: >-
                      Scan time in unix seconds; 0 when not scanned or for legacy rows without a
                      timestamp.
                  faces:
                    type: array
                    items:
                      $ref: '#/components/schemas/Face'
                required:
                  - model
                  - scanned
                  - scannedAt
                  - faces
              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
        '400':
          description: >-
            `path` missing. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/{id}/similar:
    get:
      tags:
        - People and Faces
      operationId: listSimilarFaces
      summary: Media ranked by identity similarity to one face.
      description: >-
        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.
      x-access: read
      parameters:
        - name: id
          in: path
          required: true
          description: Face id.
          schema:
            type: integer
            format: int64
        - name: limit
          in: query
          required: false
          description: >-
            Faces to search. Values above 500 are clamped to 500; non-positive or non-numeric values
            use the default.
          schema:
            type: integer
            default: 100
            maximum: 500
      responses:
        '200':
          description: Ranked items, each with the matched face.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/FaceHitItem'
              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
        '400':
          description: >-
            Face id is not an integer. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No face with that id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/{id}/assign:
    post:
      tags:
        - People and Faces
      operationId: assignFace
      summary: Assign a face to a person (confirmed).
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Face id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                personId:
                  type: integer
                  format: int64
                  description: Existing person id. Takes precedence when non-zero.
                name:
                  type: string
                  description: Person name to reuse or create (trimmed).
                newPerson:
                  type: boolean
                  description: Mint a new auto-named person when neither `personId` nor `name` is given.
            example:
              name: Ada
      responses:
        '200':
          description: Assigned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  faceId:
                    type: integer
                    format: int64
                  personId:
                    type: integer
                    format: int64
                    description: The person the face now belongs to.
                  created:
                    type: boolean
                    description: >-
                      True when a new person was created for this assignment (its only face becomes
                      the cover).
                  name:
                    type: string
                    description: >-
                      The name used when assigning by `name` / `newPerson`; empty when assigned by
                      `personId`.
                required:
                  - faceId
                  - personId
                  - created
                  - name
              example:
                faceId: 5120
                personId: 12
                created: false
                name: Ada
        '400':
          description: >-
            Invalid face id, invalid JSON, or none of `personId`, `name`, `newPerson` given. The
            body is `{"error": "..."}` but is sent with `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No face or no person with that id. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/{id}/unassign:
    post:
      tags:
        - People and Faces
      operationId: unassignFace
      summary: Detach a face from its person.
      description: >-
        No request body. Unlike reject, this records no veto, so clustering may place the face
        again. Broadcasts a `people-updated` event on `/stream`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Face id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Unassigned.
          content:
            application/json:
              schema:
                type: object
                properties:
                  faceId:
                    type: integer
                    format: int64
                  unassigned:
                    type: boolean
                    const: true
                required:
                  - faceId
                  - unassigned
              example:
                faceId: 5120
                unassigned: true
        '400':
          description: >-
            Invalid face id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No face with that id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/{id}/reject:
    post:
      tags:
        - People and Faces
      operationId: rejectFace
      summary: '"Not them": permanently veto a face from a person.'
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Face id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                personId:
                  type: integer
                  format: int64
                  description: Person to reject the face from. Required when the face is currently unassigned.
            example:
              personId: 12
      responses:
        '200':
          description: Rejected.
          content:
            application/json:
              schema:
                type: object
                properties:
                  faceId:
                    type: integer
                    format: int64
                  personId:
                    type: integer
                    format: int64
                  cannotLinks:
                    type: integer
                    description: Cannot-link constraints recorded.
                required:
                  - faceId
                  - personId
                  - cannotLinks
              example:
                faceId: 5120
                personId: 12
                cannotLinks: 8
        '400':
          description: >-
            Invalid 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`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No face or no person with that id. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/all:
    delete:
      tags:
        - People and Faces
      operationId: deleteAllFaceData
      summary: Wipe all face data (privacy reset).
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: confirm
          in: query
          required: true
          description: Must be exactly `true`.
          schema:
            type: string
            enum:
              - 'true'
      responses:
        '200':
          description: Wiped.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    const: true
                required:
                  - deleted
              example:
                deleted: true
        '400':
          description: >-
            `confirm=true` missing. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than DELETE. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/stats:
    get:
      tags:
        - People and Faces
      operationId: getFaceStats
      summary: How much face data is stored.
      description: >-
        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.
      x-access: read
      responses:
        '200':
          description: Storage stats.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FaceStats'
              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
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/ungrouped:
    get:
      tags:
        - People and Faces
      operationId: getUngroupedFaces
      summary: Count (and optionally page) faces not assigned to any person.
      description: >-
        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.
      x-access: read
      parameters:
        - name: faces
          in: query
          required: false
          description: Exactly `1` to include the `faces` page.
          schema:
            type: string
            enum:
              - '1'
        - name: limit
          in: query
          required: false
          description: Page size with `faces=1`. Values outside 1-500 use the default.
          schema:
            type: integer
            default: 120
            minimum: 1
            maximum: 500
        - name: offset
          in: query
          required: false
          description: Page offset with `faces=1`.
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        '200':
          description: Count, plus faces when requested.
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                    description: Unassigned faces.
                  faces:
                    type: array
                    items:
                      $ref: '#/components/schemas/UngroupedFace'
                    description: Present only with `faces=1`.
                required:
                  - count
              example:
                count: 1873
                faces:
                  - id: 9001
                    path: D:/photos/party.jpg
                    frameTs: 0
                    detScore: 0.97
                    model: buffalo_l
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/faces/tuning:
    get:
      tags:
        - People and Faces
      operationId: getFaceTuning
      summary: Read the face-grouping tuner.
      description: >-
        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.
      x-access: admin
      responses:
        '200':
          description: Effective tuning.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FaceTuning'
              example:
                thresholdOffset: 0
                minCluster: 3
                minQuality: 0.75
        '405':
          description: >-
            Method other than GET or POST. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    post:
      tags:
        - People and Faces
      operationId: setFaceTuning
      summary: Save the face-grouping tuner.
      description: >-
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                thresholdOffset:
                  type: number
                  minimum: -0.2
                  maximum: 0.3
                  description: >-
                    Added to the clustering similarity threshold. Positive values make grouping
                    stricter.
                minCluster:
                  type: integer
                  minimum: 1
                  maximum: 50
                  description: Minimum faces for a new group to form.
                minQuality:
                  type: number
                  exclusiveMinimum: 0
                  exclusiveMaximum: 1
                  description: Minimum face quality for clustering.
              required:
                - minCluster
                - minQuality
            example:
              thresholdOffset: 0.05
              minCluster: 4
              minQuality: 0.8
      responses:
        '200':
          description: Saved; the effective values.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FaceTuning'
              example:
                thresholdOffset: 0.05
                minCluster: 4
                minQuality: 0.8
        '400':
          description: >-
            Invalid 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`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Saving the config failed. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates:
    get:
      tags:
        - Duplicate Candidates
      operationId: listDuplicateGroups
      summary: Page through duplicate-candidate groups.
      x-access: read
      parameters:
        - name: status
          in: query
          required: false
          description: Which groups. Default `pending`. Case-insensitive.
          schema:
            type: string
            enum:
              - pending
              - dismissed
              - all
        - name: sort
          in: query
          required: false
          description: >-
            `newest` (default; most recently found first) or `members` (most active members first).
            Case-insensitive.
          schema:
            type: string
            enum:
              - newest
              - members
            default: newest
        - name: limit
          in: query
          required: false
          description: Page size. Values outside 1-500 use the default.
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 500
        - name: offset
          in: query
          required: false
          description: Page offset.
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        '200':
          description: One page of groups.
          content:
            application/json:
              schema:
                type: object
                properties:
                  groups:
                    type: array
                    items:
                      $ref: '#/components/schemas/DuplicateGroup'
                  total:
                    type: integer
                    description: Groups matching `status`, unpaged.
                  limit:
                    type: integer
                    description: Effective page size.
                  offset:
                    type: integer
                    description: Effective offset.
                required:
                  - groups
                  - total
                  - limit
                  - offset
              example:
                groups: []
                total: 0
                limit: 50
                offset: 0
        '400':
          description: >-
            Unknown `status` or `sort` value. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/stats:
    get:
      tags:
        - Duplicate Candidates
      operationId: getDuplicateStats
      summary: Group and member counts.
      x-access: read
      responses:
        '200':
          description: Counts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DuplicateStats'
              example:
                pending: 42
                dismissed: 7
                members: 131
                pendingItems: 98
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/for-path:
    get:
      tags:
        - Duplicate Candidates
      operationId: getDuplicateGroupForPath
      summary: The group a media item belongs to.
      description: A path belongs to at most one group (as an active or excluded member).
      x-access: read
      parameters:
        - name: path
          in: query
          required: true
          description: Media path (trimmed).
          schema:
            type: string
      responses:
        '200':
          description: Group membership.
          content:
            application/json:
              schema:
                type: object
                properties:
                  groupId:
                    type: integer
                    format: int64
                    description: 0 when the item is in no group.
                  status:
                    type: string
                    enum:
                      - pending
                      - dismissed
                    description: Present when `groupId` is non-zero.
                  activeCount:
                    type: integer
                    description: Active members of the group. Present when `groupId` is non-zero.
                required:
                  - groupId
              example:
                groupId: 311
                status: pending
                activeCount: 3
        '400':
          description: >-
            `path` missing (`path required`). The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than GET. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/all:
    delete:
      tags:
        - Duplicate Candidates
      operationId: deleteAllDuplicateGroups
      summary: Forget every group (optionally of one status).
      description: >-
        Forgotten items become eligible for grouping again on the next `find-duplicates` run.
        Broadcasts a `duplicates-updated` event on `/stream`.
      x-access: admin
      parameters:
        - name: confirm
          in: query
          required: true
          description: Must be exactly `true`.
          schema:
            type: string
            enum:
              - 'true'
        - name: status
          in: query
          required: false
          description: Narrow to one status. Omitted or `all` deletes every group. Case-insensitive.
          schema:
            type: string
            enum:
              - pending
              - dismissed
              - all
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: integer
                    description: Groups deleted.
                required:
                  - deleted
              example:
                deleted: 49
        '400':
          description: >-
            `confirm=true` missing (`confirm=true required`), or unknown `status`. The body is
            `{"error": "..."}` but is sent with `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than DELETE. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/{id}:
    get:
      tags:
        - Duplicate Candidates
      operationId: getDuplicateGroup
      summary: One group, in the list shape.
      x-access: read
      parameters:
        - name: id
          in: path
          required: true
          description: Group id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: The group.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DuplicateGroup'
        '400':
          description: >-
            Invalid group id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No such duplicate group. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than GET or DELETE. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Duplicate Candidates
      operationId: deleteDuplicateGroup
      summary: Forget one group without deciding.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Group id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: integer
                    format: int64
                    description: The deleted group id.
                required:
                  - deleted
              example:
                deleted: 311
        '400':
          description: >-
            Invalid group id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: >-
            Allow Public Access is on and the request carries no admin credential (anonymous
            visitors may read but not write here).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: unauthorized
        '404':
          description: >-
            No duplicate group with that id. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/{id}/merge:
    post:
      tags:
        - Duplicate Candidates
      operationId: mergeDuplicateGroup
      summary: Merge group members into one keeper.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Group id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                keep:
                  type: string
                  description: Path of the member to keep.
                sources:
                  type: array
                  items:
                    type: string
                  description: Member paths to merge into `keep` and delete.
            example:
              keep: D:/photos/IMG_0001.jpg
              sources:
                - D:/photos/IMG_0001 (1).jpg
      responses:
        '200':
          description: Merged.
          content:
            application/json:
              schema:
                type: object
                properties:
                  merge:
                    $ref: '#/components/schemas/DuplicateMergeResult'
                  group:
                    oneOf:
                      - $ref: '#/components/schemas/DuplicateGroup'
                      - type: 'null'
                    description: >-
                      The group after the merge, or `null` when it no longer exists (nothing left to
                      review).
                required:
                  - merge
                  - group
              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
        '400':
          description: >-
            Invalid 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`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No such duplicate group. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database failure or the merge failed before deleting anything. The body is `{"error":
            "..."}` but is sent with `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/{id}/dismiss:
    post:
      tags:
        - Duplicate Candidates
      operationId: dismissDuplicateGroup
      summary: Mark a group as not duplicates.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Group id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Status changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    format: int64
                  status:
                    type: string
                    const: dismissed
                required:
                  - id
                  - status
              example:
                id: 311
                status: dismissed
        '400':
          description: >-
            Invalid group id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No duplicate group with that id. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/{id}/restore:
    post:
      tags:
        - Duplicate Candidates
      operationId: restoreDuplicateGroup
      summary: Return a group to pending.
      description: >-
        Sets the status back to `pending`. No request body. Broadcasts a `duplicates-updated` event
        on `/stream`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Group id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      responses:
        '200':
          description: Status changed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    format: int64
                  status:
                    type: string
                    const: pending
                required:
                  - id
                  - status
              example:
                id: 311
                status: pending
        '400':
          description: >-
            Invalid group id. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            No duplicate group with that id. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/duplicates/{id}/exclude:
    post:
      tags:
        - Duplicate Candidates
      operationId: excludeDuplicateMember
      summary: Pull one member out of a group, or put it back.
      description: >-
        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`.
      x-access: admin
      parameters:
        - name: id
          in: path
          required: true
          description: Group id (positive integer).
          schema:
            type: integer
            format: int64
            minimum: 1
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: Member path (trimmed).
                excluded:
                  type: boolean
                  default: true
                  description: '`false` puts the member back.'
              required:
                - path
            example:
              path: D:/photos/lookalike.jpg
              excluded: true
      responses:
        '200':
          description: Updated group.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                    format: int64
                  group:
                    oneOf:
                      - $ref: '#/components/schemas/DuplicateGroup'
                      - type: 'null'
                    description: The group after the change; `null` if it no longer exists.
                required:
                  - id
                  - group
        '400':
          description: >-
            Invalid id, invalid JSON, or `path` missing (`bad request: path required`). The body is
            `{"error": "..."}` but is sent with `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            The path is not a member of the group. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: >-
            Method other than POST. The body is `{"error": "..."}` but is sent with `Content-Type:
            text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Database or internal failure. The body is `{"error": "..."}` but is sent with
            `Content-Type: text/plain`.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /swipe/api:
    get:
      tags:
        - Swipe
      operationId: getSwipeItems
      summary: A page of items for the Swipe app.
      description: >-
        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.
      x-access: read
      parameters:
        - name: mode
          in: query
          required: false
          description: Selection mode; see the description. Unrecognized values fall back to the shuffle.
          schema:
            type: string
            enum:
              - similar
              - feed
              - text
              - face
        - name: q
          in: query
          required: false
          description: >-
            Shuffle mode: library search query filter. `text` mode: the search text (required,
            trimmed).
          schema:
            type: string
        - name: anchor
          in: query
          required: false
          description: Media path. Required for `similar` and `face`; excluded from its own results.
          schema:
            type: string
        - name: seed
          in: query
          required: false
          description: >-
            Shuffle mode only. Same seed, same order (for unfiltered and tag-filtered pools). 0
            means fresh randomness.
          schema:
            type: integer
            format: int64
            default: 0
        - name: session
          in: query
          required: false
          description: '`feed` mode: client session id keying the server-side feed sequence.'
          schema:
            type: string
            default: default
        - name: exploit
          in: query
          required: false
          description: >-
            `feed` mode: weight override for the nearest-neighbours-of-your-taste lane. Must be >=
            0.
          schema:
            type: number
            minimum: 0
        - name: fresh
          in: query
          required: false
          description: '`feed` mode: weight override for the neighbours-of-the-latest-like lane. Must be >= 0.'
          schema:
            type: number
            minimum: 0
        - name: bridge
          in: query
          required: false
          description: '`feed` mode: weight override for the between-interests "surprising" lane. Must be >= 0.'
          schema:
            type: number
            minimum: 0
        - name: wildcard
          in: query
          required: false
          description: '`feed` mode: weight override for the uniform-random lane. Must be >= 0.'
          schema:
            type: number
            minimum: 0
        - name: orientation
          in: query
          required: false
          description: >-
            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.
          schema:
            type: string
            enum:
              - portrait
              - landscape
        - name: offset
          in: query
          required: false
          description: Page offset.
          schema:
            type: integer
            default: 0
            minimum: 0
        - name: limit
          in: query
          required: false
          description: Page size. Values outside 1-50 use the default.
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 50
      responses:
        '200':
          description: One page of items.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SwipeResponse'
              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
        '400':
          description: '`anchor` missing for `similar`/`face`, or `q` empty for `text`. Plain-text message.'
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET (`Use GET`).
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            Search, feed or database failure, including no face found in the `face` anchor and the
            text model not being installed. Plain-text message.
          content:
            text/plain:
              schema:
                type: string
  /swipe/manifest.json:
    get:
      tags:
        - Swipe
      operationId: getSwipeManifest
      summary: PWA manifest for the Swipe app.
      description: >-
        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.
      x-access: public
      security: []
      responses:
        '200':
          description: The manifest.
          content:
            application/manifest+json:
              schema:
                $ref: '#/components/schemas/SwipeManifest'
              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
  /api/db/query:
    post:
      tags:
        - Database
      operationId: queryDatabase
      summary: Run one read-only SQL statement.
      description: >
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - sql
              properties:
                sql:
                  type: string
                  description: One SELECT or WITH statement.
                args:
                  type: array
                  items: {}
                  description: Positional values for `?` placeholders. JSON numbers bind as floating point.
                limit:
                  type: integer
                  default: 1000
                  maximum: 10000
                  description: Row cap. Zero or negative means the default; values above 10000 are clamped.
                timeout_ms:
                  type: integer
                  default: 5000
                  maximum: 30000
                  description: >-
                    Statement timeout in milliseconds. Zero or negative means the default; values
                    above 30000 are clamped.
            example:
              sql: SELECT path, size FROM media WHERE path LIKE ? ORDER BY size DESC
              args:
                - '%/2024/%'
              limit: 100
              timeout_ms: 5000
      responses:
        '200':
          description: Query result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DBQueryResult'
              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
        '400':
          description: >-
            Body 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.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than POST.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            No database connection available, read-only mode could not be set, or reading the result
            failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/db/load:
    post:
      tags:
        - Database
      operationId: loadDatabase
      summary: Web client boot handshake (no-op).
      description: >
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: Database path the client asked for. Ignored.
            example:
              path: D:\Library\dream.sqlite
      responses:
        '200':
          description: Acknowledged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EmptyObject'
        '400':
          description: Body missing or not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/fs/list:
    post:
      tags:
        - Storage and Transfer
      operationId: listDirectory
      summary: List a directory inside a storage root.
      description: >
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: Directory to list; empty for the roots.
            example:
              path: D:\Photos
      responses:
        '200':
          description: Directory listing.
          content:
            application/json:
              schema:
                type: object
                required:
                  - entries
                  - parent
                  - roots
                properties:
                  entries:
                    type: array
                    items:
                      $ref: '#/components/schemas/FSEntry'
                  parent:
                    type:
                      - string
                      - 'null'
                    description: Parent directory, or `null` at a root.
                  roots:
                    type: array
                    items:
                      type: string
                    description: 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: []
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: '`path` is not within any configured storage root.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Listing failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/fs/scan:
    post:
      tags:
        - Storage and Transfer
      operationId: scanDirectory
      summary: List the media files in a directory.
      description: >
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - path
              properties:
                path:
                  type: string
                  description: Directory to scan, or a file to scan the directory of.
                recursive:
                  type: boolean
                  description: Include subdirectories. Default `false`.
            example:
              path: D:\Photos\a.jpg
              recursive: false
      responses:
        '200':
          description: Media files.
          content:
            application/json:
              schema:
                type: object
                required:
                  - library
                  - cursor
                properties:
                  library:
                    type: array
                    items:
                      type: object
                      required:
                        - path
                        - mtimeMs
                        - size
                      properties:
                        path:
                          type: string
                        mtimeMs:
                          type: number
                          description: Modification time in milliseconds since the epoch.
                        size:
                          type: integer
                          description: Bytes.
                  cursor:
                    type: integer
                    description: 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
        '400':
          description: Body is not valid JSON.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: '`path` is empty or not within any configured storage root.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Scan failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/upload:
    post:
      tags:
        - Storage and Transfer
      operationId: uploadFiles
      summary: Upload files into a storage root.
      description: >
        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/`.
      x-access: admin
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - files
              properties:
                files:
                  type: array
                  items:
                    type: string
                    format: binary
                  description: One or more file parts.
                destination:
                  type: string
                  description: >-
                    Directory inside a configured storage root (local path or `s3://` prefix).
                    Default: `uploads/` under the default root. Windows only.
                autoIngest:
                  type: string
                  description: '`false` to skip the ingest job. Any other value, or omitted, queues it.'
      responses:
        '200':
          description: At least one file stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadResult'
              example:
                success: true
                files:
                  - D:\Library/uploads/a.jpg
                  - D:\Library/uploads/a_1.jpg
                message: Uploaded 2 file(s)
        '400':
          description: >-
            The multipart form could not be parsed (including over 10 GB), `destination` is outside
            every storage root, or no `files` parts were sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadResult'
              example:
                success: false
                files: null
                error: No files provided
        '405':
          description: Method other than POST.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: No storage root is configured, or no file could be stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UploadResult'
  /api/export:
    get:
      tags:
        - Storage and Transfer
      operationId: exportLibrary
      summary: Download a .lokiexport archive of the tagged library.
      description: >
        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.
      x-access: admin
      responses:
        '200':
          description: The archive.
          headers:
            Content-Disposition:
              schema:
                type: string
              description: '`attachment; filename="lowkey-export-<timestamp>.lokiexport"`'
          content:
            application/gzip:
              schema:
                type: string
                format: binary
        '400':
          description: No tagged media to export.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Selecting items or building the export database failed.
          content:
            text/plain:
              schema:
                type: string
  /api/import:
    post:
      tags:
        - Storage and Transfer
      operationId: importLibrary
      summary: Merge a .lokiexport archive into this library.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - file
              properties:
                file:
                  type: string
                  format: binary
                  description: The .lokiexport archive.
                destRoot:
                  type: string
                  description: >-
                    Storage root to import into, matched against each root's path or name. Omitted
                    or unmatched: the default root.
      responses:
        '200':
          description: Imported.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImportResult'
              example:
                imported: 1200
                skipped: 34
                files: 1198
                dest_root: s3://media/library
        '400':
          description: >-
            The 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.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: >-
            The merge failed, including an archive newer than this server supports or one without
            `library.db`.
          content:
            text/plain:
              schema:
                type: string
  /api/transform/prompt:
    post:
      tags:
        - Transform and Models
      operationId: previewTransformPrompt
      summary: Preview the prompt a retouch or reshoot job will run.
      description: |
        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).
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                input:
                  type: string
                  description: '`<task> [options...] <input>`, as for `POST /create`. Needs at least two tokens.'
                fields:
                  type: object
                  additionalProperties:
                    type: string
                  description: Extra options appended as `--<key> <value>`; empty keys or values are skipped.
              required:
                - input
            example:
              input: retouch --preset=restore "D:/photos/a.jpg"
              fields:
                prompt: Remove the scratches.
      responses:
        '200':
          description: The expanded prompt of the job's first run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PromptPreview'
              example:
                engine: loki-retouch
                prompt: Remove the scratches.
                modelInput: |-
                  <|im_start|>system
                  Comprehend and analyze the provided prompt.<|im_end|>
                  ...
                references:
                  - token: <image1>
                    path: D:\photos\a.jpg
                    kind: image
                runs: 1
                fromEngine: true
        '400':
          description: >-
            Body is not JSON (`bad json`) or `input` has fewer than two tokens (`Invalid input`).
            Plain text.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '422':
          description: >-
            The 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).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            The engine failed (for example `--show-prompt` exited with an error or a video frame
            could not be sampled).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/reshoot/models:
    get:
      tags:
        - Transform and Models
      operationId: listReshootModels
      summary: List the extra loki-reshoot diffusion models a job can pick.
      description: |
        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).
      x-access: admin
      responses:
        '200':
          description: The configured models (an empty array when none).
          content:
            application/json:
              schema:
                type: object
                properties:
                  models:
                    type: array
                    items:
                      $ref: '#/components/schemas/ReshootModelChoice'
                required:
                  - models
              example:
                models:
                  - file: 10eros_beta7.safetensors
                    label: 10Eros beta7
                    recipe: fast
        '405':
          description: Method other than GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/prompts/describe:
    get:
      tags:
        - Transform and Models
      operationId: getDescribePrompt
      summary: Get the configured description prompt.
      description: >-
        Returns `describePrompt` from the config, used by the web UI as the placeholder of the
        custom-prompt panel. Sent with `Cache-Control: no-store`.
      x-access: admin
      responses:
        '200':
          description: The active prompt.
          content:
            application/json:
              schema:
                type: object
                properties:
                  prompt:
                    type: string
                    description: The configured describe prompt (may be empty).
                required:
                  - prompt
              example:
                prompt: Describe this image in detail.
        '405':
          description: Method other than GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /ollama/models:
    get:
      tags:
        - Transform and Models
      operationId: listOllamaModels
      summary: List the models of the local Ollama install.
      description: |
        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.
      x-access: admin
      responses:
        '200':
          description: Model references such as `llama3:latest` (an empty array when none).
          content:
            application/json:
              schema:
                type: object
                properties:
                  models:
                    type: array
                    items:
                      type: string
                required:
                  - models
              example:
                models:
                  - llama3:latest
                  - qwen2.5vl:7b
        '405':
          description: Method other than GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: '`ollama ls` failed or is not installed (`failed to list ollama models`). Plain text.'
          content:
            text/plain:
              schema:
                type: string
  /api/embedding/directml/install:
    get:
      tags:
        - Transform and Models
      operationId: getDirectMLRuntimeStatus
      summary: Report whether the DirectML ONNX runtime is installed.
      description: Windows builds only; the route is not registered on Linux or macOS.
      x-access: admin
      responses:
        '200':
          description: Install state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DirectMLInstallResult'
              example:
                installed: false
    post:
      tags:
        - Transform and Models
      operationId: installDirectMLRuntime
      summary: Download and install the DirectML ONNX runtime.
      description: |
        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.
      x-access: admin
      responses:
        '200':
          description: The install state afterwards, plus `error` when the install failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DirectMLInstallResult'
              example:
                installed: true
        '405':
          description: Method other than GET or POST (`method not allowed`). Plain text.
          content:
            text/plain:
              schema:
                type: string
  /workers:
    get:
      tags:
        - GPU Engines
      operationId: redirectWorkersPage
      summary: Old Workers page; redirects to the Config page's GPU Engines tab.
      x-access: admin
      responses:
        '302':
          description: Redirect to `/config#engines`.
          headers:
            Location:
              schema:
                type: string
                example: /config#engines
  /api/engines:
    get:
      tags:
        - GPU Engines
      operationId: getEngines
      summary: Get the engines config (secrets redacted) and the dispatcher's live status.
      description: >-
        The status is the last known state; nothing is probed. Use `POST /api/engines/check` to
        probe now.
      x-access: admin
      parameters:
        - name: local
          in: query
          required: false
          description: >-
            `1` adds `local`, this machine's engine installs. Reading an engine's version runs it
            once (`--version`, cached per file).
          schema:
            type: string
            enum:
              - '1'
      responses:
        '200':
          description: Config and status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    $ref: '#/components/schemas/EnginesConfig'
                  status:
                    $ref: '#/components/schemas/EngineStatus'
                  local:
                    type: array
                    items:
                      $ref: '#/components/schemas/LocalEngine'
                    description: >-
                      Only with `?local=1`. One entry per engine in display order (`loki-retouch`,
                      `loki-reshoot`, `loki-dream` in dream builds, `loki-segment`).
                required:
                  - config
                  - status
              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:
      tags:
        - GPU Engines
      operationId: replaceEngines
      summary: Replace the engines section of the config and apply it.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EnginesConfig'
            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
      responses:
        '200':
          description: The saved config (secrets redacted) and the freshly probed status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  config:
                    $ref: '#/components/schemas/EnginesConfig'
                  status:
                    $ref: '#/components/schemas/EngineStatus'
                required:
                  - config
                  - status
        '400':
          description: >-
            Body is not valid JSON (`bad json: ...`). JSON `Error` body with a `text/plain` content
            type.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The config file could not be saved. JSON `Error` body with a `text/plain` content type.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/engines/check:
    post:
      tags:
        - GPU Engines
      operationId: checkEngines
      summary: Probe every worker now and return the status.
      description: Probes every pool target (up to 30 s in total). No request body.
      x-access: admin
      responses:
        '200':
          description: The dispatcher status after probing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EngineStatus'
  /api/engines/warm:
    post:
      tags:
        - GPU Engines
      operationId: warmEngines
      summary: Preload engines' models on the workers.
      description: |
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                pool:
                  type: string
                  description: Pool name (case-insensitive); empty for every pool.
                engines:
                  type: array
                  items:
                    type: string
                  description: Engines to warm, for example `loki-retouch`. Must not be empty.
                model:
                  type: string
                  description: Optional model to warm (passed to the worker).
              required:
                - engines
            example:
              pool: lab
              engines:
                - loki-retouch
                - loki-reshoot
      responses:
        '200':
          description: >-
            One note per target, `<key>: warming <engines>` or `<key>: <error>`. `null` when no
            target matched.
          content:
            application/json:
              schema:
                type: object
                properties:
                  notes:
                    type:
                      - array
                      - 'null'
                    items:
                      type: string
                required:
                  - notes
              example:
                notes:
                  - 'lab/gpu1: warming loki-retouch, loki-reshoot'
        '400':
          description: Bad JSON or empty `engines`. JSON `Error` body with a `text/plain` content type.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/engines/probe:
    post:
      tags:
        - GPU Engines
      operationId: probeWorker
      summary: Check a worker before adding it.
      description: |
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum:
                    - nodes
                    - runpod
                  description: '`runpod` probes an endpoint; anything else probes a node.'
                url:
                  type: string
                  description: Node base URL (nodes).
                token:
                  type: string
                  description: Node bearer token (nodes).
                endpointId:
                  type: string
                  description: RunPod endpoint id (runpod).
                apiKey:
                  type: string
                  description: RunPod API key (runpod).
            example:
              kind: nodes
              url: http://10.0.0.5:8080
              token: s3cret
      responses:
        '200':
          description: Reachable. `info` is a `WorkerInfo` for a node or a `RunPodHealth` for an endpoint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    const: true
                  info:
                    oneOf:
                      - $ref: '#/components/schemas/WorkerInfo'
                      - $ref: '#/components/schemas/RunPodHealth'
                required:
                  - ok
                  - info
        '400':
          description: Body is not JSON (`bad json`). JSON `Error` body with a `text/plain` content type.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: The worker or endpoint could not be reached or refused the credentials.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes:
    get:
      tags:
        - Scenes
      operationId: listScenes
      summary: List scenes, optionally only those started from one source.
      x-access: admin
      parameters:
        - name: source
          in: query
          required: false
          description: >-
            Source file path; compared case-insensitively after making it absolute. Omit for every
            scene.
          schema:
            type: string
      responses:
        '200':
          description: Scenes, most recently changed first (an empty array when none).
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SceneSummary'
        '405':
          description: Method other than GET or POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
    post:
      tags:
        - Scenes
      operationId: openOrCreateScene
      summary: Open the latest scene for a source, or start a new one.
      description: |
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                source:
                  type: string
                  description: Absolute path of an image or video.
                time:
                  type: number
                  description: 'Video only: seconds into the clip (negative becomes 0). Ignored for images.'
                fresh:
                  type: boolean
                  description: Always start a new scene.
              required:
                - source
            example:
              source: D:/photos/beach.jpg
              fresh: false
      responses:
        '200':
          description: The scene document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: >-
            Missing `source` or bad JSON (`needs a source path`), source not found, not an image or
            video, or ffmpeg failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
    get:
      tags:
        - Scenes
      operationId: getScene
      summary: Get a scene, with finished jobs folded in.
      description: >-
        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.
      x-access: admin
      responses:
        '200':
          description: The scene document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '404':
          description: Unknown or malformed scene id.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than GET, PUT or DELETE. Plain text.
          content:
            text/plain:
              schema:
                type: string
    put:
      tags:
        - Scenes
      operationId: updateScene
      summary: Save the editor's fields of a scene.
      description: |
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SceneUpdate'
            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
      responses:
        '200':
          description: The saved scene.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: Bad JSON (`bad json`) or the save failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Scenes
      operationId: deleteScene
      summary: Delete a scene and its files.
      description: >-
        Cancels every layer's generations and removes the scene folder. Exports beside the source
        are kept.
      x-access: admin
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    const: true
                required:
                  - ok
        '404':
          description: Unknown scene.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/render:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
    post:
      tags:
        - Scenes
      operationId: renderScene
      summary: Export the scene beside its source.
      description: |
        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.
      x-access: admin
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                format:
                  type: string
                  enum:
                    - video
                    - still
                  default: video
                  description: Anything other than `still` means `video`.
            example:
              format: video
      responses:
        '200':
          description: The scene, with `render` pending.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: An export is already pending or running, or the job could not be queued.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/scenes/{id}/segment:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
    post:
      tags:
        - Scenes
      operationId: segmentScene
      summary: Find objects in the scene picture (or a motion version's frame) with loki-segment.
      description: |
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SegmentRequest'
            example:
              points:
                - - 640
                  - 360
                  - 1
              multimask: true
      responses:
        '200':
          description: The objects found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SegmentResult'
        '400':
          description: >-
            Bad JSON, `loki-segment` unavailable (not installed, no reachable worker node), or the
            segmenter failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene, or `layer`/`version` is not a motion version of it.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/scene-dream:
    post:
      tags:
        - Scenes
      operationId: queueSceneDream
      summary: Generate a new picture to start a scene from (dream builds only).
      description: |
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SceneDreamRequest'
            example:
              prompt: a lighthouse on a cliff at dusk
              size: 1344x768
      responses:
        '200':
          description: The queued job.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SceneDreamStatus'
              example:
                jobId: 5f0c2f7e-1b2a-4c55-9d1e-0a6f3b9c2d11
                state: pending
                done: 0
                total: 0
        '400':
          description: Bad JSON, empty `prompt` (`describe the picture first`), or the job could not be queued.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/scene-dream/{job}:
    parameters:
      - name: job
        in: path
        required: true
        description: The `jobId` from `POST /api/scene-dream`.
        schema:
          type: string
    get:
      tags:
        - Scenes
      operationId: getSceneDreamStatus
      summary: Report a scene-dream job (dream builds only).
      description: >-
        The handler does not check the method; any method gets the same answer. Registered only in
        dream builds.
      x-access: admin
      responses:
        '200':
          description: >-
            The job's state; `output` once completed, `error` (its last meaningful log line) once
            errored or cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SceneDreamStatus'
        '404':
          description: No such job, or the job is not a `dream` job (message `scene not found`).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/generate:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
    post:
      tags:
        - Scenes
      operationId: generateSceneLayer
      summary: Queue the layer's next generation.
      description: |
        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).
      x-access: admin
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                mask:
                  $ref: '#/components/schemas/SceneGenMask'
      responses:
        '200':
          description: The scene, with the new generation as the layer's `job` or appended to `queued`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: >-
            Paint 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.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene or layer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The layer already has 8 generations in flight.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/pose:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
    post:
      tags:
        - Scenes
      operationId: detectSceneLayerPose
      summary: Detect the body pose of the character in a box.
      description: |
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                box:
                  $ref: '#/components/schemas/SceneRect'
              required:
                - box
            example:
              box:
                x: 400
                'y': 120
                w: 300
                h: 700
      responses:
        '200':
          description: The joints.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScenePose'
        '400':
          description: >-
            Missing or invalid body, box smaller than 8 px (`select the character first ...`), model
            not installed yet, or detection failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/cancel:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
    post:
      tags:
        - Scenes
      operationId: cancelSceneLayerJobs
      summary: Cancel one of the layer's generations, or all of them.
      description: |
        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.
      x-access: admin
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                job:
                  type: string
                  description: The generation's `SceneJob.id`; empty for all.
      responses:
        '200':
          description: The scene.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '404':
          description: Unknown scene.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
    delete:
      tags:
        - Scenes
      operationId: keepOnlySceneVersion
      summary: Drop every version of a layer but one.
      description: >-
        Removes the other versions' files (and motion maps); the kept version becomes version 0 and
        active.
      x-access: admin
      parameters:
        - name: keep
          in: query
          required: true
          description: Zero-based index of the version to keep.
          schema:
            type: integer
            minimum: 0
      responses:
        '200':
          description: The scene.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: >-
            `keep` missing or not an integer (`needs ?keep=<version index>`), or out of range (`no
            version N`).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene or layer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than DELETE. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/scenes/{id}/layers/{lid}/versions/{n}:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    delete:
      tags:
        - Scenes
      operationId: deleteSceneVersion
      summary: Drop one version of a layer.
      description: >-
        Removes its file unless another version shares it. If it was active, the last remaining
        version becomes active (-1 when none).
      x-access: admin
      responses:
        '200':
          description: The scene.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: '`n` is not an integer (`bad version`) or out of range (`no version N`).'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene or layer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than DELETE. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/scenes/{id}/layers/{lid}/versions/{n}/loop:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: loopSceneVersion
      summary: Add a seamless-loop copy of a motion version.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum:
                    - crossfade
                    - natural
                    - boomerang
              required:
                - kind
            example:
              kind: crossfade
      responses:
        '200':
          description: The scene with the new version.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: >-
            `n` not an integer, unknown or missing `kind` (`unknown loop kind ...`), or ffmpeg
            failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene, or `lid`/`n` is not a motion version.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/boomerang:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: boomerangSceneVersion
      summary: Add a boomerang loop of a motion version.
      description: Same as `loop` with `kind` = `boomerang`. No body.
      x-access: admin
      responses:
        '200':
          description: The scene with the new version.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: '`n` not an integer, or ffmpeg failed.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene, or `lid`/`n` is not a motion version.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/refinish:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: refinishSceneVersion
      summary: Redo a motion version's finish from the engine output.
      description: |
        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.
      x-access: admin
      responses:
        '200':
          description: The scene with the job queued on the layer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: >-
            `n` not an integer, not a motion version, or the engine output is no longer on disk
            (loops and older versions cannot be re-finished).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene or layer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The layer already has 8 generations in flight.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/heal:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: healSceneVersion
      summary: Redraw where a still version's stitched seam still shows.
      description: |
        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).
      x-access: admin
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                strength:
                  type: number
                  description: Inpaint strength; 0 or omitted uses the default (0.5).
      responses:
        '200':
          description: The scene with the job queued on the layer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: >-
            Not a dream build, not a still layer, version not shown, layer not stitched, stitch not
            ready, or nothing to heal.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene or layer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The layer already has 8 generations in flight.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/change:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: getSceneVersionChangeMap
      summary: Where a still version differs from the picture it was made from.
      description: >-
        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.
      x-access: admin
      responses:
        '200':
          description: The change map.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SceneRegionMap'
        '400':
          description: >-
            `n` not an integer, the source crop is no longer on disk, or the map could not be
            computed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene, or `lid`/`n` is not a still version.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/motion:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: getSceneVersionMotionMap
      summary: Where a motion version moves.
      description: Returns a PNG whose alpha is motion, covering the version's region. No body.
      x-access: admin
      responses:
        '200':
          description: The motion map.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SceneRegionMap'
        '400':
          description: '`n` not an integer, or the map could not be computed.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene, or `lid`/`n` is not a motion version.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/track:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: trackSceneVersion
      summary: Queue the roto (tracked matte) of a motion version.
      description: |
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                prompts:
                  type: array
                  description: >-
                    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.
                  items:
                    type: object
              required:
                - prompts
            example:
              prompts:
                - obj: 1
                  frame: 0
                  points:
                    - - 320
                      - 240
                      - 1
      responses:
        '200':
          description: The scene with the version's `track` pending.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: >-
            `n` not an integer, bad JSON, empty `prompts` (`pick what to track first`), or not a
            motion version.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene or layer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/matte:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: getSceneVersionMatteFrame
      summary: One frame of a version's roto matte.
      x-access: admin
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                frame:
                  type: integer
                  minimum: 0
                  default: 0
                  description: Clip frame index (negative becomes 0).
      responses:
        '200':
          description: The matte frame as a mask piece, in clip pixels.
          content:
            application/json:
              schema:
                type: object
                properties:
                  mask:
                    type: string
                    description: PNG data URL (white, alpha = kept) covering `rect`.
                  rect:
                    $ref: '#/components/schemas/SceneRect'
                  width:
                    type: integer
                    description: Clip width in pixels.
                  height:
                    type: integer
                    description: Clip height in pixels.
                required:
                  - mask
                  - rect
                  - width
                  - height
        '400':
          description: >-
            `n` not an integer, the version has no completed roto (`vN has no roto yet`), or the
            frame file is missing.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene, layer or version.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/scenes/{id}/layers/{lid}/versions/{n}/untrack:
    parameters:
      - name: id
        in: path
        required: true
        description: Scene id (a UUID; ids not matching `^[a-f0-9-]{8,64}$` answer 404).
        schema:
          type: string
      - name: lid
        in: path
        required: true
        description: Layer id (chosen by the editor).
        schema:
          type: string
      - name: 'n'
        in: path
        required: true
        description: Zero-based index into the layer's `versions`; a non-integer answers 400 (`bad version`).
        schema:
          type: integer
          minimum: 0
    post:
      tags:
        - Scenes
      operationId: untrackSceneVersion
      summary: Remove a version's roto.
      description: Cancels the track job if it runs and deletes the matte folder and preview video. No body.
      x-access: admin
      responses:
        '200':
          description: The scene.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Scene'
        '400':
          description: '`n` not an integer.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown scene, layer or version.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/deps/status:
    get:
      tags:
        - Dependencies
      operationId: getDepsStatus
      summary: State of every bundled binary, optional tool and downloadable model.
      description: |
        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"}`.
      x-access: public
      responses:
        '200':
          description: Every dependency.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/DepStatusItem'
              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'
  /api/deps/models/progress:
    get:
      tags:
        - Dependencies
      operationId: streamModelProgress
      summary: Server-Sent Events stream of model install progress.
      description: |
        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.
      x-access: public
      responses:
        '200':
          description: The event stream; stays open until the client disconnects.
          content:
            text/event-stream:
              schema:
                type: string
                description: >-
                  `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"}
        '500':
          description: The response writer cannot stream. Plain-text `streaming unsupported`.
          content:
            text/plain:
              schema:
                type: string
  /api/deps/models/{id}/download:
    post:
      tags:
        - Dependencies
      operationId: downloadModel
      summary: Start downloading a model or tool.
      description: |
        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.
      x-access: public
      parameters:
        - $ref: '#/components/parameters/DepModelId'
      responses:
        '200':
          description: |
            Already installed. The body is the tracked install object when
            one exists in this server run, otherwise `{"state":"installed"}`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ModelInstall'
                  - type: object
                    properties:
                      state:
                        type: string
                        enum:
                          - installed
                    required:
                      - state
              example:
                state: installed
        '202':
          description: Install started (or already in progress); the current progress object.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelInstall'
              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'
        '401':
          $ref: '#/components/responses/DepsUnauthorized'
        '404':
          description: Unknown model id (`unknown model id "<id>"`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/deps/models/{id}/cancel:
    post:
      tags:
        - Dependencies
      operationId: cancelModelDownload
      summary: Cancel an in-flight model install.
      description: |
        Cancels the install's context; the install then moves to
        `cancelled`, which is reported on the progress stream. No request
        body.
      x-access: public
      parameters:
        - $ref: '#/components/parameters/DepModelId'
      responses:
        '202':
          description: Cancellation requested. Empty body.
        '401':
          $ref: '#/components/responses/DepsUnauthorized'
        '404':
          description: No active install for this id (`no active install for "<id>"`), including unknown ids.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/deps/models/{id}/verify:
    post:
      tags:
        - Dependencies
      operationId: verifyModel
      summary: Check the files of an installed model or tool.
      description: |
        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.
      x-access: public
      parameters:
        - $ref: '#/components/parameters/DepModelId'
      responses:
        '200':
          description: Per-file results.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelVerifyResult'
              example:
                id: wd-eva02-large-tagger-v3
                files:
                  model.onnx: ok
                  selected_tags.csv: ok
                  config.json: missing
        '401':
          $ref: '#/components/responses/DepsUnauthorized'
        '404':
          description: Unknown model id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/deps/models/{id}:
    delete:
      tags:
        - Dependencies
      operationId: deleteModel
      summary: Delete a downloaded model or tool.
      description: |
        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.
      x-access: public
      parameters:
        - $ref: '#/components/parameters/DepModelId'
      responses:
        '204':
          description: Deleted (or nothing was there). Empty body.
        '401':
          $ref: '#/components/responses/DepsUnauthorized'
        '404':
          description: Unknown model id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: The folder could not be removed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/settings:
    get:
      tags:
        - Settings and Session
      operationId: getSettings
      summary: The whole settings object.
      x-access: read
      responses:
        '200':
          description: Settings (empty object after a restart).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
    put:
      tags:
        - Settings and Session
      operationId: putSettings
      summary: Set one setting or merge several.
      description: >
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              anyOf:
                - type: object
                  description: Single setting.
                  required:
                    - key
                  properties:
                    key:
                      type: string
                    value:
                      description: Any JSON value.
                - type: object
                  description: Batch of settings to merge.
                  additionalProperties: true
              description: >-
                A body with a string `key` sets that one setting to `value`; any other object is
                merged key by key.
            examples:
              single:
                value:
                  key: theme
                  value: dark
              batch:
                value:
                  theme: dark
                  volume: 0.8
      responses:
        '200':
          description: Saved. Body is `{}`.
          content:
            application/json:
              schema:
                type: object
        '400':
          description: Body is not a JSON object. Body `{"error":"bad request"}` sent as `text/plain`.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Allow Public Access is on and the request has no valid credential.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/session:
    get:
      tags:
        - Settings and Session
      operationId: getSession
      summary: The whole session object.
      x-access: read
      responses:
        '200':
          description: Session (empty object after a restart or database switch).
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
    put:
      tags:
        - Settings and Session
      operationId: mergeSession
      summary: Merge an object into the session.
      description: >-
        Requires a credential even when Allow Public Access is on. Methods other than GET, PUT and
        DELETE answer `200` with an empty body.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
            example:
              query: tag:sunset
              cursor: 12
      responses:
        '200':
          description: Merged. Body is `{}`.
          content:
            application/json:
              schema:
                type: object
        '400':
          description: Body is not a JSON object. Body `{"error":"bad request"}` sent as `text/plain`.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Allow Public Access is on and the request has no valid credential.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      tags:
        - Settings and Session
      operationId: clearSession
      summary: Clear the whole session.
      description: Requires a credential even when Allow Public Access is on.
      x-access: read
      responses:
        '200':
          description: Cleared. Body is `{}`.
          content:
            application/json:
              schema:
                type: object
        '401':
          description: Allow Public Access is on and the request has no valid credential.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/session/{key}:
    parameters:
      - name: key
        in: path
        required: true
        description: >-
          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.
        schema:
          type: string
    get:
      tags:
        - Settings and Session
      operationId: getSessionValue
      summary: One session value.
      x-access: read
      responses:
        '200':
          description: >-
            The stored JSON value, `null` when the key is not set, or the whole session object when
            the key is empty.
          content:
            application/json:
              schema: {}
    put:
      tags:
        - Settings and Session
      operationId: putSessionValue
      summary: Set one session value to the request body.
      description: >-
        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.
      x-access: read
      requestBody:
        required: true
        content:
          application/json:
            schema: {}
            example: tag:sunset
      responses:
        '200':
          description: Saved. Body is `{}`.
          content:
            application/json:
              schema:
                type: object
        '400':
          description: Body is not valid JSON. Body `{"error":"bad request"}` sent as `text/plain`.
          content:
            text/plain:
              schema:
                type: string
        '401':
          description: Allow Public Access is on and the request has no valid credential.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/session/keys:
    post:
      tags:
        - Settings and Session
      operationId: deleteSessionKeys
      summary: Delete the named keys from the session.
      description: >-
        The handler does not check the method; any method with this body behaves the same. POST is
        what clients send.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - keys
              properties:
                keys:
                  type: array
                  items:
                    type: string
            example:
              keys:
                - library
                - previous
      responses:
        '200':
          description: Deleted. Body is `{}`.
          content:
            application/json:
              schema:
                type: object
        '400':
          description: Body is not valid JSON. Body `{"error":"bad request"}` sent as `text/plain`.
          content:
            text/plain:
              schema:
                type: string
  /api/scheduler:
    get:
      tags:
        - Auto Scheduler
      operationId: getSchedulerStatus
      summary: Current scheduler status.
      description: The last status computed by the scheduler loop, which ticks every 15 seconds.
      x-access: admin
      responses:
        '200':
          description: Status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SchedulerStatus'
        '405':
          description: Not GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '503':
          description: The scheduler has not been started. Plain text `scheduler not running`.
          content:
            text/plain:
              schema:
                type: string
  /api/scheduler/mode:
    post:
      tags:
        - Auto Scheduler
      operationId: setSchedulerMode
      summary: Turn the scheduler on (`auto`) or off for this server session.
      description: >-
        Applies immediately. Turning it off pauses a running scheduled job. Either direction cancels
        a forced run. The mode is not persisted across restarts.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - mode
              properties:
                mode:
                  type: string
                  enum:
                    - 'off'
                    - auto
            example:
              mode: auto
      responses:
        '200':
          description: Status after the change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SchedulerStatus'
        '400':
          description: Invalid JSON, or `mode` is not `off` or `auto`. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Not POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '503':
          description: The scheduler has not been started. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/scheduler/run:
    post:
      tags:
        - Auto Scheduler
      operationId: forceSchedulerRun
      summary: Start or resume the scheduled job now, ignoring idle signals until it completes.
      description: Also clears a manual hold (a scheduled job the user paused by hand). No request body.
      x-access: admin
      responses:
        '200':
          description: Status after the forced tick.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SchedulerStatus'
        '405':
          description: Not POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '503':
          description: The scheduler has not been started. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /api/onboarding/state:
    get:
      tags:
        - Onboarding
      operationId: getOnboardingState
      summary: Whether the first-run tour has been dismissed.
      description: 'A missing or unreadable `onboarding.json` reads as `{"shown": false}`.'
      x-access: public
      responses:
        '200':
          description: The tour state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OnboardingState'
              example:
                shown: true
                dismissed_at: '2026-10-31T10:00:00Z'
  /api/onboarding/dismiss:
    post:
      tags:
        - Onboarding
      operationId: dismissOnboarding
      summary: Record that the tour was dismissed (now).
      description: 'Writes `{"shown": true, "dismissed_at": <now, UTC>}`. No request body.'
      x-access: public
      responses:
        '204':
          description: Saved. Empty body.
        '401':
          $ref: '#/components/responses/DepsUnauthorized'
        '500':
          description: The state file could not be written.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/onboarding/reset:
    post:
      tags:
        - Onboarding
      operationId: resetOnboarding
      summary: Re-arm the tour.
      description: >-
        Deletes the state file so the tour shows again. Never fails (a missing file is fine). No
        request body.
      x-access: public
      responses:
        '204':
          description: Reset. Empty body.
        '401':
          $ref: '#/components/responses/DepsUnauthorized'
  /setup:
    get:
      tags:
        - Setup Wizard
      operationId: getSetupPage
      summary: The first-run setup wizard page.
      description: |
        Server-rendered HTML. Open while setup is incomplete; afterwards it
        needs a valid credential and unauthenticated requests are redirected
        to `/login`.
      x-access: public
      responses:
        '200':
          description: The wizard page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Setup is complete and the request carries no valid credential; redirects to `/login`.
          headers:
            Location:
              schema:
                type: string
                example: /login
        '500':
          description: Template rendering failed. Plain-text message.
          content:
            text/plain:
              schema:
                type: string
  /setup/api/state:
    get:
      tags:
        - Setup Wizard
      operationId: getSetupState
      summary: Everything the wizard needs to render its steps.
      x-access: public
      responses:
        '200':
          description: Wizard state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SetupState'
              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
        '403':
          $ref: '#/components/responses/SetupForbidden'
  /setup/api/browse:
    post:
      tags:
        - Setup Wizard
      operationId: setupBrowse
      summary: List folders on the server host for the wizard's folder picker.
      description: |
        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.
      x-access: public
      requestBody:
        required: true
        description: A JSON body is required (send `{}` for the starting locations).
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: >-
                    Absolute folder path to list; empty or omitted for the starting locations.
                    Trimmed and cleaned.
            example:
              path: D:\
      responses:
        '200':
          description: The folder listing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SetupBrowseResult'
              example:
                path: D:\
                parent: ''
                entries:
                  - name: Media
                    path: D:\Media
                  - name: Photos
                    path: D:\Photos
                exists: true
                writable: true
        '400':
          description: >-
            Body is not valid JSON (`bad request`), the path is not absolute, or it names a file
            rather than a folder.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/SetupForbidden'
  /setup/api/mkdir:
    post:
      tags:
        - Setup Wizard
      operationId: setupMkdir
      summary: Create a folder (and any missing parents) on the server host.
      x-access: public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: Absolute path of the folder to create. Trimmed.
              required:
                - path
            example:
              path: D:\Media\Library
      responses:
        '200':
          description: >-
            Created (or already present); the listing of the new folder, same shape as `POST
            /setup/api/browse`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SetupBrowseResult'
        '400':
          description: >-
            Body is not valid JSON, `path` is empty or not absolute, or the folder could not be
            created (`could not create folder: ...`).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/SetupForbidden'
  /setup/api/database:
    post:
      tags:
        - Setup Wizard
      operationId: setupDatabase
      summary: Open (or create) the SQLite library database and make it the active one.
      description: |
        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.
      x-access: public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: >-
                    Absolute path of the database file, or of a folder to put `media.db` in. Trimmed
                    and cleaned.
              required:
                - path
            example:
              path: D:\Lowkey\
      responses:
        '200':
          description: The database is open and saved in the config.
          content:
            application/json:
              schema:
                type: object
                properties:
                  path:
                    type: string
                    description: The database file actually opened (with `media.db` appended for a folder).
                  existed:
                    type: boolean
                    description: Whether the file was already there before this call.
                  hasRealUsers:
                    type: boolean
                    description: Whether the database has an account other than the bootstrap default admin.
                required:
                  - path
                  - existed
                  - hasRealUsers
              example:
                path: D:\Lowkey\media.db
                existed: false
                hasRealUsers: false
        '400':
          description: Body is not valid JSON, or `path` is empty or not absolute.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/SetupForbidden'
        '500':
          description: >-
            The database could not be opened (`could not open database: ...`), or it opened but the
            config could not be saved.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /setup/api/storage/test-s3:
    post:
      tags:
        - Setup Wizard
      operationId: setupTestS3
      summary: Test an S3-compatible bucket connection without saving anything.
      description: Builds an S3 client from the body and pings the bucket, with a 10 second timeout.
      x-access: public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                endpoint:
                  type: string
                  description: S3 endpoint URL; empty for AWS.
                region:
                  type: string
                bucket:
                  type: string
                  description: Bucket name (required, non-blank).
                prefix:
                  type: string
                  description: Key prefix inside the bucket.
                accessKey:
                  type: string
                secretKey:
                  type: string
              required:
                - bucket
            example:
              endpoint: http://localhost:9000
              region: us-east-1
              bucket: media
              accessKey: minio
              secretKey: minio123
      responses:
        '200':
          description: The bucket is reachable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    enum:
                      - true
                required:
                  - ok
              example:
                ok: true
        '400':
          description: >-
            Body is not valid JSON, `bucket is required`, or `connection failed: ...` (client
            creation or ping failed).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/SetupForbidden'
  /setup/api/storage:
    post:
      tags:
        - Setup Wizard
      operationId: setupStorage
      summary: Replace the configured storage roots.
      description: |
        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.
      x-access: public
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                roots:
                  type: array
                  description: At least one root.
                  minItems: 1
                  items:
                    $ref: '#/components/schemas/SetupStorageRoot'
              required:
                - roots
            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: •••
      responses:
        '200':
          description: Saved and applied.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  roots:
                    type: integer
                    description: Number of roots saved.
                required:
                  - status
                  - roots
              example:
                status: ok
                roots: 2
        '400':
          description: |
            Body 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 `; `).
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/SetupForbidden'
        '500':
          description: The config could not be saved.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /setup/api/complete:
    post:
      tags:
        - Setup Wizard
      operationId: setupComplete
      summary: Mark first-run setup complete.
      description: |
        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.
      x-access: public
      responses:
        '200':
          description: Setup is complete.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                required:
                  - status
              example:
                status: ok
        '403':
          $ref: '#/components/responses/SetupForbidden'
        '500':
          description: The config could not be saved.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /viz/embeddings:
    get:
      tags:
        - Visualizations
      operationId: embeddingsVizPage
      summary: 3D embedding point cloud page.
      description: >-
        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.
      x-access: admin
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/embeddings/projection:
    get:
      tags:
        - Visualizations
      operationId: getEmbeddingProjection
      summary: PCA projection of a model's embeddings to 3D.
      description: >
        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.
      x-access: admin
      parameters:
        - name: model
          in: query
          required: false
          schema:
            type: string
          description: Model id. Default the active embedding model.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 20000
            default: 4000
          description: Maximum points. Values above 20000 are clamped.
      responses:
        '200':
          description: The projection.
          content:
            application/json:
              schema:
                type: object
                required:
                  - model
                  - dim
                  - total
                  - count
                  - variance
                  - paths
                  - points
                properties:
                  model:
                    type: string
                  dim:
                    type: integer
                    description: Vector dimension (0 when there are no rows).
                  total:
                    type: integer
                    description: Rows considered before sampling.
                  count:
                    type: integer
                    description: Points returned.
                  variance:
                    type: array
                    items:
                      type: number
                    minItems: 3
                    maxItems: 3
                    description: Fraction of total variance explained by each of the three axes.
                  paths:
                    type: array
                    items:
                      type: string
                  points:
                    type: array
                    items:
                      type: array
                      items:
                        type: number
                      minItems: 3
                      maxItems: 3
                    description: '`[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
        '400':
          description: '`limit` is not a positive integer.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Loading embeddings failed.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /viz/treemap:
    get:
      tags:
        - Visualizations
      operationId: treemapVizPage
      summary: Library treemap page.
      description: >-
        A squarified treemap of the library by folder, sized by any metric from `GET
        /api/treemap/metrics`, with drill-down through `GET /api/treemap`.
      x-access: admin
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/treemap:
    get:
      tags:
        - Visualizations
      operationId: getTreemap
      summary: Per-folder row counts for one metric.
      description: >
        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.
      x-access: admin
      parameters:
        - name: metric
          in: query
          required: false
          schema:
            type: string
            default: items
          description: '`items`, `tags`, `embed:<model>` or `face:<model>`.'
        - name: path
          in: query
          required: false
          schema:
            type: string
          description: Folder to root the response at, exactly as a node's `path` (default the library root).
        - name: depth
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 8
            default: 3
          description: Levels of children to include. Values above 8 are clamped.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 2000
            default: 100
          description: >-
            Children per node, largest first; the rest collapse into `otherCount`/`otherDirs`.
            Values above 2000 are clamped.
      responses:
        '200':
          description: The subtree.
          content:
            application/json:
              schema:
                type: object
                required:
                  - metric
                  - path
                  - total
                  - node
                properties:
                  metric:
                    type: string
                  path:
                    type: string
                  total:
                    type: integer
                    description: Rows in the whole library for this metric.
                  totalItems:
                    type: integer
                    description: Items in the whole library. Absent for the `items` metric.
                  node:
                    $ref: '#/components/schemas/TreemapNode'
              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
        '400':
          description: Unknown `metric`, or `depth`/`limit` not a positive integer.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: '`path` is not a folder in the tree.'
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /api/treemap/metrics:
    get:
      tags:
        - Visualizations
      operationId: listTreemapMetrics
      summary: Metrics a treemap can be drawn by, with library-wide counts.
      description: >-
        Always `items` and `tags`, then one `embed:<model>` per model with stored embeddings and one
        `face:<model>` per model with stored faces.
      x-access: admin
      responses:
        '200':
          description: Metrics.
          content:
            application/json:
              schema:
                type: object
                required:
                  - metrics
                properties:
                  metrics:
                    type: array
                    items:
                      type: object
                      required:
                        - id
                        - label
                        - count
                      properties:
                        id:
                          type: string
                          description: Value for `GET /api/treemap?metric=`.
                        label:
                          type: string
                          description: Display label.
                        count:
                          type: integer
                          description: 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
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Database error.
          content:
            text/plain:
              schema:
                $ref: '#/components/schemas/Error'
  /media/api:
    get:
      tags:
        - Legacy Media Browser
      operationId: listLegacyMediaItems
      summary: Paged items for the legacy gallery, or one item by path.
      description: >
        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.
      x-access: admin
      parameters:
        - name: offset
          in: query
          required: false
          description: Items to skip. Invalid values mean `0`.
          schema:
            type: integer
            default: 0
        - name: limit
          in: query
          required: false
          description: Page size. Values outside 1 to 100 use the default.
          schema:
            type: integer
            default: 25
            minimum: 1
            maximum: 100
        - name: q
          in: query
          required: false
          description: Search expression.
          schema:
            type: string
          example: tag:sunset AND width>1000
        - name: path
          in: query
          required: false
          description: >-
            Media path for a single-item lookup (read from the raw query string and path-unescaped).
            Ignored unless `single=true`.
          schema:
            type: string
        - name: single
          in: query
          required: false
          description: '`true` together with `path` looks up one item.'
          schema:
            type: string
            enum:
              - 'true'
      responses:
        '200':
          description: A page of items.
          content:
            application/json:
              schema:
                type: object
                required:
                  - items
                  - has_more
                  - total_count
                properties:
                  items:
                    type:
                      - array
                      - 'null'
                    items:
                      $ref: '#/components/schemas/LegacyMediaItem'
                  has_more:
                    type: boolean
                    description: More items exist after this page.
                  total_count:
                    type: integer
                    description: 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
        '400':
          description: '`path` is badly encoded.'
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: The method is not `GET`.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The query failed.
          content:
            text/plain:
              schema:
                type: string
  /media/suggest:
    get:
      tags:
        - Legacy Media Browser
      operationId: suggestLegacyMediaTerms
      summary: Typeahead suggestions for the gallery search box.
      x-access: read
      parameters:
        - name: kind
          in: query
          required: true
          description: |
            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.
          schema:
            type: string
            enum:
              - filters
              - tag
              - category
              - path
              - pathdir
        - name: prefix
          in: query
          required: false
          description: Text typed so far.
          schema:
            type: string
        - name: limit
          in: query
          required: false
          description: Maximum suggestions. Values outside 1 to 200 use the default.
          schema:
            type: integer
            default: 25
            minimum: 1
            maximum: 200
      responses:
        '200':
          description: Suggestions. `kind=tag` adds `tags`.
          content:
            application/json:
              schema:
                type: object
                required:
                  - suggestions
                properties:
                  suggestions:
                    type:
                      - array
                      - 'null'
                    items:
                      type: string
                    description: >-
                      Suggested strings. For `kind=tag`, the distinct labels of `tags` (`null` when
                      there are none).
                  tags:
                    type:
                      - array
                      - 'null'
                    description: Only for `kind=tag`.
                    items:
                      $ref: '#/components/schemas/LegacyMediaTag'
              example:
                tags:
                  - label: sunset
                    category: Scene
                suggestions:
                  - sunset
        '400':
          description: Missing or unknown `kind`.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: The method is not `GET`.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The query failed.
          content:
            text/plain:
              schema:
                type: string
  /media/tag:
    post:
      tags:
        - Legacy Media Browser
      operationId: updateLegacyMediaTag
      summary: Add or remove one tag on one item.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - media_path
                - tag_label
                - category_label
                - action
              properties:
                media_path:
                  type: string
                  description: Media path.
                tag_label:
                  type: string
                  description: Tag label.
                category_label:
                  type: string
                  description: The tag's category.
                action:
                  type: string
                  enum:
                    - add
                    - remove
            example:
              media_path: D:/photos/x.jpg
              tag_label: sunset
              category_label: Scene
              action: add
      responses:
        '200':
          description: Done.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                properties:
                  status:
                    type: string
                    enum:
                      - ok
        '400':
          description: Invalid JSON, a required field empty, or `action` not `add`/`remove`.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: The method is not `POST`.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The update failed.
          content:
            text/plain:
              schema:
                type: string
  /media/has-tag:
    get:
      tags:
        - Legacy Media Browser
      operationId: legacyMediaHasTag
      summary: Whether an item carries a tag.
      x-access: read
      parameters:
        - name: media_path
          in: query
          required: true
          schema:
            type: string
          description: Media path.
        - name: tag_label
          in: query
          required: true
          schema:
            type: string
          description: Tag label.
        - name: category_label
          in: query
          required: true
          schema:
            type: string
          description: The tag's category.
      responses:
        '200':
          description: The answer.
          content:
            application/json:
              schema:
                type: object
                required:
                  - has_tag
                properties:
                  has_tag:
                    type: boolean
              example:
                has_tag: true
        '400':
          description: A parameter is missing.
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: The method is not `GET`.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The query failed.
          content:
            text/plain:
              schema:
                type: string
  /config:
    get:
      tags:
        - HTML Pages
      operationId: configPage
      summary: Configuration editor page.
      description: Renders the Config page from the config file. S3 credentials are redacted in the page.
      x-access: admin
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session. Redirects to `/login` (or `/setup`).
        '500':
          description: Config could not be loaded or the template failed. Plain text.
          content:
            text/plain:
              schema:
                type: string
    post:
      tags:
        - System
      operationId: updateConfig
      summary: Merge settings into the configuration, save it and apply it.
      description: >
        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.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ConfigUpdateRequest'
            example:
              dbPath: C:\ProgramData\Lowkey Media Server\media.db
              embeddingModel: siglip2-base-patch16-224
              allowPublicAccess: false
      responses:
        '200':
          description: Saved and applied.
          content:
            application/json:
              schema:
                type: object
                required:
                  - status
                  - configPath
                  - activeDBPath
                  - changed
                  - dbChanged
                  - logoutRequired
                  - storageWarnings
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  configPath:
                    type: string
                    description: Path of the config file written.
                  activeDBPath:
                    type: string
                  changed:
                    type: boolean
                    description: Whether any configuration value differs from before.
                  dbChanged:
                    type: boolean
                  logoutRequired:
                    type: boolean
                    description: Equal to `dbChanged`; the session cookie was cleared.
                  storageWarnings:
                    type: array
                    items:
                      type: string
                    description: >-
                      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: []
        '400':
          description: '`bad json`, or `dbPath cannot be empty`. Plain text.'
          content:
            text/plain:
              schema:
                type: string
        '405':
          description: Method other than GET or POST. Plain text.
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: Config file could not be saved, or the database switch failed. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /:
    get:
      tags:
        - HTML Pages
      operationId: homePage
      summary: Home dashboard (library stats, metadata coverage, ingestion, job queue).
      description: >-
        `/` is the catch-all pattern: any path no other route matches also renders this page with
        `200`. Every method except POST renders it.
      x-access: admin
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session. Redirects to `/login` (or `/setup`).
    post:
      tags:
        - System
      operationId: legacyLaunchJob
      summary: Legacy single-task job launch.
      description: >
        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`.
      x-access: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - Arguments
              properties:
                Command:
                  type: string
                  description: Task id.
                Arguments:
                  type: array
                  minItems: 1
                  items:
                    type: string
                  description: Option tokens followed by the input as the final element.
            example:
              Command: metadata
              Arguments:
                - '--type=hash'
                - D:\Media\clip.mp4
      responses:
        '201':
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                required:
                  - id
                properties:
                  id:
                    type: string
                    description: Id of the created job (empty string if none was created).
        '400':
          description: '`bad json`, or `Arguments` empty. Plain text.'
          content:
            text/plain:
              schema:
                type: string
        '500':
          description: The workflow could not be queued. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /jobs:
    get:
      tags:
        - HTML Pages
      operationId: jobsPage
      summary: Job list page.
      x-access: admin
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session. Redirects to `/login` (or `/setup`).
        '405':
          description: Not GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /media:
    get:
      tags:
        - HTML Pages
      operationId: mediaPage
      summary: Legacy media gallery page.
      description: >-
        Renders the first 25 matching items; the page then pages through `/media/api`. The handler
        does not check the method.
      x-access: admin
      parameters:
        - name: q
          in: query
          required: false
          description: Search query.
          schema:
            type: string
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session. Redirects to `/login` (or `/setup`).
        '500':
          description: Items could not be fetched. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /swipe:
    get:
      tags:
        - HTML Pages
      operationId: swipePage
      summary: The Swipe app.
      description: >-
        Anonymous visitors reach it only while Allow Public Access is on, and then get a view-only
        page (tagging hidden).
      x-access: read
      parameters:
        - name: q
          in: query
          required: false
          description: Initial search query.
          schema:
            type: string
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session (public access off). Redirects to `/login` (or `/setup`).
        '405':
          description: Not GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /editor:
    get:
      tags:
        - HTML Pages
      operationId: editorPage
      summary: Job composer page.
      x-access: admin
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session. Redirects to `/login` (or `/setup`).
        '405':
          description: Not GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /events:
    get:
      tags:
        - HTML Pages
      operationId: eventsPage
      summary: Event log page.
      description: Windows builds only; not registered on macOS or Linux.
      x-access: admin
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session. Redirects to `/login` (or `/setup`).
        '405':
          description: Not GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /login:
    get:
      tags:
        - HTML Pages
      operationId: loginPage
      summary: Sign-in page.
      description: >-
        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.
      x-access: public
      security: []
      parameters:
        - name: redirect
          in: query
          required: false
          schema:
            type: string
        - name: setup
          in: query
          required: false
          schema:
            type: string
            enum:
              - 'true'
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: First-run setup not complete. Redirects to `/setup`.
        '405':
          description: Not GET. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /app/{path}:
    get:
      tags:
        - HTML Pages
      operationId: webViewerApp
      summary: The embedded web viewer (single-page application).
      description: >
        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`.
      x-access: read
      parameters:
        - name: path
          in: path
          required: true
          description: File in the bundle, or a client route. May be empty and may contain slashes.
          schema:
            type: string
      responses:
        '200':
          description: The bundle file (content type from its extension) or `index.html`.
          content:
            text/html:
              schema:
                type: string
            application/javascript:
              schema:
                type: string
            '*/*':
              schema:
                type: string
                format: binary
        '302':
          description: >-
            Browser without a session while public access is off. Redirects to `/login` (or
            `/setup`).
        '304':
          description: Not modified (`If-None-Match` matched the `ETag`).
        '500':
          description: '`index.html` missing from the embedded bundle. Plain text.'
          content:
            text/plain:
              schema:
                type: string
  /static/{path}:
    get:
      tags:
        - HTML Pages
      operationId: staticFile
      summary: Embedded static assets used by the server-rendered pages.
      description: >-
        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.
      x-access: public
      security: []
      parameters:
        - name: path
          in: path
          required: true
          description: File path under the static root.
          schema:
            type: string
      responses:
        '200':
          description: The file, with a content type from its extension.
          content:
            '*/*':
              schema:
                type: string
                format: binary
        '404':
          description: No such file. Plain text.
          content:
            text/plain:
              schema:
                type: string
  /viz/{path}:
    get:
      tags:
        - HTML Pages
      operationId: vizIndexPage
      summary: Index of the visualization pages.
      description: >-
        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.
      x-access: admin
      parameters:
        - name: path
          in: path
          required: true
          description: Usually empty.
          schema:
            type: string
      responses:
        '200':
          description: HTML page.
          content:
            text/html:
              schema:
                type: string
        '302':
          description: Browser without a session. Redirects to `/login` (or `/setup`).
        '405':
          description: Not GET. Body `{"error":"use GET"}` sent as `text/plain`.
          content:
            text/plain:
              schema:
                type: string
  /stats:
    get:
      tags:
        - HTML Pages
      operationId: statsPage
      summary: Metadata coverage page (macOS and Linux builds only).
      description: >-
        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).
      x-access: admin
      responses:
        '200':
          description: The page.
          content:
            text/html:
              schema:
                type: string
        '405':
          description: Method other than GET.
          content:
            text/plain:
              schema:
                type: string
tags:
  - name: Authentication
    description: >
      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.
  - name: System
    description: >
      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.
  - name: Library Statistics
    description: >
      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.
  - name: Jobs
    description: >
      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.
  - name: Workflows
    description: >
      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.
  - name: Real-Time Events
    description: >
      `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.
  - name: Media Library
    description: >
      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.
  - name: File Serving
    description: >
      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`.
  - name: Taxonomy
    description: >
      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.
  - name: Tag Groups
    description: >
      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`.
  - name: Embeddings Index
    description: >
      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`.
  - name: People and Faces
    description: >-
      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`.
  - name: Duplicate Candidates
    description: >-
      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`.
  - name: Swipe
    description: >-
      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.
  - name: Database
    description: >
      Direct access to the active library database: read-only SQL for trusted clients such as
      `lokictl`, and the web client's boot handshake.
  - name: Storage and Transfer
    description: >
      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.
  - name: Transform and Models
    description: |
      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.
  - name: GPU Engines
    description: >
      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.
  - name: Scenes
    description: >
      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.
  - name: Dependencies
    description: |
      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`.
  - name: Settings and Session
    description: >
      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.
  - name: Auto Scheduler
    description: >
      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.
  - name: Onboarding
    description: |
      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).
  - name: Setup Wizard
    description: |
      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`.
  - name: Visualizations
    description: >
      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.
  - name: Legacy Media Browser
    description: >
      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.
  - name: HTML Pages
    description: >
      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.
