openapi: 3.1.0
info:
  title: Timms4Culture API
  version: 1.0.0
  description: |
    HTTP-API für das Timms4Culture-Filmportal. Zwei Familien von Endpoints:

    - **Medien-URLs** (`/media/{id}/...`, `/meta/{id}/...`) liefern Original-Datei,
      HLS-Streams, Vorschaubilder, WebVTT-Untertitel und PBCore-XML.
    - **JSON-API** (`/api/{namespace}.{method}.{format}`) gibt strukturierte Daten zurück
      (JSON oder XML), genutzt von der Web-Oberfläche und externen Systemen.

    Schreibende Endpoints akzeptieren drei Auth-Pfade: Session-Cookie, `Authorization: Bearer <key>`
    oder `?apikey=<key>`. Deaktivierte Accounts (`users.is_enabled = 0`) werden überall blockiert.

    **Rückwärtskompatibilität:** Das ältere URL-Schema `/api/{Namespace}/{Method}/{Format}` wird
    weiterhin aufgelöst. Die hier dokumentierte Dot-Notation ist die empfohlene Form.
  license:
    name: Proprietär
  contact:
    name: Timms4Culture
    url: https://timms4culture.hdf.de/

servers:
  - url: https://timms4culture.hdf.de
    description: Produktion

tags:
  - name: media
    description: Direkter Datei-Auslauf aus videofiles/&lt;id&gt;/
  - name: health
    description: System-Health-Check
  - name: auth
    description: Login / Logout
  - name: video-public
    description: Öffentliche Suche und Startseiten-Sektionen
  - name: video
    description: CRUD und Asset-Verwaltung (Login erforderlich)
  - name: upload
    description: Chunked Upload großer Dateien
  - name: profile
    description: Selbstbedienung für den eingeloggten User
  - name: admin
    description: UserManager — nur für Admins

security:
  - cookieAuth: []
  - bearerAuth: []
  - apiKeyQuery: []

