openapi: 3.1.0

info:
  title: Freaking Fast File Drive API
  version: '2026.09.28'
  summary: Large-file upload, storage, and sharing.
  description: |
    Files are uploaded directly to object storage using a presigned POST policy, then recorded
    against the caller's account. Three calls per upload:

      1. `POST /generate-file-urls` — returns `uploadUrl` + `uploadForm`
      2. `POST` the file as multipart/form-data to `uploadUrl`, including every `uploadForm` field
      3. `POST /insert-file` — records the file and returns its id

    Step 3 re-reads the uploaded object's `Content-Length` and rejects the insert if it disagrees
    with the submitted `fileSize`, so the size must be exact.

    Large files can go up in parts instead of step 2, so a dropped connection costs one part rather
    than the whole file, and an interrupted upload can be resumed:

      1. `POST /create-multipart-upload` with the same body as `/generate-file-urls` — returns a
         `token`, the part plan, and any parts already stored under that key
      2. `POST /sign-upload-parts` for the parts still to send, then `PUT` each part's bytes to its
         `url`. Each URL accepts exactly its part's length, and nothing else
      3. `POST /complete-multipart-upload`, then `POST /insert-file` as above

    To resume, call `/create-multipart-upload` again with the same `objectKey`: it answers with the
    parts storage already has.

    Polymorphic bodies use `type` as the discriminator (built_value `StandardJsonPlugin`).

servers:
  - url: https://{host}
    variables:
      host:
        default: fffs.freakingfast.io

security:
  - bearerAuth: []

tags:
  - name: upload
  - name: download
  - name: library
  - name: trash
  - name: metadata
  - name: search
  - name: organize
  - name: requests
  - name: images
  - name: reviews

