openapi: 3.0.3
info:
  title: Media API
  version: 1.3.0
  description: |
    Manages media assets stored in SeaweedFS. Handles the full lifecycle from
    TUS resumable upload through background processing, thumbnail generation,
    and asset serving.

    ## URL Strategy
    The API returns relative paths for `url` and `thumbnailUrl`, including
    the bucket segment:
    ```
    url:          "/assets/playtelly/jastv/banner/hero.jpg"
    thumbnailUrl: "/assets/playtelly/jastv/banner/thumbs/hero.jpg"
    ```
    The client prepends its own base URL to construct the full asset URL.

    **As of 2026-07-12, `/assets/*` is served by cproxy (fronting SeaweedFS
    directly), not CoreAPI.** nginx strips the `/assets/` prefix before
    forwarding — it's a routing convention on the client side, not a route
    that exists on the serving backend. CoreAPI's own `/assets/*` route
    (documented below) still exists as a manual rollback fallback, but is
    not in the default serving path.

    **React app (via nginx → cproxy):** `VITE_ASSET_BASE_URL + url`
    e.g. `http://localhost:15173/assets/playtelly/jastv/banner/hero.jpg`

    **Shareable/downloadable link:** `VITE_CDN_HOST + url` (falls back to
    `VITE_ASSET_BASE_URL` until a real CDN hostname is configured)

    **HTML page / direct to CoreAPI (rollback path only):** `window.location.origin + url`
    e.g. `http://localhost:3000/assets/playtelly/jastv/banner/hero.jpg`

servers:
  - url: http://localhost:3000
    description: Local development (direct to CoreAPI — rollback path only, not default serving route)
  - url: https://stag.api.castis.io/
    description: Staging CoreAPI server
  - url: http://localhost:15173
    description: Local development (via nginx → cproxy — default serving route)

tags:
  - name: Media
    description: Media asset management
  - name: Upload
    description: TUS upload flow
  - name: Assets
    description: File serving (rollback path — default serving is via cproxy, see URL Strategy above)
  - name: TranscodingProfiles
    description: |
      Shared config table for video/image/document processing, category
      discriminated. Video uses `renditions` (CMAF ladder); image and
      document use `settings` (webp options / rasterization options,
      respectively). Editing a profile creates a new version — existing
      Media rows stay pinned to whichever version they were processed
      with.
  - name: Slides
    description: Per-page output of the document rasterization pipeline