components:

  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: PHPSESSID
      description: Wird nach erfolgreichem `POST /api/auth.login.json` gesetzt.
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <apikey>`. Den Token erzeugt jeder User
        einmalig über `/api/profile.generateapikey.json` — in der DB liegt
        nur SHA-256(Pepper + Token).
    apiKeyQuery:
      type: apiKey
      in: query
      name: apikey
      description: Derselbe Token wie bei bearerAuth, als Query-Parameter.

  parameters:
    VideoId:
      name: id
      in: path
      required: true
      description: UUID des Videos (Spalte `videos.id`).
      schema:
        type: string
        format: uuid
    LangCode:
      name: lang
      in: path
      required: true
      description: 2–8 Kleinbuchstaben (z.B. `de`, `en`).
      schema:
        type: string
        pattern: ^[a-z]{2,8}$

  schemas:

    Error:
      type: object
      required: [id, msg]
      properties:
        id:  { type: integer, description: "0 wenn alles OK, sonst > 0" }
        msg: { type: string }

    Envelope:
      type: object
      required: [error, result, runtime]
      properties:
        error:   { $ref: "#/components/schemas/Error" }
        result:
          description: Operations-spezifische Nutzdaten oder `null` bei Fehlern.
          oneOf:
            - type: object
            - type: array
            - type: "null"
        runtime: { type: number, format: float, description: "Server-seitige Laufzeit in Sekunden" }
        request:
          type: object
          description: Eingabe-Parameter, vom Server zurückgespiegelt.
          additionalProperties: true

    OkResult:
      type: object
      properties:
        ok: { type: boolean }

    VideoSummary:
      type: object
      properties:
        id:           { type: string, format: uuid }
        name:         { type: string }
        external_id:  { type: string, nullable: true }
        pool_id:      { type: string, format: uuid, nullable: true }
        pool_name:    { type: string, nullable: true }
        ts_created:   { type: integer, description: "Unix-Timestamp" }
        org_duration: { type: number,  format: float, nullable: true, description: "Dauer in Sekunden" }
        org_size:     { type: integer, nullable: true, description: "Originalgröße in Bytes" }
        poster_url:   { type: string,  description: "Pre-computed Poster-URL (bei homeSection)" }

    Video:
      allOf:
        - $ref: "#/components/schemas/VideoSummary"
        - type: object
          properties:
            description:        { type: string }
            original_file:      { type: string, description: "Storage-Name <md5>.<ext>" }
            original_file_name: { type: string, description: "User-Upload-Dateiname" }
            pool_infobox:       { type: string, nullable: true }
            created_user_id:    { type: string, format: uuid, nullable: true }

    SearchResponse:
      type: object
      properties:
        rows:        { type: array, items: { $ref: "#/components/schemas/VideoSummary" } }
        total:       { type: integer }
        page:        { type: integer }
        per_page:    { type: integer }
        total_pages: { type: integer }

    Pool:
      type: object
      properties:
        id:   { type: string, format: uuid }
        name: { type: string }

    PoolStat:
      allOf:
        - $ref: "#/components/schemas/Pool"
        - type: object
          properties:
            video_count:    { type: integer }
            total_size:     { type: integer, description: "Bytes" }
            total_duration: { type: number,  format: float, description: "Sekunden" }

    AssetStatus:
      type: object
      properties:
        id:     { type: string, format: uuid }
        poster:
          type: object
          properties:
            active: { type: boolean }
            slots:
              type: object
              additionalProperties: { type: boolean }
              description: Map "1"|"2"|"3" → existiert poster<N>.jpg
        pbcore:
          type: object
          properties:
            exists: { type: boolean }
            size:   { type: integer }
        captions:
          type: array
          items: { $ref: "#/components/schemas/CaptionEntry" }
        chapters:
          type: array
          items: { $ref: "#/components/schemas/CaptionEntry" }

    CaptionEntry:
      type: object
      properties:
        lang: { type: string }
        size: { type: integer }

    AuditEntry:
      type: object
      properties:
        id:         { type: integer }
        field:      { type: string,  description: "name | external_id | description" }
        old_value:  { type: string,  nullable: true }
        new_value:  { type: string,  nullable: true }
        ts_created: { type: integer, description: "Unix-Timestamp" }
        user_name:  { type: string,  nullable: true }

    PbcoreFields:
      type: object
      properties:
        identifiers:
          type: array
          items:
            type: object
            properties:
              source: { type: string }
              value:  { type: string }
        titles:
          type: array
          items:
            type: object
            properties:
              type:  { type: string }
              value: { type: string }
        description: { type: string }
        subjects:    { type: array, items: { type: string } }
        genre:       { type: string }
        creators:
          type: array
          items:
            type: object
            properties:
              role: { type: string }
              name: { type: string }
        contributors:
          type: array
          items:
            type: object
            properties:
              role: { type: string }
              name: { type: string }
        rights_summary: { type: string }
        asset_dates:
          type: array
          items:
            type: object
            properties:
              type:  { type: string }
              value: { type: string }

    User:
      type: object
      properties:
        id:         { type: string, format: uuid }
        name:       { type: string }
        email:      { type: string, format: email }
        is_admin:   { type: integer, minimum: 0, maximum: 1 }
        is_enabled: { type: integer, minimum: 0, maximum: 1 }
        ts_stamp:   { type: integer, nullable: true }

    UserListEntry:
      allOf:
        - $ref: "#/components/schemas/User"
        - type: object
          properties:
            pool_count:  { type: integer }
            video_count: { type: integer }

    HealthCheck:
      type: object
      properties:
        status: { type: string, enum: [ok, degraded] }
        checks:
          type: object
          properties:
            php:
              type: object
              properties:
                ok:            { type: boolean }
                version:       { type: string }
                memory_usage:  { type: integer, description: "Bytes" }
                memory_peak:   { type: integer, description: "Bytes" }
                memory_limit:  { type: integer, description: "Bytes (-1 = unlimited)" }
                extensions:    { type: array, items: { type: string } }
                opcache:
                  type: object
                  nullable: true
                  properties:
                    enabled:     { type: boolean }
                    hit_rate:    { type: number }
                    used_memory: { type: integer }
                    free_memory: { type: integer }
            db:
              type: object
              properties:
                ok:     { type: boolean }
                error:  { type: string, nullable: true }
                size:   { type: integer, description: "Gesamtgröße der DB in Bytes" }
                counts:
                  type: object
                  properties:
                    videos:   { type: integer }
                    users:    { type: integer }
                    pools:    { type: integer }
                    feedback: { type: integer }
                connections:
                  type: object
                  properties:
                    active: { type: integer, description: "Threads_connected" }
                    max:    { type: integer, description: "max_connections" }
            storage:
              type: object
              properties:
                ok:         { type: boolean }
                disk_total: { type: integer, nullable: true, description: "Bytes" }
                disk_free:  { type: integer, nullable: true, description: "Bytes" }
                disk_used:  { type: integer, nullable: true, description: "Bytes" }
                disk_pct:   { type: number, nullable: true, description: "Belegung in %" }
            rabbit:
              type: object
              properties:
                ok:              { type: boolean }
                error:           { type: string, nullable: true }
                messages_ready:  { type: integer }
                messages_unack:  { type: integer }
                messages_total:  { type: integer }
                consumers:       { type: integer }
                publish_rate:    { type: number, nullable: true }
                deliver_rate:    { type: number, nullable: true }
                ack_rate:        { type: number, nullable: true }
            cdn:
              type: object
              properties:
                ok:   { type: boolean }
                host: { type: string }
            ssl:
              type: object
              nullable: true
              properties:
                ok:        { type: boolean }
                error:     { type: string, nullable: true }
                issuer:    { type: string, nullable: true }
                valid_to:  { type: string, format: date, nullable: true }
                days_left: { type: integer, nullable: true }
        timings:
          type: object
          description: "Laufzeit jedes Checks in Millisekunden"
          properties:
            php:     { type: number }
            db:      { type: number }
            storage: { type: number }
            rabbit:  { type: number }
            cdn:     { type: number }
        system:
          type: object
          properties:
            memory:
              type: object
              nullable: true
              properties:
                total:      { type: integer, description: "Bytes" }
                used:       { type: integer }
                available:  { type: integer }
                pct:        { type: number, description: "Belegung in %" }
                swap_total: { type: integer }
                swap_free:  { type: integer }
            cpu_load:
              type: object
              nullable: true
              properties:
                1min:  { type: number }
                5min:  { type: number }
                15min: { type: number }
            uptime:
              type: object
              nullable: true
              properties:
                seconds:   { type: integer }
                formatted: { type: string, description: "z.B. 3d 14h 22m" }
        stats:
          type: object
          properties:
            videos:
              type: object
              nullable: true
              properties:
                total:          { type: integer }
                with_hls:       { type: integer }
                with_poster:    { type: integer }
                total_size:     { type: integer, description: "Bytes (Originaldateien)" }
                total_duration: { type: number, description: "Sekunden" }
            uploads:
              type: object
              nullable: true
              properties:
                sessions: { type: integer, description: "Aktive Upload-Sessions" }
                size:     { type: integer, description: "Bytes in Temp-Chunks" }
            sessions:
              type: object
              nullable: true
              properties:
                active:    { type: integer, description: "Anzahl PHP-Session-Dateien" }
                save_path: { type: string }
            logs:
              type: object
              nullable: true
              additionalProperties:
                type: object
                properties:
                  size:       { type: integer, description: "Bytes" }
                  last_entry: { type: string, nullable: true, description: "Timestamp des letzten Eintrags" }
        deploy:
          type: object
          properties:
            stage:      { type: string, nullable: true, description: "z.B. production, staging" }
            git_commit: { type: string, nullable: true, description: "Full SHA des deployten Commits" }
            hostname:   { type: string, nullable: true, description: "Container-Hostname" }

  responses:
    JsonResponse:
      description: Generische JSON-Antwort. `result` enthält die operations-spezifischen Daten.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Envelope" }

    BinaryMedia:
      description: Datei mit korrektem `Content-Type`.
      content:
        application/octet-stream: { schema: { type: string, format: binary } }
        video/mp4:                 { schema: { type: string, format: binary } }
        video/mp2t:                { schema: { type: string, format: binary } }
        image/jpeg:                { schema: { type: string, format: binary } }
        application/vnd.apple.mpegurl: { schema: { type: string } }
        application/xml:           { schema: { type: string } }
        text/vtt:                  { schema: { type: string } }
      headers:
        Accept-Ranges:
          schema: { type: string, enum: [bytes, none] }
        Content-Length:
          schema: { type: integer }

    NotFound:
      description: Datei oder Ressource existiert nicht.
      content:
        text/plain: { schema: { type: string, example: "Not Found" } }

paths:

  # ---------- Media URLs ----------

  /media/{id}/file:
    get:
      tags: [media]
      summary: Original-Datei streamen (MP4 mit Range-Support)
      security: []
      parameters:
        - $ref: "#/components/parameters/VideoId"
        - name: Range
          in: header
          required: false
          schema: { type: string, example: "bytes=0-1048575" }
      responses:
        "200": { $ref: "#/components/responses/BinaryMedia" }
        "206":
          description: Teilantwort (Byte-Range)
          content:
            video/mp4: { schema: { type: string, format: binary } }
        "404": { $ref: "#/components/responses/NotFound" }
        "416":
          description: Range nicht erfüllbar.

  /media/{id}/poster.jpg:
    get:
      tags: [media]
      summary: Aktives Vorschaubild
      security: []
      parameters: [ { $ref: "#/components/parameters/VideoId" } ]
      responses:
        "200":
          description: JPG
          content:
            image/jpeg: { schema: { type: string, format: binary } }
        "404": { $ref: "#/components/responses/NotFound" }

  /media/{id}/poster1.jpg:
    get:
      tags: [media]
      summary: Auto-Vorschlag (25 %)
      security: []
      parameters: [ { $ref: "#/components/parameters/VideoId" } ]
      responses:
        "200":
          description: JPG
          content: { image/jpeg: { schema: { type: string, format: binary } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /media/{id}/poster2.jpg:
    get:
      tags: [media]
      summary: Auto-Vorschlag (50 %)
      security: []
      parameters: [ { $ref: "#/components/parameters/VideoId" } ]
      responses:
        "200":
          description: JPG
          content: { image/jpeg: { schema: { type: string, format: binary } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /media/{id}/poster3.jpg:
    get:
      tags: [media]
      summary: Auto-Vorschlag (75 %)
      security: []
      parameters: [ { $ref: "#/components/parameters/VideoId" } ]
      responses:
        "200":
          description: JPG
          content: { image/jpeg: { schema: { type: string, format: binary } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /media/{id}/hls/masterplaylist.m3u8:
    get:
      tags: [media]
      summary: HLS-Master-Playlist (1080p / 720p / 480p)
      description: |
        Liefert die Master-Playlist on-the-fly aus `hls.zip`.
        Referenziert die Variantenlisten `v0/playlist.m3u8`, `v1/playlist.m3u8`, `v2/playlist.m3u8`.
      security: []
      parameters: [ { $ref: "#/components/parameters/VideoId" } ]
      responses:
        "200":
          description: M3U8
          content:
            application/vnd.apple.mpegurl: { schema: { type: string } }
        "404": { $ref: "#/components/responses/NotFound" }

  /media/{id}/captions.{lang}.vtt:
    get:
      tags: [media]
      summary: WebVTT-Untertitel
      security: []
      parameters:
        - $ref: "#/components/parameters/VideoId"
        - $ref: "#/components/parameters/LangCode"
      responses:
        "200":
          description: WebVTT
          content: { text/vtt: { schema: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /media/{id}/chapters.{lang}.vtt:
    get:
      tags: [media]
      summary: WebVTT-Kapitel
      security: []
      parameters:
        - $ref: "#/components/parameters/VideoId"
        - $ref: "#/components/parameters/LangCode"
      responses:
        "200":
          description: WebVTT
          content: { text/vtt: { schema: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /meta/{id}/pbcore.xml:
    get:
      tags: [media]
      summary: PBCore-Metadaten als XML
      security: []
      parameters: [ { $ref: "#/components/parameters/VideoId" } ]
      responses:
        "200":
          description: XML
          content: { application/xml: { schema: { type: string } } }
        "404": { $ref: "#/components/responses/NotFound" }

  # ---------- Health ----------

  /api/health.check.json:
    get:
      tags: [health]
      summary: System-Health-Check (PHP, DB, Storage, RabbitMQ, System, Stats)
      security: []
      responses:
        "200":
          description: Envelope mit Health-Status.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result: { $ref: "#/components/schemas/HealthCheck" }

  # ---------- Auth ----------

  /api/auth.login.json:
    post:
      tags: [auth]
      summary: Login
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [email, password]
              properties:
                email:    { type: string, format: email }
                password: { type: string, format: password }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/auth.logout.json:
    post:
      tags: [auth]
      summary: Logout (Session beenden)
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  # ---------- Video — öffentlich ----------

  /api/video.search.json:
    post:
      tags: [video-public]
      summary: Paginierte Volltext-Suche
      security: []
      requestBody:
        required: false
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                q:        { type: string,  description: "LIKE über Titel/external_id/Beschreibung/Pool" }
                pool:     { type: string,  format: uuid, description: "Pool-Filter" }
                duration: { type: string,  enum: [short, mid, long], description: "<5 min / 5–30 min / >30 min" }
                sort:     { type: string,  enum: [newest, oldest, az, za], default: newest }
                page:     { type: integer, minimum: 1, default: 1 }
                per_page: { type: integer, minimum: 1, maximum: 100, default: 48 }
      responses:
        "200":
          description: Suchergebnisse im Envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result: { $ref: "#/components/schemas/SearchResponse" }

  /api/video.homesection.json:
    post:
      tags: [video-public]
      summary: 12 Items für eine Startseiten-Sektion
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [type]
              properties:
                type:      { type: string,  enum: [foryou, new, city, pool] }
                city:      { type: string,  description: "nur bei type=city" }
                pool_name: { type: string,  description: "nur bei type=pool" }
                limit:     { type: integer, minimum: 1, maximum: 24, default: 12 }
      responses:
        "200":
          description: Sektion im Envelope.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          rows: { type: array, items: { $ref: "#/components/schemas/VideoSummary" } }

  # ---------- Video — Login ----------

  /api/video.list.json:
    post:
      tags: [video]
      summary: Videos des eingeloggten Users
      requestBody:
        required: false
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                pool_id: { type: string, format: uuid }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.update.json:
    post:
      tags: [video]
      summary: Video-Felder partiell aktualisieren
      description: |
        Nur die übermittelten Felder werden geschrieben. Externe ID wird gegen
        `pools.external_id_regex` und gegen Eindeutigkeit über alle Videos geprüft.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties:
                id:          { type: string, format: uuid }
                name:        { type: string, maxLength: 300 }
                external_id: { type: string, maxLength: 100 }
                description: { type: string }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.delete.json:
    post:
      tags: [video]
      summary: Video löschen (inklusive Dateien)
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: string, format: uuid }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.assetstatus.json:
    post:
      tags: [video]
      summary: Status der Assets (Poster, PBCore, Captions, Chapters)
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties: { id: { type: string, format: uuid } }
      responses:
        "200":
          description: Envelope mit AssetStatus.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result: { $ref: "#/components/schemas/AssetStatus" }

  /api/video.auditlist.json:
    post:
      tags: [video]
      summary: Audit-Log eines Videos (letzte 50 Einträge)
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties: { id: { type: string, format: uuid } }
      responses:
        "200":
          description: Envelope mit Audit-Einträgen.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          rows: { type: array, items: { $ref: "#/components/schemas/AuditEntry" } }

  # ---------- Asset-Uploads ----------

  /api/video.posterupload.json:
    post:
      tags: [video]
      summary: JPG-Poster hochladen (max 8 MiB)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [id, file]
              properties:
                id:   { type: string, format: uuid }
                file: { type: string, format: binary, description: "JPG-Datei" }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.posterchoose.json:
    post:
      tags: [video]
      summary: Auto-Poster 1/2/3 als aktives Poster setzen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id, slot]
              properties:
                id:   { type: string, format: uuid }
                slot: { type: integer, minimum: 1, maximum: 3 }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.posterclear.json:
    post:
      tags: [video]
      summary: Aktives Poster entfernen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties: { id: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.pbcoreupload.json:
    post:
      tags: [video]
      summary: PBCore-XML hochladen (max 4 MiB)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [id, file]
              properties:
                id:   { type: string, format: uuid }
                file: { type: string, format: binary }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.pbcoredelete.json:
    post:
      tags: [video]
      summary: PBCore-XML löschen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties: { id: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.pbcorefields.json:
    post:
      tags: [video]
      summary: PBCore-Struktur lesen (JSON)
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties: { id: { type: string, format: uuid } }
      responses:
        "200":
          description: Envelope mit `{exists, valid, error, fields}`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          exists: { type: boolean }
                          valid:  { type: boolean, nullable: true }
                          error:  { type: string,  nullable: true }
                          fields: { $ref: "#/components/schemas/PbcoreFields" }

  /api/video.pbcorefieldssave.json:
    post:
      tags: [video]
      summary: PBCore-Struktur speichern (JSON-Body)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [id, fields]
              properties:
                id:     { type: string, format: uuid }
                fields: { $ref: "#/components/schemas/PbcoreFields" }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.pbcorevalidate.json:
    post:
      tags: [video]
      summary: PBCore-XML validieren
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties: { id: { type: string, format: uuid } }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.captionupload.json:
    post:
      tags: [video]
      summary: WebVTT-Untertitel/Kapitel hochladen (max 2 MiB)
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [id, kind, lang, file]
              properties:
                id:   { type: string, format: uuid }
                kind: { type: string, enum: [captions, chapters] }
                lang: { type: string, pattern: "^[a-z]{2,8}$" }
                file: { type: string, format: binary, description: "WebVTT-Datei (.vtt)" }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.captiondelete.json:
    post:
      tags: [video]
      summary: Untertitel/Kapitel löschen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id, kind, lang]
              properties:
                id:   { type: string, format: uuid }
                kind: { type: string, enum: [captions, chapters] }
                lang: { type: string, pattern: "^[a-z]{2,8}$" }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.captioncontent.json:
    post:
      tags: [video]
      summary: VTT-Inhalt für den Editor abrufen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id, kind, lang]
              properties:
                id:   { type: string, format: uuid }
                kind: { type: string, enum: [captions, chapters] }
                lang: { type: string, pattern: "^[a-z]{2,8}$" }
      responses:
        "200":
          description: Envelope mit `{content, exists}`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          content: { type: string, description: "WebVTT-Text" }
                          exists:  { type: boolean }

  /api/video.captionsave.json:
    post:
      tags: [video]
      summary: VTT-Inhalt direkt schreiben
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id, kind, lang, content]
              properties:
                id:      { type: string, format: uuid }
                kind:    { type: string, enum: [captions, chapters] }
                lang:    { type: string, pattern: "^[a-z]{2,8}$" }
                content: { type: string, description: "WebVTT-Text (muss mit `WEBVTT` beginnen)" }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  # ---------- Chunked Upload ----------

  /api/video.uploadstart.json:
    post:
      tags: [upload]
      summary: Upload-Session starten
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [pool_id, filename, size, total_chunks, chunk_size]
              properties:
                pool_id:      { type: string, format: uuid }
                filename:     { type: string }
                size:         { type: integer, minimum: 1 }
                total_chunks: { type: integer, minimum: 1 }
                chunk_size:   { type: integer, minimum: 1 }
      responses:
        "200":
          description: Envelope mit `{upload_id, received_chunks}`.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          upload_id:        { type: string, format: uuid }
                          received_chunks:  { type: array, items: { type: integer } }

  /api/video.uploadchunk.json:
    post:
      tags: [upload]
      summary: Ein Chunk hochladen
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [upload_id, index, file]
              properties:
                upload_id: { type: string, format: uuid }
                index:     { type: integer, minimum: 0 }
                file:      { type: string, format: binary }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.uploadstatus.json:
    post:
      tags: [upload]
      summary: Empfangene Chunks abfragen (Resume)
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [upload_id]
              properties:
                upload_id: { type: string, format: uuid }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/video.uploadfinish.json:
    post:
      tags: [upload]
      summary: Chunks mergen, MD5 prüfen, Video anlegen
      description: |
        Merged alle Chunks, berechnet MD5. Existiert bereits ein Video mit
        identischem Inhalt (`original_file = <md5>.<ext>`), wird der Upload
        verworfen und ein Fehler geliefert (kein DB-Eintrag).
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [upload_id]
              properties:
                upload_id: { type: string, format: uuid }
      responses:
        "200":
          description: Envelope mit neuer Video-ID.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          id:                 { type: string, format: uuid }
                          name:               { type: string }
                          pool_id:            { type: string, format: uuid }
                          original_file:      { type: string }
                          original_file_name: { type: string }
                          stored_as:          { type: string }
                          size:               { type: integer }

  /api/video.uploadcancel.json:
    post:
      tags: [upload]
      summary: Upload-Session abbrechen und Chunks löschen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [upload_id]
              properties:
                upload_id: { type: string, format: uuid }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  # ---------- Profile ----------

  /api/profile.updatename.json:
    post:
      tags: [profile]
      summary: Eigenen Anzeigenamen ändern
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, maxLength: 50 }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/profile.changepassword.json:
    post:
      tags: [profile]
      summary: Eigenes Passwort ändern
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [current_password, new_password]
              properties:
                current_password: { type: string, format: password }
                new_password:     { type: string, format: password, minLength: 8 }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/profile.generateapikey.json:
    post:
      tags: [profile]
      summary: Neuen API-Key generieren (alter wird ungültig)
      responses:
        "200":
          description: Envelope mit Klartext-Key (nur einmalig).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          ok:     { type: boolean }
                          apikey: { type: string, description: "Klartext, wird nur jetzt zurückgegeben" }

  /api/profile.revokeapikey.json:
    post:
      tags: [profile]
      summary: Eigenen API-Key entfernen
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  # ---------- Admin: UserManager ----------

  /api/usermanager.list.json:
    post:
      tags: [admin]
      summary: Alle User auflisten
      responses:
        "200":
          description: Envelope mit User-Liste.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          rows: { type: array, items: { $ref: "#/components/schemas/UserListEntry" } }

  /api/usermanager.getone.json:
    post:
      tags: [admin]
      summary: Detail eines Users
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties: { id: { type: string, format: uuid } }
      responses:
        "200":
          description: Envelope mit User + Pools + Stats + Audit.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          user:       { $ref: "#/components/schemas/User" }
                          pools:      { type: array, items: { $ref: "#/components/schemas/Pool" } }
                          pool_stats: { type: array, items: { $ref: "#/components/schemas/PoolStat" } }
                          audit:      { type: array, items: { $ref: "#/components/schemas/AuditEntry" } }

  /api/usermanager.create.json:
    post:
      tags: [admin]
      summary: User anlegen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [name, email, password]
              properties:
                name:       { type: string,  maxLength: 50 }
                email:      { type: string,  format: email }
                password:   { type: string,  format: password, minLength: 8 }
                is_admin:   { type: boolean }
                is_enabled: { type: boolean }
                pool_ids:
                  type: array
                  items: { type: string, format: uuid }
                  description: "Optionale Pool-Zuordnung beim Anlegen"
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/usermanager.update.json:
    post:
      tags: [admin]
      summary: User-Stammdaten ändern
      description: Eigener Account kann nicht auf `is_enabled=0` gesetzt werden.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties:
                id:         { type: string, format: uuid }
                name:       { type: string, maxLength: 50 }
                email:      { type: string, format: email }
                is_admin:   { type: boolean }
                is_enabled: { type: boolean }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/usermanager.setpassword.json:
    post:
      tags: [admin]
      summary: Passwort eines Users zurücksetzen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id, password]
              properties:
                id:       { type: string, format: uuid }
                password: { type: string, format: password, minLength: 8 }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/usermanager.setpools.json:
    post:
      tags: [admin]
      summary: Pool-Zuordnung eines Users ersetzen
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [id]
              properties:
                id: { type: string, format: uuid }
                pool_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        "200": { $ref: "#/components/responses/JsonResponse" }

  /api/usermanager.poolsall.json:
    post:
      tags: [admin]
      summary: Alle Pools für Edit-UIs
      responses:
        "200":
          description: Envelope mit Pool-Liste.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    properties:
                      result:
                        type: object
                        properties:
                          rows: { type: array, items: { $ref: "#/components/schemas/Pool" } }