paths:
  /generate-file-urls:
    post:
      tags: [upload]
      operationId: generateFileUrls
      summary: Get a presigned upload form for a new file.
      description: |
        Returns a short-lived S3 POST policy. The policy expires in 2 days and pins the exact
        content length, Content-Type, Content-Disposition, and Cache-Control — send the returned
        `uploadForm` fields verbatim or the upload is rejected by storage.
      parameters:
        - $ref: '#/components/parameters/AbsurdApp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileInput' }
      responses:
        '200':
          description: Presigned upload form.
          content:
            application/json:
              schema:
                type: object
                required: [uploadUrl, uploadForm]
                properties:
                  uploadUrl:
                    type: string
                    format: uri
                    description: POST the multipart body here.
                  uploadForm:
                    type: object
                    additionalProperties: { type: string }
                    description: Form fields to include before the file part.
                  downloadUrl:
                    type: [string, 'null']
                    format: uri
                    description: Static URL for public-bucket files; null for private files.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /insert-file:
    post:
      tags: [upload]
      operationId: insertFile
      summary: Record an uploaded file against the account.
      description: |
        Call only after the storage POST succeeds. `fileSize` must equal the stored object's real
        size. Audio, video, and image files get metadata extracted server-side; audio additionally
        kicks off asynchronous soundprint generation.
      parameters:
        - $ref: '#/components/parameters/AbsurdApp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileInsert' }
      responses:
        '200':
          description: The new file's id. Read the file back with `select-user-files` or `search-files`.
          content:
            application/json:
              schema:
                type: object
                required: [id]
                properties:
                  id: { type: string, format: uuid }
        '400':
          description: Size mismatch, storage limit reached, or missing fields.
        '403':
          $ref: '#/components/responses/Forbidden'

  /create-multipart-upload:
    post:
      tags: [upload]
      operationId: createMultipartUpload
      summary: Open a multipart upload, or resume the one open under this key.
      description: |
        Takes the same body as `/generate-file-urls` and passes the same checks, quota included.
        Calling it again with the same `objectKey` resumes: `parts` lists what storage already
        holds. An open upload whose parts do not fit this file's size is discarded and replaced.
        Parts are 64 MiB, larger only when a file would need more than 10,000.
      parameters:
        - $ref: '#/components/parameters/AbsurdApp'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileInput' }
      responses:
        '200':
          description: The open upload.
          content:
            application/json:
              schema:
                type: object
                required: [token, uploadId, resumed, partSize, partCount, parts]
                properties:
                  token:
                    type: string
                    description: Passed to the other multipart calls. Valid for 7 days; open the upload again for a new one.
                  uploadId: { type: string }
                  resumed:
                    type: boolean
                    description: True when this picked up an upload already open under the key.
                  partSize:
                    type: integer
                    format: int64
                    description: Every part is this size except the last, which carries the remainder.
                  partCount: { type: integer }
                  parts:
                    type: array
                    description: Parts storage already holds, by number.
                    items: { $ref: '#/components/schemas/StoredPart' }
                  signedParts:
                    type: array
                    description: |
                      URLs for the first 100 parts storage does not hold yet, so a file of up to 100
                      parts never calls `/sign-upload-parts`.
                    items: { $ref: '#/components/schemas/SignedPart' }
                  expiresAt:
                    type: string
                    format: date-time
                    description: When the `signedParts` URLs stop working, a day after issue.
                  downloadUrl:
                    type: [string, 'null']
                    format: uri
                    description: Static URL for public-bucket files; null for private files.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /sign-upload-parts:
    post:
      tags: [upload]
      operationId: signUploadParts
      summary: Get upload URLs for parts of an open upload.
      description: |
        Up to 1,000 parts per call. `PUT` each part's bytes to its `url` within a day. The URL is
        signed for the part's exact length, so storage refuses a body one byte longer or shorter.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, partNumbers]
              properties:
                token: { type: string }
                partNumbers:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { type: integer, minimum: 1 }
      responses:
        '200':
          description: One signed URL per part.
          content:
            application/json:
              schema:
                type: object
                required: [parts, expiresAt]
                properties:
                  parts:
                    type: array
                    items: { $ref: '#/components/schemas/SignedPart' }
                  expiresAt: { type: string, format: date-time }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'

  /complete-multipart-upload:
    post:
      tags: [upload]
      operationId: completeMultipartUpload
      summary: Join the parts into the finished object.
      description: |
        Reads the parts from storage, so no ETags need collecting. Every part must be there at its
        planned size. Safe to repeat: a completion whose response was lost reports success again.
        Call `/insert-file` afterwards to record the file.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UploadToken' }
      responses:
        '200':
          description: The object is complete.
          content:
            application/json:
              schema:
                type: object
                required: [bucket, objectKey, fileSize]
                properties:
                  bucket: { type: string }
                  objectKey: { type: string }
                  fileSize: { type: integer, format: int64 }
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The upload is no longer open and no object was made (`upload_not_found`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }
        '409':
          description: Parts are missing (`parts_missing`) or do not match the plan (`parts_invalid`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }

  /abort-multipart-upload:
    post:
      tags: [upload]
      operationId: abortMultipartUpload
      summary: Discard an open upload and its parts.
      description: |
        For starting over rather than resuming. Aborting an upload that is already gone succeeds.
        An upload that gets no new part for 7 days is aborted automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/UploadToken' }
      responses:
        '200':
          description: The upload is gone.
          content:
            application/json:
              schema:
                type: object
                properties:
                  aborted: { type: boolean }
        '403':
          $ref: '#/components/responses/Forbidden'

  /generate-download-url:
    post:
      tags: [download]
      operationId: generateDownloadUrl
      summary: Get a time-limited download URL for a file.
      description: Presigned GET valid for 6 hours. Public-bucket files may be fetched without a token.
      security:
        - bearerAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: string, format: uuid }
      responses:
        '200':
          description: File record with `downloadUrl` populated.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FileRecord' }
        '400':
          description: Missing id, or private file requested without a token.
        '403':
          description: File not found for this caller.

  /read-file:
    post:
      tags: [download]
      operationId: readFile
      summary: Read what's in a file, for a model.
      description: |
        Under the caller's own token, so it reaches what a download would. An image, HEIC and camera RAW
        included, comes back as JPEG no larger than 1568 pixels on a side; a video as the frame at `at`
        seconds, or one a tenth of the way in; a PDF as the text of up to 20 pages, as many whole pages as
        fit in 100 KB; a text file as up to 100 KB from `offset`, ending on a whole character. Anything
        else, audio included, comes back with a note and nothing to read.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileId]
              properties:
                fileId: { type: string, format: uuid }
                at: { type: number, minimum: 0, description: Seconds into a video. }
                fromPage: { type: integer, minimum: 1, description: First PDF page. 1 by default. }
                toPage: { type: integer, minimum: 1, description: Last PDF page, at most 20 on. Ten pages by default. }
                offset: { type: integer, minimum: 0, description: Where to go on from in a text file. }
      responses:
        '200':
          description: What the file holds, and a note on what was read.
          content:
            application/json:
              schema:
                type: object
                required: [id, fileName, type, mimeType, fileSize, note]
                properties:
                  id: { type: string, format: uuid }
                  fileName: { type: string }
                  type: { type: string }
                  mimeType: { type: string }
                  fileSize: { type: integer }
                  image:
                    type: object
                    properties:
                      data: { type: string, description: Base64. }
                      mimeType: { type: string }
                  text: { type: string }
                  at: { type: number }
                  duration: { type: number }
                  pages:
                    type: object
                    properties:
                      from: { type: integer }
                      to: { type: integer }
                      total: { type: integer }
                  nextOffset: { type: integer }
                  note: { type: string }
        '400':
          description: >-
            A field it can't use; `at` past the end of a video whose length is known; `fromPage` past the
            end of the PDF, where a `toPage` past it is read to the end instead; or `offset` past the end
            of the text.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }
        '404':
          description: No file the caller can open has that id.
        '422':
          description: The file couldn't be opened, or is too big to read here (`unreadable`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }
        '502':
          description: Storage didn't hand the file over (`fetching_file_failed`). Worth trying again.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiError' }

  /select-user-files:
    post:
      tags: [library]
      operationId: selectUserFiles
      summary: List every file the caller created.
      description: |
        Newest first. Excludes trashed files and extracted derivatives (album art, video
        thumbnails). Takes no body.
      responses:
        '200':
          description: Files owned by the caller.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/FileRecord' }

  /get-user-storage-usage:
    post:
      tags: [library]
      operationId: getUserStorageUsage
      summary: Byte and object counts broken down by bucket and media kind.
      responses:
        '200':
          description: Usage totals.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StorageUsage' }

  /trash-files:
    post:
      tags: [trash]
      operationId: trashFiles
      summary: Move files to trash (recoverable).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              maxItems: 100
              items: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: Ids of the files that were trashed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  trashed:
                    type: array
                    items: { type: string, format: uuid }

  /restore-files:
    post:
      tags: [trash]
      operationId: restoreFiles
      summary: Restore files from trash.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              items: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: Ids of the files that were restored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  restored:
                    type: array
                    items: { type: string, format: uuid }

  /select-trashed-files:
    post:
      tags: [trash]
      operationId: selectTrashedFiles
      summary: List trashed files.
      responses:
        '200':
          description: Trashed files.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/FileRecord' }

  /purge-trashed-files:
    post:
      tags: [trash]
      operationId: purgeTrashedFiles
      summary: Permanently delete everything in trash.
      responses:
        '200': { description: Trash purged. }

  /delete-files:
    post:
      tags: [trash]
      operationId: deleteFiles
      summary: Permanently delete files and their stored objects.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              maxItems: 100
              items: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: Ids of the files that were deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: array
                    items: { type: string, format: uuid }

  /toggle-file-access:
    post:
      tags: [metadata]
      operationId: toggleFileAccess
      summary: Flip one file between public and private.
      description: |
        Moves the stored object to the other bucket. A file uploaded under `users/` is always
        inserted private, so this is how it becomes public.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FileRef' }
      responses:
        '200':
          description: The file as it now stands. `downloadUrl` is its public link when it is public.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FileRecord' }

  /update-file-description:
    post:
      tags: [metadata]
      operationId: updateFileDescription
      summary: Set a file's description.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileId, description]
              properties:
                fileId: { type: string, format: uuid }
                description:
                  type: string
                  description: An empty string clears it.
      responses:
        '200': { description: Description updated. }

  /update-file-tags:
    post:
      tags: [metadata]
      operationId: updateFileTags
      summary: Add a tag to files, or remove it from them.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileIds, tagId, action]
              properties:
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { type: string, format: uuid }
                tagId: { type: string, format: uuid }
                action: { type: string, enum: [add, remove] }
      responses:
        '200': { description: Tags updated. }

  /create-archive-request:
    post:
      tags: [library]
      operationId: createArchiveRequest
      summary: Request a ZIP archive of files, a gallery, or a playlist.
      description: Send one of `fileIds`, `galleryId` or `playlistId`. The archive is built in the background.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fileIds:
                  type: array
                  items: { type: string, format: uuid }
                galleryId: { type: string, format: uuid }
                playlistId: { type: string, format: uuid }
                title: { type: string, default: Archive }
      responses:
        '200': { description: Archive request accepted. }

  /search-files:
    post:
      tags: [search]
      operationId: searchFiles
      summary: Find the caller's files by what they are.
      description: |
        Every field is optional and every one given narrows the results. Excludes trashed files and
        extracted derivatives. Results come a page at a time: pass `nextOffset` back as `offset`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                text:
                  type: string
                  description: |
                    Words that must all appear in the file's name, description or tags, or a track's
                    title, artists, album or genre, or a video's or photo's title.
                types:
                  type: array
                  items: { $ref: '#/components/schemas/FileType' }
                tags:
                  type: array
                  items: { type: string }
                  description: Tag names, case-insensitive. Files must carry every one of them.
                folder:
                  type: string
                  description: A folder path like `/Clients/Harbor`. Includes the folders inside it.
                isPublic: { type: boolean }
                uploadedAfter: { type: string, format: date-time }
                uploadedBefore: { type: string, format: date-time }
                takenAfter:
                  type: string
                  format: date-time
                  description: When a photo was taken or a video recorded.
                takenBefore: { type: string, format: date-time }
                minDuration: { type: number, minimum: 0, description: Seconds, for audio and video. }
                maxDuration: { type: number, minimum: 0 }
                minSize: { type: integer, format: int64, minimum: 0, description: Bytes. }
                maxSize: { type: integer, format: int64, minimum: 0 }
                minTempo:
                  type: number
                  minimum: 0
                  description: Beats per minute, as measured, or as the track's own tag says when it wasn't.
                maxTempo: { type: number, minimum: 0 }
                key:
                  type: string
                  description: A musical key like `Am`, `F#` or `Bb minor`. Without a mode, either matches.
                within:
                  type: object
                  description: Where a photo was taken. A `west` past `east` crosses the antimeridian.
                  required: [south, west, north, east]
                  properties:
                    south: { type: number, minimum: -90, maximum: 90 }
                    west: { type: number, minimum: -180, maximum: 180 }
                    north: { type: number, minimum: -90, maximum: 90 }
                    east: { type: number, minimum: -180, maximum: 180 }
                sort:
                  type: string
                  enum: [newest, oldest, largest, smallest, name]
                  default: newest
                limit: { type: integer, minimum: 1, maximum: 200, default: 50 }
                offset: { type: integer, minimum: 0, default: 0 }
      responses:
        '200':
          description: One page of matching files.
          content:
            application/json:
              schema:
                type: object
                required: [files, nextOffset]
                properties:
                  files:
                    type: array
                    items: { $ref: '#/components/schemas/SearchResult' }
                  nextOffset: { type: [integer, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }

  /select-file-views:
    post:
      tags: [library]
      operationId: selectFileViews
      summary: How often other people opened the caller's files, and who of those they were shared with.
      description: |
        Views are opens by anyone but the owner. People a file was shared with are named unless they
        turned that off; everyone else is counted and never named. Without `fileIds`, answers with the
        files opened most since `since`.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { type: string, format: uuid }
                since:
                  type: string
                  format: date
                  description: 30 days ago by default.
                limit: { type: integer, minimum: 1, maximum: 100, default: 25 }
                folder:
                  type: string
                  description: |
                    A folder path like `/Clients/Harbor`. Only files in it and the folders inside it, counted before
                    `limit`.
      responses:
        '200':
          description: Views per file.
          content:
            application/json:
              schema:
                type: object
                required: [since, files]
                properties:
                  since: { type: string, format: date }
                  files:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        fileName: { type: string }
                        isPublic: { type: boolean }
                        views: { type: integer, description: Opens since `since`. }
                        allViews: { type: integer }
                        lastViewTime: { type: [string, 'null'], format: date-time }
                        viewers:
                          type: array
                          description: Named viewers, the most recent first.
                          items:
                            type: object
                            properties:
                              handle: { type: [string, 'null'] }
                              displayName: { type: [string, 'null'] }
                              lastDay: { type: string, format: date }
                              views: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }

  /select-drive-events:
    post:
      tags: [library]
      operationId: selectDriveEvents
      summary: What happened in the caller's drive after a point, oldest first.
      description: |
        Files sent to the caller's file requests (one event when a sender finishes), notes and replies
        from reviewers, other people opening the caller's files (once a day each), files joining the
        drive other than through a request or a generation, and image generations finishing (failed,
        canceled or aborted, or succeeded with the image saved). Pass `nextCursor` back as
        `since` to go on from where the last call left off.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                since:
                  type: string
                  description: A `nextCursor` from before, or an ISO 8601 time. 24 hours ago by default.
                kinds:
                  type: array
                  minItems: 1
                  items: { type: string, enum: [drop, note, view, file, generation] }
                folder: { type: string, description: What happened in this folder and the folders inside it. }
                requestId: { type: string, format: uuid, description: Only drops to this request. }
                reviewId: { type: string, format: uuid, description: Only notes on this review. }
                fileId: { type: string, format: uuid, description: Only views of this file. }
                generationId: { type: string, format: uuid, description: Only this generation finishing. }
                limit: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: What happened, in time order.
          content:
            application/json:
              schema:
                type: object
                required: [events, nextCursor, more]
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      required: [kind, id, time]
                      description: >-
                        Every event has `kind`, `id` and `time`. A drop adds `sender`, `note`, `request` and
                        `files`; a note adds `reviewer`, `text`, `seconds`, `replyTo`, `review` and `file`; a view
                        adds `viewer` (named only when the file was shared with them and they let it be known,
                        null otherwise) and `file`; a file adds `file`; a
                        generation adds `status`, `error`, `prompt` and `image`.
                      properties:
                        kind: { type: string, enum: [drop, note, view, file, generation] }
                        id: { type: string, format: uuid }
                        time: { type: string, format: date-time }
                  nextCursor: { type: string }
                  more: { type: boolean, description: Whether there is more after this page. }
        '400': { $ref: '#/components/responses/BadRequest' }

  /tag-files:
    post:
      tags: [organize]
      operationId: tagFiles
      summary: Tag files by name, making any tag the caller doesn't have yet.
      description: Tags are case-insensitive and kept in the case they were first written.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileIds, tags]
              properties:
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { type: string, format: uuid }
                tags:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items: { type: string }
                action: { type: string, enum: [add, remove], default: add }
      responses:
        '200':
          description: The caller's files that were updated, and the tags as stored.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: array
                    items: { type: string, format: uuid }
                  tags:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        text: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: None of the files are the caller's. }

  /move-files:
    post:
      tags: [organize]
      operationId: moveFiles
      summary: Put files in a folder, by its path or its id.
      description: |
        Send the files as `fileIds` or `fromFolderId`, and the folder as `folder` or `folderId`. A folder only says
        where a file sits. Moving one never changes who can see it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { type: string, format: uuid }
                fromFolderId:
                  type: string
                  format: uuid
                  description: Every file in this folder of the caller's, trashed ones included.
                folder:
                  type: string
                  description: |
                    A path like `/Clients/Harbor`, at most 32 deep, making the folders on the way. `/` is the top
                    of the drive.
                folderId:
                  type: [string, 'null']
                  format: uuid
                  description: One of the caller's folders, or null for the top of the drive.
      responses:
        '200':
          description: The files moved and the folder they're in.
          content:
            application/json:
              schema:
                type: object
                properties:
                  moved:
                    type: array
                    items: { type: string, format: uuid }
                  folder:
                    type: [object, 'null']
                    properties:
                      id: { type: string, format: uuid }
                      path: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: None of the files, or not the folder, are the caller's. }

  /select-collections:
    post:
      tags: [organize]
      operationId: selectCollections
      summary: The caller's folders, playlists, galleries and tags.
      responses:
        '200':
          description: Everything the caller has to put files in.
          content:
            application/json:
              schema:
                type: object
                properties:
                  folders:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        path: { type: string }
                        fileCount: { type: integer }
                  playlists:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        title: { type: string }
                        isPublic: { type: boolean }
                        fileCount: { type: integer }
                  galleries:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        title: { type: string }
                        description: { type: [string, 'null'] }
                        isPublic: { type: boolean }
                        fileCount: { type: integer }
                        featuredCount: { type: integer }
                  tags:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        text: { type: string }
                        fileCount: { type: integer }

  /create-playlist:
    post:
      tags: [organize]
      operationId: createPlaylist
      summary: A new playlist of audio and video, in the order given.
      description: |
        Any audio or video the caller can see goes in while the playlist is private; only their own once it's public,
        since a public playlist's page plays everything in it, private files included. Anything else is skipped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                fileIds:
                  type: array
                  maxItems: 500
                  items: { type: string, format: uuid }
                isPublic: { type: boolean, default: false }
      responses:
        '200':
          description: The playlist.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CollectionResult' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /add-to-playlist:
    post:
      tags: [organize]
      operationId: addToPlaylist
      summary: Add audio and video to the end of one of the caller's playlists.
      description: |
        Files already in it stay where they are. What can go in is as for `create-playlist`. The whole playlist is
        renumbered in one statement, so two adds at once can't drop each other's files.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [playlistId, fileIds]
              properties:
                playlistId: { type: string, format: uuid }
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: The playlist as it now stands.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CollectionResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No playlist of the caller's has that id. }

  /create-gallery:
    post:
      tags: [organize]
      operationId: createGallery
      summary: A new gallery of images, in the order given.
      description: |
        Any image the caller can see goes in while the gallery is private; only their own once it's public, since a
        public gallery's page shows everything in it, private images included. Anything else is skipped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string }
                description: { type: string }
                fileIds:
                  type: array
                  maxItems: 500
                  items: { type: string, format: uuid }
                featuredIds:
                  type: array
                  description: Which of `fileIds` go up front.
                  items: { type: string, format: uuid }
                isPublic: { type: boolean, default: false }
      responses:
        '200':
          description: The gallery.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CollectionResult' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /add-to-gallery:
    post:
      tags: [organize]
      operationId: addToGallery
      summary: Add images to the end of one of the caller's galleries.
      description: |
        Images already in it stay where they are, and so do the ones it features. What can go in is as for
        `create-gallery`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [galleryId, fileIds]
              properties:
                galleryId: { type: string, format: uuid }
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items: { type: string, format: uuid }
                featuredIds:
                  type: array
                  description: Which of the new images go up front.
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: The gallery as it now stands.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CollectionResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No gallery of the caller's has that id. }

  /reorder-playlist:
    post:
      tags: [organize]
      operationId: reorderPlaylist
      summary: Put one of the caller's playlists in the order given.
      description: |
        The files named come first, in that order; anything else it holds, such as a file in the trash, follows in
        the order it had. Ids it doesn't hold are ignored.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [playlistId, fileIds]
              properties:
                playlistId: { type: string, format: uuid }
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items: { type: string, format: uuid }
      responses:
        '200': { description: 'The playlist: `id`, `title`, `isPublic`, `fileCount`.' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No playlist of the caller's has that id. }

  /reorder-gallery:
    post:
      tags: [organize]
      operationId: reorderGallery
      summary: Put one of the caller's galleries in the order given, and set which images go up front.
      description: |
        As `reorder-playlist`. With `featuredIds`, those of the named images go up front and the rest of the named
        don't; without it, what's featured stays as it is. Images not named keep their own.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [galleryId, fileIds]
              properties:
                galleryId: { type: string, format: uuid }
                fileIds:
                  type: array
                  minItems: 1
                  maxItems: 10000
                  items: { type: string, format: uuid }
                featuredIds:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        '200': { description: 'The gallery: `id`, `title`, `isPublic`, `fileCount`.' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No gallery of the caller's has that id. }

  /create-file-request:
    post:
      tags: [requests]
      operationId: createFileRequest
      summary: A link anyone can send files to without an account.
      description: |
        The link is `https://freakingfast.io/requests/{id}`. What arrives lands in the folder given, made if it isn't
        there, or else in a folder at the top of the drive named for the request.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title: { type: string, minLength: 1, maxLength: 200 }
                message: { type: string, maxLength: 2000, description: Shown to whoever opens the link. }
                folder: { type: string, description: A path like `/Clients/Harbor`. }
                folderId: { type: string, format: uuid, description: One of the caller's folders, instead of `folder`. }
                expireTime: { type: string, format: date-time, description: When it stops taking files. }
                maxBytes: { type: integer, format: int64, minimum: 1, description: The most it takes across every sender. }
      responses:
        '200':
          description: The request.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  title: { type: string }
                  message: { type: [string, 'null'] }
                  expireTime: { type: [string, 'null'], format: date-time }
                  maxBytes: { type: [integer, 'null'], format: int64 }
                  folder:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      path: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: The folder isn't the caller's. }

  /select-file-requests:
    post:
      tags: [requests]
      operationId: selectFileRequests
      summary: The caller's file requests and what arrived on each.
      description: Newest first, at most 50. A sender who sent nothing isn't listed.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                requestIds:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: string, format: uuid }
                folder:
                  type: string
                  description: |
                    A folder path like `/Clients/Harbor`. Only requests that drop into it or a folder inside it, counted
                    before the 50, and of what arrived, the files still there; `receivedBytes` counts only those.
      responses:
        '200':
          description: The requests.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requests:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        title: { type: string }
                        message: { type: [string, 'null'] }
                        insertTime: { type: string, format: date-time }
                        open: { type: boolean, description: Not closed and not past its expireTime. }
                        closeTime: { type: [string, 'null'], format: date-time }
                        expireTime: { type: [string, 'null'], format: date-time }
                        maxBytes: { type: [integer, 'null'], format: int64 }
                        receivedBytes: { type: integer, format: int64 }
                        folder:
                          type: object
                          properties:
                            id: { type: string, format: uuid }
                            path: { type: string }
                        drops:
                          type: array
                          items:
                            type: object
                            properties:
                              id: { type: string, format: uuid }
                              senderName: { type: string }
                              note: { type: [string, 'null'] }
                              arrivedTime: { type: string, format: date-time }
                              finishTime: { type: [string, 'null'], format: date-time }
                              viewed: { type: boolean, description: Whether the owner has looked. }
                              files:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    id: { type: string, format: uuid }
                                    type: { $ref: '#/components/schemas/FileType' }
                                    fileName: { type: string }
                                    fileSize: { type: integer, format: int64 }
                                    mimeType: { type: string }

  /set-file-request-open:
    post:
      tags: [requests]
      operationId: setFileRequestOpen
      summary: Close a file request to new files, or open it again.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requestId, open]
              properties:
                requestId: { type: string, format: uuid }
                open: { type: boolean }
      responses:
        '200':
          description: Whether it takes files now. One opened again past its expireTime still doesn't.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  open: { type: boolean }
                  closeTime: { type: [string, 'null'], format: date-time }
                  expireTime: { type: [string, 'null'], format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No file request of the caller's has that id. }

  /generate-image:
    post:
      tags: [images]
      operationId: generateImage
      summary: Start an image from a prompt, on the caller's plan.
      description: |
        The plan decides the monthly allowance and the Pro-only choices (`nanoBananaPro`, and `gptImage2` at medium or
        high quality). The image is saved to the caller's drive, private; poll `select-generations` for it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prompt]
              properties:
                prompt: { type: string, minLength: 1, maxLength: 4000 }
                model:
                  type: string
                  enum: [gptImage2, nanoBanana, imagen4Fast, seedream4, flux2Pro, fluxKreaDev, nanoBananaPro]
                  default: gptImage2
                aspectRatio:
                  type: string
                  enum: ['1:1', '3:2', '2:3', '16:9', '9:16', '4:3', '3:4']
                  default: '1:1'
                  description: '`gptImage2` takes 1:1, 3:2 and 2:3.'
                quality:
                  type: string
                  enum: [low, medium, high]
                  description: '`gptImage2` only. Low on Basic and medium on Pro by default.'
      responses:
        '200':
          description: The generation, started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  model: { type: string }
                  aspectRatio: { type: string }
                  quality: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { description: 'Refused by the plan: no subscription, no images left, storage full, or a Pro-only choice.' }
        '502': { description: The image generator is unavailable. Retryable. }

  /select-generations:
    post:
      tags: [images]
      operationId: selectGenerations
      summary: How the caller's generations are going, and the images they made.
      description: |
        `done` once the image is saved. An image since put in the trash is still `done`, with `trashed: true` and no
        `downloadUrl` until it's restored. One that succeeded but has no image 15 minutes on, never saved or deleted
        for good, is `failed`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ids]
              properties:
                ids:
                  type: array
                  minItems: 1
                  maxItems: 20
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: Each generation.
          content:
            application/json:
              schema:
                type: object
                properties:
                  generations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        status: { type: string, enum: [working, done, failed] }
                        trashed: { type: boolean, description: Only when the image is in the trash. }
                        error: { type: [string, 'null'] }
                        model: { type: [string, 'null'] }
                        prompt: { type: [string, 'null'] }
                        image: { oneOf: [{ $ref: '#/components/schemas/FileRecord' }, { type: 'null' }] }

  /set-cover:
    post:
      tags: [metadata]
      operationId: setCover
      summary: Put one of the caller's images on a track as its album art, or on a playlist as its art.
      description: |
        A track gets its own copy, as public as the track, and the art it had is removed. The image can be up to 5 MB
        (`image_too_large`), as extracted art is: it isn't counted against storage. A playlist's art is replaced by
        the image itself. For a video's poster, use `set-video-thumbnail` with `imageId`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [imageId]
              properties:
                imageId: { type: string, format: uuid }
                trackId: { type: string, format: uuid }
                playlistId: { type: string, format: uuid }
      responses:
        '200':
          description: The track's new art, or the playlist.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      trackId: { type: string, format: uuid }
                      art:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          downloadUrl: { type: [string, 'null'], format: uri, description: Set when the track is public. }
                  - type: object
                    properties:
                      playlistId: { type: string, format: uuid }
                      title: { type: string }
                      isPublic: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: The image, track or playlist isn't the caller's. }

  /set-video-thumbnail:
    post:
      tags: [metadata]
      operationId: setVideoThumbnail
      summary: Set a video's poster, from a frame of it or from an image in the drive.
      description: Send `timestampSeconds` to capture that frame, or `imageId` to use one of the caller's images.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [videoId]
              properties:
                videoId: { type: string, format: uuid }
                timestampSeconds: { type: number, minimum: 0 }
                imageId: { type: string, format: uuid }
      responses:
        '200':
          description: The poster is set.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403': { description: '`not_owner`: the video, or the image, isn''t the caller''s.' }
        '404': { description: No such video or image. }

  /create-file-review:
    post:
      tags: [reviews]
      operationId: createFileReview
      summary: A link anyone can play one of the caller's videos or tracks at and leave notes on.
      description: The link is `https://freakingfast.io/reviews/{id}`. Reviewers need no account.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fileId]
              properties:
                fileId: { type: string, format: uuid, description: A video or audio file of the caller's. }
                message: { type: string, maxLength: 2000, description: Shown above the player. }
                expireTime: { type: string, format: date-time }
                allowDownload: { type: boolean, default: false }
      responses:
        '200':
          description: The review.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  message: { type: [string, 'null'] }
                  expireTime: { type: [string, 'null'], format: date-time }
                  allowDownload: { type: boolean }
                  file:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      type: { $ref: '#/components/schemas/FileType' }
                      fileName: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No video or track of the caller's has that id. }

  /select-file-reviews:
    post:
      tags: [reviews]
      operationId: selectFileReviews
      summary: The caller's reviews, each with its notes as an edit list.
      description: |
        Notes that aren't replies come by where they sit in the file, a note on the whole file first, each with
        `seconds`, a `timecode` like `1:05`, who left it, whether it's resolved, and its replies.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reviewIds:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: string, format: uuid }
                fileId: { type: string, format: uuid }
                folder:
                  type: string
                  description: A folder path like `/Clients/Harbor`. Only reviews of files in it, counted before the 50.
      responses:
        '200':
          description: The reviews, newest first, at most 50.
          content:
            application/json:
              schema:
                type: object
                properties:
                  reviews:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        file:
                          type: [object, 'null']
                          description: Null while the file is in the trash.
                          properties:
                            id: { type: string, format: uuid }
                            type: { $ref: '#/components/schemas/FileType' }
                            fileName: { type: string }
                        message: { type: [string, 'null'] }
                        insertTime: { type: string, format: date-time }
                        open: { type: boolean }
                        expireTime: { type: [string, 'null'], format: date-time }
                        allowDownload: { type: boolean }
                        reviewers: { type: array, items: { type: string } }
                        openCount: { type: integer, description: Reviewers' notes not yet resolved. }
                        notes:
                          type: array
                          items:
                            allOf:
                              - $ref: '#/components/schemas/ReviewNote'
                              - type: object
                                properties:
                                  seconds: { type: [number, 'null'], description: Null for a note on the whole file. }
                                  timecode: { type: [string, 'null'], description: 'Like `1:05` or `1:02:03`.' }
                                  resolved: { type: boolean }
                                  replies: { type: array, items: { $ref: '#/components/schemas/ReviewNote' } }

  /reply-to-review-note:
    post:
      tags: [reviews]
      operationId: replyToReviewNote
      summary: The owner's answer to a note, under it.
      description: An answer to a reply answers the note at the top. Reviewers who left an address hear about it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [noteId, text]
              properties:
                noteId: { type: string, format: uuid }
                text: { type: string, minLength: 1, maxLength: 4000 }
      responses:
        '200':
          description: The reply.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  text: { type: string }
                  parentId: { type: string, format: uuid, description: The note it sits under. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No note on a review of the caller's has that id. }

  /set-review-note-resolved:
    post:
      tags: [reviews]
      operationId: setReviewNoteResolved
      summary: Mark a note dealt with, or not.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [noteId, resolved]
              properties:
                noteId: { type: string, format: uuid }
                resolved: { type: boolean }
      responses:
        '200':
          description: The note's resolve time, null when open.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  resolveTime: { type: [string, 'null'], format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { description: No note on a review of the caller's has that id. }

  /import-from-url:
    post:
      tags: [upload]
      operationId: importFromUrl
      summary: Bring a file at a link into the drive, fetched by the service itself.
      parameters:
        - $ref: '#/components/parameters/AbsurdApp'
      description: |
        For callers with no disk to upload from. The link must be https on port 443, with no credentials in it, and
        every address it or a redirect leads to must be on the public internet. It has to state the file's size, and
        past 64 MiB its server has to answer range requests.

        Then:

          1. `POST /import-from-url-part` with `importToken` for each part, 1 to `partCount`, in any order
          2. `POST /complete-multipart-upload` with `token`
          3. `POST /insert-file` with `file`, plus `isPublic: false` and the caller as `creator`; with `folder`, it's
             recorded in that folder from the start
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                fileName: { type: string, maxLength: 255, description: Otherwise the server's name, or the link's. }
                folder:
                  type: string
                  description: |
                    A folder path like `/Data` to put it in, made if it isn't there. `file` then names it as `folder`
                    for insert-file.
      responses:
        '200':
          description: The import, opened.
          content:
            application/json:
              schema:
                type: object
                properties:
                  importToken: { type: string }
                  token: { type: string, description: For complete-multipart-upload. }
                  partSize: { type: integer }
                  partCount: { type: integer }
                  file: { $ref: '#/components/schemas/FileInput' }
        '400':
          description: |
            The link was refused (`source_refused`, `source_size_unknown`, `source_ranges_required`, `empty_file`),
            the file won't fit (`storage_limit_reached`, `file_too_large`), or the `X-Absurd-App` header is missing.
        '403': { description: '`subscription_required`: the account has no plan or pass that uploads.' }
        '502': { description: The link couldn't be reached. Retryable. }

  /import-from-url-part:
    post:
      tags: [upload]
      operationId: importFromUrlPart
      summary: Bring one part of an import across.
      description: Safe to repeat. A file that changed at the link since it was opened stops the import with a 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [importToken, partNumber]
              properties:
                importToken: { type: string }
                partNumber: { type: integer, minimum: 1 }
      responses:
        '200': { description: The part is stored. }
        '403': { description: The import token is invalid, expired, or someone else's. }
        '409':
          description: |
            `source_changed`: the file at the link changed. `source_ranges_required`: its server stopped sending it in
            pieces. Either way, open the import again.
        '502': { description: The link or storage failed for this part. Retryable. }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A Gel session token (HS256, `sub` = identity UUID). CLI API tokens must first be exchanged
        for a session token via the auth service's `/exchange-token` endpoint.

  parameters:
    AbsurdApp:
      name: X-Absurd-App
      in: header
      required: true
      schema: { type: string }
      description: Calling application identifier. Rejected if unrecognized.

  responses:
    BadRequest:
      description: Missing or invalid field.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }
    Forbidden:
      description: Token rejected, path prefix not permitted, or subscription required.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ApiError' }

  schemas:
    ApiError:
      type: object
      required: [error, code, retryable]
      properties:
        error:
          type: string
          description: Human-readable sentence. Free to change; do not branch on it.
        code:
          type: string
          description: |
            Stable machine handle. Branch on this, never on `error`. One of:
            - `adding_to_gallery_failed`
            - `adding_to_playlist_failed`
            - `admin_check_failed`
            - `admin_required`
            - `archive_too_large`
            - `authentication_required`
            - `batch_too_large`
            - `changing_file_request_failed`
            - `corrupt_record`
            - `create_archive_failed`
            - `creating_file_request_failed`
            - `creating_file_review_failed`
            - `creating_gallery_failed`
            - `creating_playlist_failed`
            - `creator_mismatch`
            - `delete_file_failed`
            - `delete_files_failed`
            - `delete_linked_failed`
            - `delete_version_failed`
            - `delete_work_failed`
            - `deleting_file_data_failed`
            - `empty_batch`
            - `empty_file`
            - `extract_audio_failed`
            - `extract_frame_failed`
            - `extract_image_failed`
            - `fetching_file_failed`
            - `fetching_image_failed`
            - `fetching_video_failed`
            - `file_not_found`
            - `file_too_large`
            - `generation_refused`
            - `generator_unavailable`
            - `image_too_large`
            - `import_part_failed`
            - `inserting_file_failed`
            - `internal_error`
            - `invalid_app_header`
            - `invalid_body`
            - `invalid_bucket`
            - `invalid_email`
            - `invalid_field`
            - `invalid_file_name`
            - `invalid_file_type`
            - `invalid_id`
            - `invalid_name`
            - `invalid_note`
            - `invalid_object_key`
            - `invalid_part_number`
            - `invalid_position`
            - `invalid_sender_name`
            - `invalid_text`
            - `invalid_token`
            - `invalid_visibility`
            - `list_buckets_failed`
            - `listing_objects_failed`
            - `missing_app_header`
            - `missing_field`
            - `move_file_failed`
            - `moving_files_failed`
            - `multipart_abort_failed`
            - `multipart_complete_failed`
            - `multipart_create_failed`
            - `not_found`
            - `not_owner`
            - `note_not_found`
            - `origin_not_allowed`
            - `parts_invalid`
            - `parts_missing`
            - `purging_trashed_files_failed`
            - `rate_limited`
            - `reading_file_failed`
            - `recording_file_view_failed`
            - `reordering_gallery_failed`
            - `reordering_playlist_failed`
            - `replying_to_note_failed`
            - `request_closed`
            - `request_expired`
            - `request_full`
            - `request_not_found`
            - `request_pass_invalid`
            - `request_unavailable`
            - `resolving_note_failed`
            - `restoring_files_failed`
            - `review_closed`
            - `review_expired`
            - `review_gone`
            - `review_not_found`
            - `review_pass_invalid`
            - `searching_files_failed`
            - `selecting_collections_failed`
            - `selecting_drive_events_failed`
            - `selecting_file_requests_failed`
            - `selecting_file_reviews_failed`
            - `selecting_file_views_failed`
            - `selecting_files_failed`
            - `selecting_generations_failed`
            - `selecting_trashed_files_failed`
            - `setting_cover_failed`
            - `size_mismatch`
            - `source_changed`
            - `source_ranges_required`
            - `source_refused`
            - `source_size_unknown`
            - `source_unreachable`
            - `stop_link_invalid`
            - `storage_limit_reached`
            - `stream_unavailable`
            - `subscription_required`
            - `tagging_files_failed`
            - `token_required`
            - `too_many_parts`
            - `trashing_files_failed`
            - `unknown_route`
            - `unreadable`
            - `updating_file_authorized_failed`
            - `updating_file_description_failed`
            - `updating_file_email_shares_failed`
            - `updating_file_tags_failed`
            - `updating_metadata_failed`
            - `updating_thumbnail_failed`
            - `upload_not_found`
            - `upload_start_failed`
            - `upload_token_invalid`
            - `user_lookup_failed`
            - `user_not_found`
            - `verify_file_failed`
        field:
          type: string
          description: Present when a specific request field caused the failure.
        retryable:
          type: boolean
          description: Whether retrying the identical request could plausibly succeed.

    StoredPart:
      type: object
      required: [partNumber, size]
      properties:
        partNumber: { type: integer }
        size: { type: integer, format: int64 }

    SignedPart:
      type: object
      required: [partNumber, size, url]
      properties:
        partNumber: { type: integer }
        size: { type: integer, format: int64 }
        url:
          type: string
          format: uri
          description: Accepts exactly `size` bytes, by `PUT`.

    UploadToken:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: From `/create-multipart-upload`.

    FileType:
      type: string
      enum: [Image, Video, Audio, Pdf, Latex, Blob]
      description: Discriminator for polymorphic file bodies.

    FileInput:
      type: object
      required: [type, objectKey, fileSize, fileName]
      properties:
        type: { $ref: '#/components/schemas/FileType' }
        fileName: { type: string }
        fileSize:
          type: integer
          format: int64
          maximum: 2000000000000
          description: Exact byte length. 2TB ceiling.
        mimeType: { type: string }
        bucket:
          type: string
          description: |
            Optional. Omit for the private bucket, which is where user uploads belong. When
            supplied it must be the configured public or private bucket.
        objectKey:
          type: string
          description: |
            `users/{userId}/...` (at least three segments, userId must be the caller) or
            `global/...` (admins only, public bucket only).

    FileInsert:
      allOf:
        - $ref: '#/components/schemas/FileInput'
        - type: object
          required: [isPublic, creator]
          properties:
            isPublic:
              type: boolean
              description: Must be true for `global/` keys and false for `users/` keys.
            creator:
              type: object
              required: [id]
              properties:
                id: { type: string, format: uuid }
              description: Must match the authenticated user for `users/` keys.
            downloadUrl: { type: string, format: uri }
            tags:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
            folder:
              type: object
              required: [id]
              properties:
                id: { type: string, format: uuid }
              description: |
                One of the caller's folders to record it in, as import-from-url hands back; `not_found` (404) when it
                isn't theirs. Not for `global/` keys.

    FileRecord:
      type: object
      properties:
        id: { type: string, format: uuid }
        type: { $ref: '#/components/schemas/FileType' }
        fileName: { type: string }
        fileSize: { type: integer, format: int64 }
        mimeType: { type: string }
        isPublic: { type: boolean }
        downloadUrl: { type: [string, 'null'], format: uri }
        objectKey: { type: string }
        bucket: { type: string }
        insertTime: { type: string, format: date-time }
        updateTime: { type: string, format: date-time }
        tags:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              text: { type: string }

    FileRef:
      type: object
      description: |
        A file as the mutation endpoints read it. The body is deserialized as a File, so `type`
        is required; take it from `select-user-files` or `select-trashed-files`.
      required: [type, id]
      properties:
        type: { $ref: '#/components/schemas/FileType' }
        id: { type: string, format: uuid }

    StorageUsage:
      type: object
      properties:
        totalBytes: { type: integer, format: int64 }
        totalObjects: { type: integer }
        publicBytes: { type: integer, format: int64 }
        publicObjects: { type: integer }
        privateBytes: { type: integer, format: int64 }
        privateObjects: { type: integer }
        audioBytes: { type: integer, format: int64 }
        audioObjects: { type: integer }
        videoBytes: { type: integer, format: int64 }
        videoObjects: { type: integer }
        imageBytes: { type: integer, format: int64 }
        imageObjects: { type: integer }
        archiveBytes: { type: integer, format: int64 }
        archiveObjects: { type: integer }
        documentBytes: { type: integer, format: int64 }
        documentObjects: { type: integer }

    SearchResult:
      type: object
      description: A file with only the facts it has. Audio and video carry `seconds`; tracks their tags.
      required: [id, type, fileName, fileSize, mimeType, isPublic, insertTime]
      properties:
        id: { type: string, format: uuid }
        type: { $ref: '#/components/schemas/FileType' }
        fileName: { type: string }
        fileSize: { type: integer, format: int64 }
        mimeType: { type: string }
        isPublic: { type: boolean }
        downloadUrl: { type: string, format: uri, description: A public file's link. }
        insertTime: { type: string, format: date-time }
        description: { type: string }
        tags:
          type: array
          items: { type: string }
        folder: { type: string }
        seconds: { type: number }
        width: { type: integer }
        height: { type: integer }
        takenTime: { type: string, format: date-time }
        location:
          type: object
          properties:
            latitude: { type: number }
            longitude: { type: number }
        camera: { type: string }
        title: { type: string }
        artists: { type: string }
        album: { type: string }
        genre: { type: string }
        tempo: { type: number, description: Beats per minute. }
        key: { type: string, description: Like `F#m` or `A`. }

    CollectionResult:
      type: object
      required: [id, title, isPublic, added, skipped]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        isPublic: { type: boolean }
        fileCount: { type: integer }
        added:
          type: array
          items: { type: string, format: uuid }
        alreadyIn:
          type: array
          description: Files that were in it already.
          items: { type: string, format: uuid }
        skipped:
          type: array
          description: Files that aren't the caller's, or aren't the kind this holds.
          items: { type: string, format: uuid }

    ReviewNote:
      type: object
      properties:
        id: { type: string, format: uuid }
        from: { type: string, description: The reviewer's name, or `You` for the owner. }
        fromOwner: { type: boolean }
        text: { type: string }
        time: { type: string, format: date-time }