paths:

  /api/v1/media:
    get:
      tags: [Media]
      summary: List media
      operationId: listMedia
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: type
          in: query
          schema: { type: string, enum: [video, image, audio, document, other] }
        - name: status
          in: query
          schema: { type: string, enum: [pending, uploading, processing, ready, error, archived] }
        - name: tenant
          in: query
          schema: { type: string }
          description: Filter by tenant prefix e.g. jastv
        - name: folder
          in: query
          schema: { type: string }
          description: Filter by folder prefix
        - name: tag
          in: query
          schema: { type: string }
        - name: search
          in: query
          schema: { type: string }
          description: Search title, originalName, description
        - name: uploadedBy
          in: query
          schema: { type: string }
        - name: sortBy
          in: query
          schema: { type: string, enum: [created_at, title, file_size, placement], default: created_at }
        - name: sortDir
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
        - name: isActive
          in: query
          schema: { type: boolean, default: true }
          description: Pass false to include inactive/archived records
      responses:
        "200":
          description: Paginated media list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaListResponse"
              example:
                success: true
                data:
                  - id: 7
                    title: turtle
                    bucket: playtelly
                    folder: davichi/home
                    fileName: turtle.mp4
                    storageKey: davichi/home/turtle.mp4
                    mediaType: video
                    status: ready
                    fileSize: 6533201
                    url: /assets/playtelly/davichi/home/turtle.mp4
                    thumbnailUrl: /assets/playtelly/davichi/home/thumbs/turtle.jpg
                meta:
                  page: 1
                  limit: 20
                  total: 7
                  total_pages: 1
                  count: 7
                  has_next: false
                  has_prev: false
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/media/folders:
    get:
      tags: [Media]
      summary: List distinct folders
      operationId: listFolders
      parameters:
        - name: tenant
          in: query
          schema: { type: string }
          description: Filter folders by tenant prefix
      responses:
        "200":
          description: List of folder paths
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data: ["jastv/banners", "jastv/vod", "monomax/hero"]

  /api/v1/media/tenants:
    get:
      tags: [Media]
      summary: List distinct tenants
      operationId: listTenants
      responses:
        "200":
          description: List of tenant prefixes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data: ["jastv", "monomax", "ferrer"]

  /api/v1/media/tags:
    get:
      tags: [Media]
      summary: List distinct tags
      operationId: listTags
      responses:
        "200":
          description: All unique tags across active media
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data: ["banner", "hero", "homepage", "vod", "animation"]

  /api/v1/media/stats:
    get:
      tags: [Media]
      summary: Media statistics
      operationId: mediaStats
      responses:
        "200":
          description: Count by type, count by status, total storage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data:
                  byType:
                    - { mediaType: "video", count: 6 }
                    - { mediaType: "image", count: 3 }
                    - { mediaType: "audio", count: 1 }
                  byStatus:
                    - { status: "ready", count: 10 }
                  totalSizeBytes: 419051423

  /api/v1/media/status:
    get:
      tags: [Media]
      summary: Health check
      operationId: mediaHealth
      responses:
        "200":
          description: Service ok
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data: "media service ok"

  /api/v1/media/prepare:
    post:
      tags: [Upload]
      summary: Issue upload token
      operationId: prepareUpload
      description: |
        Step 1 of the TUS upload flow. Creates a short-lived upload token (10 min)
        that holds all metadata. Pass the token as `metadata.uploadToken` in the TUS upload.

        The `tenant` and `folder` fields determine the final storage path:
        ```
        playtelly/<tenant>/<folder>/<fileName>
        ```
        The S3 bucket is always set by `MINIO_BUCKET` env var on the server —
        never sent by the client.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PrepareInput"
            example:
              title: "Hero Banner"
              originalName: "hero.jpg"
              mediaType: "image"
              mimeType: "image/jpeg"
              fileSize: 672049
              tenant: "jastv"
              folder: "banner"
      responses:
        "201":
          description: Token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PrepareResponse"
              example:
                success: true
                data:
                  token: "a1b2c3d4e5f6..."
                  expiresAt: "2026-06-12T03:26:00Z"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/media/retry-stuck:
    post:
      tags: [Media]
      summary: Retry stuck records
      operationId: retryStuck
      description: |
        Re-triggers processMedia for any record stuck in `processing` for more than 5 minutes.
        Safe to call repeatedly.
      responses:
        "200":
          description: Retry result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data:
                  message: "retrying 2 stuck records"
                  count: 2

  /api/v1/media/{id}:
    parameters:
      - $ref: "#/components/parameters/IntId"

    get:
      tags: [Media]
      summary: Get media by ID
      operationId: getMedia
      responses:
        "200":
          description: Media record with relative asset paths
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaResponse"
        "404":
          $ref: "#/components/responses/NotFound"

    put:
      tags: [Media]
      summary: Update media metadata
      operationId: updateMedia
      description: |
        All fields optional. Only provided fields are updated.

        **Known gap:** if `folder` or `fileName` changes here, the previously
        cached cproxy response for the old path is not invalidated. Purge is
        not yet wired to this endpoint — see media domain index.md, Serving
        Architecture section.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateInput"
      responses:
        "200":
          description: Updated media record
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "500":
          $ref: "#/components/responses/InternalError"

    delete:
      tags: [Media]
      summary: Delete media
      operationId: deleteMedia
      parameters:
        - name: purge
          in: query
          schema: { type: boolean, default: true }
          description: |
            Defaults to true as of 2026-08-03 (previously defaulted to
            false — the frontend's only caller never passed the param, so
            "Delete" in the UI was silently DB-only before this change).
            Pass `purge=false` to opt into the old soft-delete-only
            behavior. When true and the record is transcoded media (has a
            transcodingProfileId), the whole `<folder>/<mediaId>/` storage
            prefix is removed recursively (source, thumbs, hls/smil,
            optimized, slides) — not just the individual storageKey/
            thumbnailKey. Flat-layout media removes just those two keys.
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data:
                  id: 7
                  purged: true
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/media/storage-audit:
    get:
      tags: [Media]
      summary: Cross-reference SeaweedFS objects against DB rows
      operationId: storageAudit
      description: |
        Read-only. Lists SeaweedFS objects with no corresponding Media row
        (`orphans`) and Media rows whose expected key/prefix isn't actually
        present in storage (`missing`). Does not delete anything itself —
        pair with `DELETE /media/{id}?purge=true` or a manual cleanup pass.
      parameters:
        - name: prefix
          in: query
          schema: { type: string }
          description: Scope the scan to a storage prefix, e.g. `jastv/`
        - name: limit
          in: query
          schema: { type: integer, default: 20000 }
          description: Max objects to list from SeaweedFS before truncating the scan
      responses:
        "200":
          description: Audit result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data:
                  orphans: ["jastv/tmp/e1b2cf94.mp4"]
                  missing: [{ mediaId: 42, key: "jastv/banner/hero.jpg" }]
                  scanTruncated: false
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/media/{id}/slides:
    parameters:
      - $ref: "#/components/parameters/IntId"
    get:
      tags: [Slides]
      summary: List a document's rasterized pages
      operationId: getMediaSlides
      description: |
        Returns an empty array — not an error — for a document that
        hasn't been rasterized yet, or was processed with a passthrough
        profile.
      responses:
        "200":
          description: Ordered list of slides
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/MediaSlide"
        "400":
          $ref: "#/components/responses/BadRequest"
        "500":
          $ref: "#/components/responses/InternalError"

  /api/v1/media/transcoding-profiles:
    get:
      tags: [TranscodingProfiles]
      summary: List transcoding profiles
      operationId: listTranscodingProfiles
      parameters:
        - name: category
          in: query
          required: true
          schema: { type: string, enum: [video, image, document] }
      responses:
        "200":
          description: Profiles for the given category, with their current version's config flattened in
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TranscodingProfile"
    post:
      tags: [TranscodingProfiles]
      summary: Create a transcoding profile
      operationId: createTranscodingProfile
      description: |
        `renditions` is required (and `settings` ignored) when
        `category=video`. `settings` is required (and `renditions`
        ignored) for `category=image` or `category=document`. An empty
        `renditions: []` (video) or `settings: {}` (image/document) is
        valid — that's how a passthrough profile is defined.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TranscodingProfileInput"
            example:
              name: "Signage Ads Banner"
              code: "IMAGE-SIGNAGE-ADS-BANNER"
              category: "image"
              settings: { format: "webp", quality: 80, maxWidth: 1920, maxHeight: 400 }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    $ref: "#/components/schemas/TranscodingProfile"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/v1/media/transcoding-profiles/{id}:
    parameters:
      - $ref: "#/components/parameters/IntId"
    get:
      tags: [TranscodingProfiles]
      summary: Get a transcoding profile
      operationId: getTranscodingProfile
      responses:
        "200":
          description: Profile with current version's config flattened in
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    $ref: "#/components/schemas/TranscodingProfile"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [TranscodingProfiles]
      summary: Update a transcoding profile
      operationId: updateTranscodingProfile
      description: |
        Creates a new `TranscodingProfileVersion` — never mutates an
        existing version in place (seed data is the one exception; see
        the media domain changelog). Media rows already processed with
        the prior version keep referencing it; only future uploads pick
        up the new version.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TranscodingProfileInput"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    $ref: "#/components/schemas/TranscodingProfile"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/media/transcoding-profiles/{id}/versions:
    parameters:
      - $ref: "#/components/parameters/IntId"
    get:
      tags: [TranscodingProfiles]
      summary: List a profile's version history
      operationId: listTranscodingProfileVersions
      responses:
        "200":
          description: Versions, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TranscodingProfileVersion"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/media/playlists:
    post:
      tags: [Playlists]
      summary: Create a media playlist
      operationId: createPlaylist
      description: |
        Creates a MediaPlaylist referencing existing Media rows by ID, in
        order. Regeneration (concat, CMAF transcode, SMIL generation,
        upload) runs asynchronously — poll GET /media/playlists/:id and
        watch `status`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePlaylistInput"
      responses:
        "201":
          description: Playlist created, regeneration started
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaPlaylistResponse"
        "400":
          $ref: "#/components/responses/BadRequest"

  /api/v1/media/playlists/{id}:
    parameters:
      - $ref: "#/components/parameters/IntId"
    get:
      tags: [Playlists]
      summary: Get playlist by ID
      operationId: getPlaylist
      responses:
        "200":
          description: Playlist with items and Media preloaded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediaPlaylistResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v1/media/playlists/{id}/order:
    parameters:
      - $ref: "#/components/parameters/IntId"
    put:
      tags: [Playlists]
      summary: Reorder playlist items
      operationId: updatePlaylistOrder
      description: |
        Replaces the item order. If the new order hashes identically to
        the last successfully-regenerated order, this is a no-op — no
        re-encode is triggered.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePlaylistOrderInput"
      responses:
        "200":
          description: Order unchanged, no regeneration needed
        "202":
          description: Order changed, regeneration started
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/media/{id}/process:
    parameters:
      - $ref: "#/components/parameters/IntId"
    post:
      tags: [Media]
      summary: Trigger processing
      operationId: processMedia
      description: |
        Manually triggers background processing for a media record.
        Returns 200 if already ready. Returns 202 if processing started.
        Publishes `media.processing.started` to NATS.
      responses:
        "200":
          description: Already ready
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data:
                  message: "already ready"
                  id: 7
        "202":
          description: Processing started
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data:
                  id: 7
                  status: processing
        "404":
          $ref: "#/components/responses/NotFound"

  /assets/{bucket}/{key}:
    parameters:
      - name: bucket
        in: path
        required: true
        schema: { type: string }
        description: S3 bucket, e.g. `playtelly`
      - name: key
        in: path
        required: true
        schema: { type: string }
        description: Storage key e.g. `jastv/banner/hero.jpg`
    get:
      tags: [Assets]
      summary: Serve asset (rollback fallback route — not default serving path)
      operationId: serveAsset
      description: |
        Streams the file directly from SeaweedFS via CoreAPI's own MinIO
        client. Supports HEAD for browser video preflight. Sets Content-Type,
        Cache-Control, and Accept-Ranges headers.

        **As of 2026-07-12, this route is not in the default serving path.**
        `/assets/*` requests from the browser are proxied by nginx to a
        cproxy node fronting SeaweedFS directly — this CoreAPI route exists
        only as a manual rollback switch (flip nginx's `ASSET_SERVING_URL`
        back to CoreAPI to re-enable it).
      responses:
        "200":
          description: File stream
          headers:
            Content-Type:
              schema: { type: string }
            Cache-Control:
              schema: { type: string, example: "public, max-age=31536000" }
            Accept-Ranges:
              schema: { type: string, example: "bytes" }
        "404":
          $ref: "#/components/responses/NotFound"

  /assets/{bucket}/{key}/presign:
    parameters:
      - name: bucket
        in: path
        required: true
        schema: { type: string }
      - name: key
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Assets]
      summary: Get presigned URL (not used in current architecture)
      operationId: presignAsset
      description: |
        Returns a presigned S3 URL valid for 15 minutes.

        **Not used by the current cproxy-based serving architecture** — the
        client never contacts SeaweedFS directly, and cproxy's origin routing
        is fixed/internal rather than credential-based, so presigning doesn't
        apply. This endpoint remains for potential future use but has no
        active caller today.
      responses:
        "200":
          description: Presigned URL
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessResponse"
              example:
                success: true
                data:
                  url: "http://seaweedfs:8333/playtelly/jastv/banner/hero.jpg?X-Amz-Signature=..."
        "500":
          $ref: "#/components/responses/InternalError"

  /hooks/tusd:
    post:
      tags: [Upload]
      summary: TUS post-finish webhook
      operationId: tusdHook
      description: |
        Called automatically by tusd after upload completes.
        Not called by the frontend directly.

        On receipt:
        1. Looks up UploadToken by `metadata.uploadToken`
        2. Creates Media record with `status=processing`
        3. Marks token `used=true`
        4. Launches `processMedia` goroutine (filer move → status=ready)
        5. Launches `generateThumbnail` goroutine after move completes
      responses:
        "200":
          description: Acknowledged

components:
  parameters:
    IntId:
      name: id
      in: path
      required: true
      schema: { type: integer, minimum: 1 }
    Page:
      name: page
      in: query
      schema: { type: integer, default: 1, minimum: 1 }
    Limit:
      name: limit
      in: query
      schema: { type: integer, default: 20, minimum: 1, maximum: 100 }

  responses:
    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
          example:
            success: false
            error: "media not found"
    BadRequest:
      description: Validation error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"

  schemas:
    Media:
      type: object
      properties:
        id:              { type: integer, readOnly: true }
        createdAt:       { type: string, format: date-time, readOnly: true }
        updatedAt:       { type: string, format: date-time, readOnly: true }
        title:           { type: string }
        description:     { type: string }
        altText:         { type: string }
        tenantId:        { type: string }
        uploadedBy:      { type: string }
        bucket:          { type: string, example: playtelly }
        folder:          { type: string, example: "jastv/banner" }
        fileName:        { type: string, example: "hero.jpg" }
        storageKey:      { type: string, example: "jastv/banner/hero.jpg", description: "Source of truth — stored in DB" }
        originalName:    { type: string }
        mimeType:        { type: string }
        fileSize:        { type: integer }
        mediaType:
          type: string
          enum: [video, image, audio, document, other]
        status:
          type: string
          enum: [pending, uploading, processing, ready, error, archived]
        width:           { type: integer, nullable: true }
        height:          { type: integer, nullable: true }
        duration:        { type: number, nullable: true }
        thumbnailKey:    { type: string, description: "Raw key stored in DB" }
        optimizedKey:
          type: string
          nullable: true
          description: |
            Image category only. Set once optimizeImage finishes —
            `<folder>/optimized/<stem>.webp`. Distinct from storageKey
            (the untouched original upload).
        transcodingProfileId:
          type: integer
          nullable: true
          description: Set at upload time via PrepareInput; drives which pipeline (transcode/optimize/rasterize) processMedia runs
        transcodingProfileVersionId:
          type: integer
          nullable: true
          description: Resolved and pinned once processing actually starts — the specific version this asset was processed with
        transcodingStage:
          type: string
          nullable: true
          enum: ["", queued, transcode, done, failed]
          description: |
            Empty string for anything with no active pipeline (no profile,
            audio, "other"). Only meaningful when transcodingProfileId is set.
        placement:       { type: integer, nullable: true }
        tags:            { type: string }
        license:         { type: string }
        provider:        { type: string }
        isActive:        { type: boolean }
        activeStartDate: { type: string, format: date, nullable: true }
        activeEndDate:   { type: string, format: date, nullable: true }
        url:
          type: string
          example: /assets/playtelly/jastv/banner/hero.jpg
          description: |
            Relative path — derived from bucket + storageKey at response
            time, never stored. Prepend base URL to use:
            `VITE_ASSET_BASE_URL + url` (in-app preview) or
            `VITE_CDN_HOST + url` (shareable link, falls back to
            VITE_ASSET_BASE_URL) or `window.location.origin + url`
            (rollback path, direct to CoreAPI).
        thumbnailUrl:
          type: string
          example: /assets/playtelly/jastv/banner/thumbs/hero.jpg
          description: |
            Relative path to thumbnail. Empty until thumbnail generation completes.
            Prepend base URL same as url.

    PrepareInput:
      type: object
      required: [title, originalName, fileSize]
      properties:
        title:           { type: string }
        description:     { type: string }
        mediaType:       { type: string, enum: [video, image, audio, document, other] }
        mimeType:        { type: string }
        fileSize:        { type: integer }
        originalName:    { type: string }
        tenant:          { type: string, description: "Tenant namespace — becomes path prefix e.g. jastv" }
        folder:          { type: string, description: "Subfolder within tenant e.g. banner" }
        fileName:        { type: string, description: "Desired display filename" }
        uploadedBy:      { type: string }
        tenantId:        { type: string }
        tags:            { type: string }
        placement:       { type: integer, nullable: true }
        transcodingProfileId:
          type: integer
          nullable: true
          description: |
            Which TranscodingProfile to process this upload with —
            required to get anything other than a plain, unprocessed
            flat-layout upload. Pass a *-PASSTHROUGH profile's id to skip
            processing but still get the per-category pipeline's
            bookkeeping (e.g. a resolved transcodingProfileVersionId).

    UpdateInput:
      type: object
      description: All fields optional — only provided fields are updated
      properties:
        title:           { type: string }
        description:     { type: string }
        altText:         { type: string }
        fileName:        { type: string }
        folder:          { type: string }
        tags:            { type: string }
        placement:       { type: integer, nullable: true }
        isActive:        { type: boolean }
        license:         { type: string }
        provider:        { type: string }
        activeStartDate: { type: string, format: date }
        activeEndDate:   { type: string, format: date }

    PrepareResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: object
          properties:
            token:     { type: string }
            expiresAt: { type: string, format: date-time }

    MediaResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          $ref: "#/components/schemas/Media"

    MediaListResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          type: array
          items:
            $ref: "#/components/schemas/Media"
        meta:
          $ref: "#/components/schemas/PaginationMeta"

    CreatePlaylistInput:
      type: object
      required: [title, mediaIds]
      properties:
        tenant:               { type: string }
        title:                { type: string }
        mediaIds:             { type: array, items: { type: integer } }
        transcodingProfileId: { type: integer, nullable: true }

    UpdatePlaylistOrderInput:
      type: object
      required: [mediaIds]
      properties:
        mediaIds: { type: array, items: { type: integer } }

    MediaPlaylist:
      type: object
      properties:
        id:              { type: integer, readOnly: true }
        tenant:          { type: string }
        title:           { type: string }
        status:
          type: string
          enum: [pending, concat, transcode, package, ready, failed]
        sourceOrderHash: { type: string }
        manifestKey:     { type: string, description: "CMAF/HLS master playlist path" }
        smilKey:         { type: string, description: "Castis SMIL path" }
        duration:        { type: number, nullable: true }
        generatedAt:     { type: string, format: date-time, nullable: true }
        errorMessage:    { type: string }
        items:
          type: array
          items:
            $ref: "#/components/schemas/MediaPlaylistItem"

    MediaPlaylistItem:
      type: object
      properties:
        id:              { type: integer, readOnly: true }
        mediaPlaylistId: { type: integer }
        mediaId:         { type: integer }
        order:           { type: integer }
        media:
          $ref: "#/components/schemas/Media"

    MediaPlaylistResponse:
      type: object
      properties:
        success: { type: boolean }
        data:
          $ref: "#/components/schemas/MediaPlaylist"

    TranscodingProfile:
      type: object
      description: |
        One shared table across video/image/document, `category` as
        discriminator. Response is the profile identity flattened
        together with its **current** version's config (renditions/
        settings/segmentSecs) for convenience — the version history
        itself is a separate object, see TranscodingProfileVersion.
      properties:
        id:              { type: integer, readOnly: true }
        name:            { type: string, example: "Signage Ads Banner" }
        code:
          type: string
          nullable: true
          description: Stable machine-readable id, e.g. IMAGE-SIGNAGE-ADS-BANNER. Nullable — ad-hoc UI-created profiles don't set one.
        category:        { type: string, enum: [video, image, document] }
        container:       { type: string, nullable: true, description: "Video only — not read by any pipeline for image/document" }
        isDefault:       { type: boolean }
        version:         { type: integer, description: "Current version number" }
        renditions:
          type: array
          description: category=video only. Empty array = passthrough.
          items: { type: object }
        settings:
          type: object
          nullable: true
          description: |
            category=image|document only. Shape depends on category:
            image → {format, quality, maxWidth, maxHeight};
            document → {dpi, colorScheme, defaultSlideDurationSec}.
            Missing/empty = passthrough.
        segmentSecs:     { type: integer, nullable: true, description: "Video only" }
        assetCount:      { type: integer, description: "Number of Media rows currently referencing this profile" }
        createdAt:       { type: string, format: date-time, readOnly: true }
        updatedAt:       { type: string, format: date-time, readOnly: true }

    TranscodingProfileVersion:
      type: object
      description: Immutable snapshot of a profile's config as of one edit. Media rows pin to a specific version, never to "whatever the profile currently is."
      properties:
        id:                   { type: integer, readOnly: true }
        transcodingProfileId: { type: integer }
        version:              { type: integer }
        renditions:           { type: array, items: { type: object } }
        settings:              { type: object, nullable: true }
        segmentSecs:          { type: integer, nullable: true }
        changeNote:           { type: string, nullable: true }
        createdAt:            { type: string, format: date-time, readOnly: true }

    TranscodingProfileInput:
      type: object
      required: [name, category]
      description: |
        `renditions` required for category=video; `settings` required for
        category=image|document — the API 400s if you send the wrong one.
      properties:
        name:        { type: string }
        code:        { type: string, nullable: true }
        category:    { type: string, enum: [video, image, document] }
        container:   { type: string, nullable: true, description: "Video only" }
        isDefault:   { type: boolean }
        renditions:  { type: array, items: { type: object } }
        settings:    { type: object, nullable: true }
        segmentSecs: { type: integer, nullable: true }
        changeNote:  { type: string, nullable: true, description: "Recorded on the new version created by this call" }

    MediaSlide:
      type: object
      description: One rasterized page of a document, produced by the document rasterization pipeline.
      properties:
        id:          { type: integer, readOnly: true }
        mediaId:     { type: integer }
        order:       { type: integer, description: "1-indexed page number" }
        imageKey:    { type: string, example: "jastv/decks/deck/slides/deck/page-1.jpg" }
        durationSec: { type: integer, description: "Defaulted from the profile's settings.defaultSlideDurationSec at rasterize time — metadata only, expected to be overridden by the consumer (e.g. Tellyboard's own schedule duration)" }

    PaginationMeta:
      type: object
      properties:
        count:       { type: integer }
        total:       { type: integer }
        page:        { type: integer }
        limit:       { type: integer }
        total_pages: { type: integer }
        has_next:    { type: boolean }
        has_prev:    { type: boolean }
        timestamp:   { type: string, format: date-time }

    SuccessResponse:
      type: object
      properties:
        success: { type: boolean }
        data:    {}

    ErrorResponse:
      type: object
      properties:
        success: { type: boolean, example: false }
        error:   { type: string }