openapi: 3.1.0
info:
  title: FlickList API
  version: "3.0.0"
  summary: FlickList's public developer surface — a TMDB-shaped catalog plus a Trakt-shaped per-user sync API.
  description: |
    This document covers the `/v3` namespace of the FlickList API: the TMDB-compatible catalog
    surface, the Trakt-shaped per-user sync surface, and the device-code authentication flow that
    issues the tokens both surfaces accept.

    `/v3` is FlickList's stable, additive-only developer surface. Everything documented here is
    live in production today — nothing in this file describes planned or in-progress work.

    See the guide sections on the developer page for a walkthrough of authentication, the `ids`
    object, pagination, and rate limits. This document is the machine-readable reference; the
    guide is the narrative one.
  contact:
    name: FlickList
    url: https://flicklist.tv/dev
  license:
    name: Terms of use
    url: https://flicklist.tv/dev/terms
externalDocs:
  description: FlickList Developer Guide
  url: https://flicklist.tv/dev
servers:
  - url: https://flicklist.tv/api/v3
    description: Production — Catalog, Sync, and Identity endpoints
  - url: https://flicklist.tv/api
    description: Production — auth endpoints (device-code login and refresh), one level above /v3 (Authentication tag)
tags:
  - name: Authentication
    description: >-
      Device-code login (RFC 8628-style) and session refresh. These endpoints live outside the
      `/v3` namespace, at `/api/auth/device/*` and `/api/auth/refresh` — they issue the session
      token that every other endpoint on this page accepts.
  - name: Catalog
    description: >-
      TMDB-shaped read endpoints backed entirely by FlickList's own database. Point an existing
      TMDB client at this base URL with a FlickList credential in the slot where its TMDB key
      went (the `api_key` query parameter works exactly like TMDB's) and it keeps working — this
      is the surface existing Kodi catalog addons use today. A credential is required;
      these endpoints are not anonymous, same as TMDB's own v3.
  - name: Sync
    description: >-
      Trakt-shaped per-user endpoints — watched history, playback progress, watchlist, ratings,
      favorites, lists, Up Next, tracked shows, and activity timestamps, both read and write.
      Every media object carries the shared `ids` block described in the guide; every write item
      identifies media the same way, through the `ids` input block covered in the guide's
      ids-object section. Write endpoints require the `write` scope on an API key credential
      (session tokens are unrestricted).
  - name: Scrobble
    description: >-
      Real-time playback lifecycle — `start`, `pause`, and `stop` — the mechanism FlickList's own
      Kodi and Plex-ecosystem scrobbler apps use to report progress and auto-mark things watched.
      See the guide's Scrobbling section for the watched threshold and heartbeat cadence guidance.
  - name: Lists
    description: >-
      Public, read-only list discovery and consumption: browse and search community lists, read
      any public (or your own, of any privacy) list by numeric id, and see one user's published
      lists. No credential is required for the pure-discovery endpoints. To create, delete, or
      edit your own lists, use the `/sync/lists*` write endpoints under the Sync tag.
  - name: Identity
    description: Who the calling credential belongs to.
  - name: Calendar
    description: >-
      Date-ranged airing schedules — a global feed across the whole catalog, and a personalized
      feed scoped to shows the user tracks, watchlists, or has watch history for. No 33-day cap
      the way Trakt's calendar has; FlickList's only limit is a 180-day performance ceiling.

# ─── Shared parameter/security groups (YAML anchors — must precede first use) ─
x-list-params: &listParams
  - $ref: '#/components/parameters/ApiKeyQuery'
  - $ref: '#/components/parameters/PageQuery'
x-catalog-security: &catalogSecurity
  - SessionToken: []
  - ApiKeyBearer: []
  - ApiKeyHeader: []
x-public-security: &publicSecurity
  - SessionToken: []
  - ApiKeyBearer: []
  - ApiKeyHeader: []
  - {}
x-sync-security: &syncSecurity
  - SessionToken: []
  - ApiKeyBearer: []
  - ApiKeyHeader: []

paths:
  # ─── Authentication (outside /v3, see server override below) ─────────
  /auth/device/code:
    servers:
      - url: https://flicklist.tv/api
    post:
      tags: [Authentication]
      operationId: requestDeviceCode
      summary: Start a device-code login
      description: >-
        Step 1 of the device-code flow. A device or CLI app that can't open a browser sign-in
        form requests a code pair here, shows the `user_code` to the person using it, and starts
        polling `/auth/device/token`. Requires no authentication — `client_id` must already be a
        registered app; unregistered client IDs are rejected. FlickList does not currently expose
        self-serve app registration — contact FlickList to register a `client_id` before building
        against this endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id]
              properties:
                client_id:
                  type: string
                  maxLength: 64
                  description: Your app's registered client identifier.
                  example: fl_kodi_scrobbler
      security: []
      responses:
        '200':
          description: Code pair issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  user_code:
                    type: string
                    description: 8-character code for the person to type in at `verification_uri`.
                    example: A1B2C3D4
                  device_code:
                    type: string
                    format: uuid
                    description: Opaque code your app polls `/auth/device/token` with. Never shown to the user.
                  verification_uri:
                    type: string
                    format: uri
                    description: Page where the person enters `user_code`.
                    example: https://flicklist.tv/link
                  expires_in:
                    type: integer
                    description: Seconds until the code pair expires.
                    example: 900
                  interval:
                    type: integer
                    description: Minimum seconds to wait between polls of `/auth/device/token`.
                    example: 5
        '400':
          $ref: '#/components/responses/BadRequest'
  /auth/device/token:
    servers:
      - url: https://flicklist.tv/api
    post:
      tags: [Authentication]
      operationId: pollDeviceToken
      summary: Poll for device-code authorization
      description: >-
        Step 3 of the device-code flow. Poll no more often than the `interval` returned by
        `/auth/device/code`. This endpoint returns **HTTP 200 for every state, including
        `authorization_pending`** — check the response body's `error` field, not the status code,
        to tell pending from authorized. Only an unrecognized `device_code` produces a non-200
        response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [device_code]
              properties:
                device_code:
                  type: string
                  format: uuid
      security: []
      responses:
        '200':
          description: >-
            Current state of the code pair. `error` is present while pending, denied, or expired;
            `access_token` is present once the person has authorized the device.
          content:
            application/json:
              schema:
                anyOf:
                  - $ref: '#/components/schemas/DevicePollPending'
                  - $ref: '#/components/schemas/DevicePollAuthorized'
              examples:
                pending:
                  value:
                    error: authorization_pending
                    error_description: The user has not yet authorized this device.
                authorized:
                  value:
                    access_token: 9f2c...redacted
                    token: 9f2c...redacted
                    user:
                      id: 6dd59ecd-d028-4710-9498-bf3729269e2a
                      username: kodifitzwell
                      email: dev@example.com
                      display_name: null
                      avatar_url: null
                      role_id: 3
                      role_level: 3
                      is_staff: false
                      is_system: false
                      email_verified: true
                      has_password: true
                      created_at: "2026-01-04T18:22:41.000Z"
                      timezone: America/Chicago
                    expires_at: "2026-08-30T12:00:00Z"
        '400':
          description: Unrecognized `device_code`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /auth/device/authorize:
    servers:
      - url: https://flicklist.tv/api
    post:
      tags: [Authentication]
      operationId: authorizeDeviceCode
      summary: Approve a pending device code
      description: >-
        Step 2 of the device-code flow. Called by a browser that already holds a FlickList
        session — in practice this is `flicklist.tv/link`, where a signed-in person types in the
        `user_code` shown on their device. Third-party apps don't call this directly; it's
        documented here so the full flow is traceable end to end.
      security:
        - SessionToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_code]
              properties:
                user_code:
                  type: string
                  description: The 8-character code shown on the device. Case-insensitive; spaces and dashes are stripped.
                device_name:
                  type: string
                  maxLength: 100
                  description: Optional friendly label for the session (e.g. "Living Room TV").
      responses:
        '200':
          description: Device authorized.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  client_id:
                    type: string
                  device_name:
                    type: string
                  message:
                    type: string
                    example: Device authorized successfully. You can close this page.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /auth/refresh:
    servers:
      - url: https://flicklist.tv/api
    post:
      tags: [Authentication]
      operationId: refreshSession
      summary: Rotate a session token
      description: >-
        Exchanges a valid, non-expired session token for a new one with a fresh 30-day
        expiration, and invalidates the old token (rotation, not extension-in-place). Session
        tokens only — an `fs_live_` API key sent here returns 400, since API keys carry their own
        independent expiration and aren't refreshed through this endpoint.
      security:
        - SessionToken: []
      responses:
        '200':
          description: New session issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  # ─── Catalog ───────────────────────────────────────────────────────
  /movie/{tmdb_id}:
    get:
      tags: [Catalog]
      operationId: getMovie
      summary: Movie detail
      parameters:
        - $ref: '#/components/parameters/TmdbIdPath'
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/AppendToResponse'
      responses:
        '200':
          description: Movie detail, TMDB `movie_details` shape.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MovieDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /tv/{tmdb_id}:
    get:
      tags: [Catalog]
      operationId: getShow
      summary: TV show detail
      parameters:
        - $ref: '#/components/parameters/TmdbIdPath'
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/AppendToResponse'
      responses:
        '200':
          description: TV show detail, TMDB `tv_details` shape.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TvDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /tv/{tmdb_id}/season/{season_number}:
    get:
      tags: [Catalog]
      operationId: getSeason
      summary: Season detail with episodes
      parameters:
        - $ref: '#/components/parameters/TmdbIdPath'
        - name: season_number
          in: path
          required: true
          schema: { type: integer }
        - $ref: '#/components/parameters/ApiKeyQuery'
        - name: append_to_response
          in: query
          schema: { type: string }
          description: Only `credits` has an effect here — adds `guest_stars` to each episode.
      responses:
        '200':
          description: Season detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SeasonDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /movie/popular:
    get:
      tags: [Catalog]
      operationId: getPopularMovies
      summary: Popular movies
      description: All-time popularity ranking, distinct from the daily `/trending` snapshot.
      parameters: *listParams
      responses: &mediaListResponses
        '200':
          description: Paginated movie/show cards.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaListPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /movie/now_playing:
    get:
      tags: [Catalog]
      operationId: getNowPlayingMovies
      summary: Movies currently in theaters
      parameters: *listParams
      responses: *mediaListResponses
      security: *catalogSecurity
  /movie/upcoming:
    get:
      tags: [Catalog]
      operationId: getUpcomingMovies
      summary: Upcoming movies
      description: >-
        Returns only what's genuinely upcoming — no fallback to trending data when the result set
        is short, so pagination ends cleanly instead of silently mixing in unrelated titles.
      parameters: *listParams
      responses: *mediaListResponses
      security: *catalogSecurity
  /tv/popular:
    get:
      tags: [Catalog]
      operationId: getPopularShows
      summary: Popular TV shows
      parameters: *listParams
      responses: *mediaListResponses
      security: *catalogSecurity
  /tv/airing_today:
    get:
      tags: [Catalog]
      operationId: getAiringTodayShows
      summary: Shows airing today
      parameters: *listParams
      responses: *mediaListResponses
      security: *catalogSecurity
  /tv/on_the_air:
    get:
      tags: [Catalog]
      operationId: getOnTheAirShows
      summary: Shows currently on the air
      parameters: *listParams
      responses: *mediaListResponses
      security: *catalogSecurity
  /trending/{media_type}/{window}:
    get:
      tags: [Catalog]
      operationId: getTrending
      summary: Trending movies or shows
      description: The short daily/weekly snapshot — deliberately distinct from `popular`, which is all-time.
      parameters:
        - name: media_type
          in: path
          required: true
          schema: { type: string, enum: [movie, tv] }
        - name: window
          in: path
          required: true
          schema: { type: string, enum: [day, week] }
          description: Accepted for TMDB-path compatibility. Both values return the same daily snapshot today.
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/PageQuery'
      responses: *mediaListResponses
      security: *catalogSecurity
  /discover/movie:
    get:
      tags: [Catalog]
      operationId: discoverMovies
      summary: Filtered movie discovery
      parameters: &discoverParams
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/PageQuery'
        - name: with_genres
          in: query
          schema: { type: string }
          description: Comma-separated genre IDs.
        - name: with_watch_providers
          in: query
          schema: { type: string }
          description: Comma-separated provider IDs.
        - name: with_original_language
          in: query
          schema: { type: string }
        - name: primary_release_year
          in: query
          schema: { type: integer }
          description: Exact year. Takes priority over the `.gte`/`.lte` range params below.
        - name: primary_release_date.gte
          in: query
          schema: { type: string }
        - name: primary_release_date.lte
          in: query
          schema: { type: string }
        - name: release_date.gte
          in: query
          schema: { type: string }
        - name: release_date.lte
          in: query
          schema: { type: string }
        - name: first_air_date.gte
          in: query
          schema: { type: string }
        - name: first_air_date.lte
          in: query
          schema: { type: string }
        - name: first_air_date_year
          in: query
          schema: { type: integer }
        - name: sort_by
          in: query
          schema: { type: string }
          description: >-
            TMDB-style sort key. Recognized prefixes: `vote_average*` → rating, anything
            containing `date` → year, `original_title*` → title. Anything else falls back to
            popularity.
        - name: with_keywords
          in: query
          schema: { type: string }
          description: A single keyword string, not TMDB keyword IDs.
        - name: with_companies
          in: query
          schema: { type: string }
          description: Only used when `with_keywords` is empty; matched as a company-name filter.
      responses: &discoverResponses
        '200':
          description: Paginated, exact-count movie/show cards.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MediaListPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /discover/tv:
    get:
      tags: [Catalog]
      operationId: discoverShows
      summary: Filtered TV discovery
      parameters: *discoverParams
      responses: *discoverResponses
      security: *catalogSecurity
  /search/multi:
    get:
      tags: [Catalog]
      operationId: searchMulti
      summary: Search movies and shows
      parameters: &searchParams
        - $ref: '#/components/parameters/ApiKeyQuery'
        - name: query
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/PageQuery'
      responses: &searchResponses
        '200':
          description: Up to 20 matches. See `SearchPage` for pagination caveats.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /search/movie:
    get:
      tags: [Catalog]
      operationId: searchMovies
      summary: Search movies
      parameters: *searchParams
      responses: *searchResponses
      security: *catalogSecurity
  /search/tv:
    get:
      tags: [Catalog]
      operationId: searchShows
      summary: Search TV shows
      parameters: *searchParams
      responses: *searchResponses
      security: *catalogSecurity
  /find/{external_id}:
    get:
      tags: [Catalog]
      operationId: findByExternalId
      summary: Look up a title by external ID
      description: >-
        The flagship cross-source lookup. Accepts an IMDb ID, a TVDB ID, or FlickList's own
        `fldb` ID directly on the path — a `flm_`/`flt_`-prefixed ID is detected automatically
        and resolved without needing `external_source` at all.
      parameters:
        - name: external_id
          in: path
          required: true
          schema: { type: string }
          examples:
            imdb: { value: tt0111161 }
            tvdb: { value: "78804" }
            fldb: { value: flm_7d3a95e0 }
        - $ref: '#/components/parameters/ApiKeyQuery'
        - name: external_source
          in: query
          schema: { type: string, enum: [imdb_id, tvdb_id] }
          description: >-
            Required for IMDb/TVDB lookups. Omit entirely for `flm_`/`flt_` FlickList IDs — the
            prefix alone is enough to resolve them.
      responses:
        '200':
          description: >-
            Zero or one match per bucket. `person_results` always returns empty — FlickList's
            `/v3/find` doesn't resolve people yet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FindResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /movie/{tmdb_id}/recommendations:
    get:
      tags: [Catalog]
      operationId: getMovieRecommendations
      summary: Similar movies
      parameters:
        - $ref: '#/components/parameters/TmdbIdPath'
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/PageQuery'
      responses: &recommendationsResponses
        '200':
          description: >-
            Similar titles. `page` and `total_pages` are always `1` regardless of the `page`
            query param — this endpoint returns one unpaginated batch.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecommendationsPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /tv/{tmdb_id}/recommendations:
    get:
      tags: [Catalog]
      operationId: getShowRecommendations
      summary: Similar TV shows
      parameters:
        - $ref: '#/components/parameters/TmdbIdPath'
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/PageQuery'
      responses: *recommendationsResponses
      security: *catalogSecurity
  /collection/{collection_id}:
    get:
      tags: [Catalog]
      operationId: getCollection
      summary: Movie collection (franchise) detail
      parameters:
        - name: collection_id
          in: path
          required: true
          schema: { type: integer }
        - $ref: '#/components/parameters/ApiKeyQuery'
      responses:
        '200':
          description: Collection with its member movies.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CollectionDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /network/{network_id}:
    get:
      tags: [Catalog]
      operationId: getNetwork
      summary: TV network detail
      description: Thin lookup — currently name only. `logo_path` is always `null` and `origin_country` is always `""`.
      parameters:
        - name: network_id
          in: path
          required: true
          schema: { type: integer }
        - $ref: '#/components/parameters/ApiKeyQuery'
      responses:
        '200':
          description: Network name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NetworkDetail'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /person/popular:
    get:
      tags: [Catalog]
      operationId: getPopularPeople
      summary: Popular people
      description: Filtered to people with at least one credit on a non-adult title.
      parameters:
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/PageQuery'
      responses: &peopleResponses
        '200':
          description: Paginated people.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PeoplePage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *catalogSecurity
  /trending/person/{window}:
    get:
      tags: [Catalog]
      operationId: getTrendingPeople
      summary: Trending people
      description: Currently an alias of `/person/popular` — both windows return the same ranking.
      parameters:
        - name: window
          in: path
          required: true
          schema: { type: string, enum: [day, week] }
        - $ref: '#/components/parameters/ApiKeyQuery'
        - $ref: '#/components/parameters/PageQuery'
      responses: *peopleResponses
      security: *catalogSecurity

  # ─── Scrobble ──────────────────────────────────────────────────────
  /scrobble/start:
    post:
      tags: [Scrobble]
      operationId: startScrobble
      summary: Start or resume playback tracking
      description: >-
        Call once when playback begins (or resumes after a seek). Upserts a resume point for the
        item and, as a side effect, resolves any of the caller's other stale in-progress sessions
        — siblings at >=90% get auto-marked watched, the rest get their resume point persisted.
        The resolved catalog item must carry a TMDB ID; see the guide's Scrobbling section for
        why. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScrobbleWriteRequest' }
      responses:
        '200':
          description: Playback tracking started.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScrobbleStartResponse' }
        '400':
          description: >-
            Malformed request, missing `write` scope, or the resolved item has no TMDB ID (see
            the guide's Scrobbling section — scrobble and playback both require a TMDB-linked
            catalog row).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The `ids` block didn't resolve to any catalog item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /scrobble/pause:
    post:
      tags: [Scrobble]
      operationId: pauseScrobble
      summary: Pause playback tracking
      description: >-
        Marks an existing, currently-active resume point as paused. Never creates a new resume
        point — if there's no active (non-paused) session matching this item, `id` comes back
        `null` and nothing is written. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScrobbleWriteRequest' }
      responses:
        '200':
          description: Playback tracking paused (or a no-op, if nothing was active).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScrobblePauseResponse' }
        '400':
          description: Malformed request, missing `write` scope, or the resolved item has no TMDB ID.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The `ids` block didn't resolve to any catalog item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /scrobble/stop:
    post:
      tags: [Scrobble]
      operationId: stopScrobble
      summary: Stop playback tracking
      description: >-
        Call when playback ends, whether that's a natural finish, a user-initiated stop, or your
        app shutting down. `progress` >= 90% (the `WATCHED_THRESHOLD`) and the session lasting at
        least the greater of 300 seconds or 15% of the title's runtime marks the item watched;
        below the time floor at >=90% progress records a `preview` instead of `watched` (both
        clear the resume point); below 90% keeps the resume point as `partial`. A stop reported at
        under 0.5% progress on a session that never recorded any progress is treated as a phantom
        event and discarded outright. See the guide's Scrobbling section for the full state
        machine. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScrobbleWriteRequest' }
      responses:
        '200':
          description: Playback tracking stopped; see `watch_status` for the outcome.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScrobbleStopResponse' }
        '400':
          description: Malformed request, missing `write` scope, or the resolved item has no TMDB ID.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The `ids` block didn't resolve to any catalog item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity

  # ─── Sync ──────────────────────────────────────────────────────────
  /sync/watched/shows:
    get:
      tags: [Sync]
      operationId: getWatchedShows
      summary: All watched shows, nested by season and episode
      description: >-
        One roll-up call per user — no pagination. Only counts a show/episode watched under
        FlickList's `watch_status = 'watched'` rule; skips specials (season 0) and anything the
        user explicitly hid. `reset_at` is reserved for a future rewatch-reset feature and is
        always `null` today.
      responses:
        '200':
          description: Watched shows, newest-watched first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WatchedShow' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/watched/movies:
    get:
      tags: [Sync]
      operationId: getWatchedMovies
      summary: All watched movies with play counts
      responses:
        '200':
          description: Watched movies, newest-watched first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/WatchedMovie' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/playback:
    get:
      tags: [Sync]
      operationId: getPlayback
      summary: Active resume points
      description: In-progress playback the user hasn't finished or abandoned.
      responses:
        '200':
          description: Resume points.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncPlayback' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: upsertPlayback
      summary: Upsert a resume point
      description: >-
        Directly write (or overwrite) a single resume point, without the stale-sibling cleanup
        `/scrobble/start` performs. Most integrations want the scrobble lifecycle instead — this
        is for apps managing playback progress independently of it. Requires a TMDB-linked
        catalog row, same as scrobble. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PlaybackWriteRequest' }
      responses:
        '200':
          description: Resume point written.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlaybackUpsertResponse' }
        '400':
          description: Malformed request, missing `write` scope, or the resolved item has no TMDB ID.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The `ids` block didn't resolve to any catalog item.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/playback/{id}:
    delete:
      tags: [Sync]
      operationId: deletePlayback
      summary: Delete a resume point
      description: >-
        Deletes one resume point by its own id (from `GET /sync/playback`'s response, or the `id`
        a prior scrobble/playback write returned) — not by media identity. Owner-checked: deleting
        another user's resume point id 404s, same as a nonexistent one. Requires the `write` scope
        on an API key credential.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
          description: The `playback_progress` row id.
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/favorites:
    get:
      tags: [Sync]
      operationId: getFavorites
      summary: The user's favorites
      responses:
        '200':
          description: Favorites, newest-favorited first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncFavorite' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: addFavorites
      summary: Bulk add favorites
      description: >-
        Batch of up to 1000 items. Upsert semantics — an item already favorited still counts as
        `added` in the response, same idempotent-success convention as `addWatchlistItems`.
        Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FavoritesBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkAddResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    delete:
      tags: [Sync]
      operationId: removeFavorites
      summary: Bulk remove favorites
      description: Batch of up to 1000 items. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FavoritesBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkRemoveResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/history:
    get:
      tags: [Sync]
      operationId: getHistory
      summary: Flat, paginated watch history
      description: >-
        One row per watch event (a rewatch produces a second row), newest first — unlike
        `/sync/watched/shows`, which rolls up into one entry per episode. Only rows that matched
        a catalog title are returned.
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: One page of history events.
          headers:
            X-FlickList-Page:
              description: Echoes the requested page.
              schema: { type: integer }
            X-FlickList-Limit:
              description: Echoes the effective page size (clamped to 1-100).
              schema: { type: integer }
            X-FlickList-Page-Count:
              description: Total pages available at this `limit`.
              schema: { type: integer }
            X-FlickList-Item-Count:
              description: Total matching events across all pages.
              schema: { type: integer }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/HistoryEvent' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: addHistory
      summary: Bulk mark watched
      description: >-
        Batch of up to 1000 items (`items` array; a longer batch is a 400). Each item resolves
        independently — an item whose `ids` block doesn't resolve, or whose `season`/`episode`
        doesn't match a real episode, lands in the response's `not_found` array rather than
        failing the whole request. `watched_at` defaults to now; a timestamp more than 5 minutes
        in the future is a 400 for the whole batch. Requires the `write` scope on an API key
        credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HistoryBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkAddResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    delete:
      tags: [Sync]
      operationId: removeHistory
      summary: Bulk unmark watched
      description: >-
        Batch of up to 1000 items. Hides (never deletes) the matching `watch_history` rows —
        unmarking is reversible in FlickList's own data model even though this endpoint doesn't
        expose an undo. Same per-item resolution/`not_found` behavior as the `POST` above.
        Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/HistoryBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkRemoveResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/watchlist:
    get:
      tags: [Sync]
      operationId: getWatchlist
      summary: The user's watchlist
      responses:
        '200':
          description: Watchlist entries.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncWatchlistItem' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: addWatchlistItems
      summary: Bulk add to watchlist
      description: >-
        Batch of up to 1000 items, added as `plan_to_watch`. Upsert semantics — an item already on
        the watchlist still counts as `added` in the response, since the call succeeded
        idempotently. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WatchlistBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkAddResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    delete:
      tags: [Sync]
      operationId: removeWatchlistItems
      summary: Bulk remove from watchlist
      description: Batch of up to 1000 items. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WatchlistBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkRemoveResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/ratings:
    get:
      tags: [Sync]
      operationId: getRatings
      summary: The user's ratings
      responses:
        '200':
          description: Ratings.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncRating' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: addRatings
      summary: Bulk rate
      description: >-
        Batch of up to 1000 items. Unlike the other bulk write endpoints, validation runs over the
        whole batch before anything is written: every `rating` must be 0.5-10 (half-point
        increments) and any item with `episode` set must also set `season` — either violation
        fails the entire request with a 400 rather than partially applying it. Per-item
        `ids`-resolution failures still land in `not_found` as usual. Requires the `write` scope
        on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RatingAddRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkAddResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    delete:
      tags: [Sync]
      operationId: removeRatings
      summary: Bulk unrate
      description: Batch of up to 1000 items. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MediaIdentBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BulkRemoveResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/lists:
    get:
      tags: [Sync]
      operationId: getLists
      summary: The user's custom lists (index)
      description: Metadata only — no items. Fetch `/sync/lists/{id}/items` per list for contents.
      responses:
        '200':
          description: Lists.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncList' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: createList
      summary: Create a list
      description: >-
        Every list `/v3` creates is a plain manual list — there's no way to create a smart
        (rule-based) list through this API; `is_smart` is always `false` on the result. Requires
        the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListCreateRequest' }
      responses:
        '201':
          description: List created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncList' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/lists/{id}:
    delete:
      tags: [Sync]
      operationId: deleteList
      summary: Delete a list
      description: >-
        Owner-checked: a nonexistent list and a list you don't own both 404, so existence isn't
        leaked. Cascades to the list's items. Requires the `write` scope on an API key credential.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/lists/{id}/items:
    get:
      tags: [Sync]
      operationId: getListItems
      summary: Items of one of the user's own lists
      description: >-
        Strictly the caller's own lists — unlike some internal endpoints, there's no
        public/unlisted-list exception here. Smart lists resolve their rule set live; the rules
        themselves aren't exposed on `/v3`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: List items, in list order.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncListItem' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: addListItems
      summary: Batch add items to a list
      description: >-
        Batch of up to 1000 items. Owner-checked and smart-list-guarded — a list you don't own, a
        nonexistent list, or a smart list (which has no manually-editable items) all 400/404
        before any item is processed. Splits successes into `added` (new to the list) vs.
        `existing` (already there — still a success, not a `not_found`) so a re-sync doesn't read
        as a failure. Requires the `write` scope on an API key credential.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListItemsBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListItemsAddResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    delete:
      tags: [Sync]
      operationId: removeListItems
      summary: Batch remove items from a list
      description: >-
        Batch of up to 1000 items, matched by media identity (plus season/episode for
        episode-level entries) rather than by list-item id. Same owner/smart-list guard as the
        `POST` above. An item not currently on the list lands in `not_found`, same as an
        unresolvable `ids` block. Requires the `write` scope on an API key credential.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ListItemsBulkRequest' }
      responses:
        '200':
          description: Batch result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ListItemsRemoveResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity

  # ─── Sync: activity, Up Next, tracked shows ──────────────────────────
  /sync/last_activities:
    get:
      tags: [Sync]
      operationId: getLastActivities
      summary: Activity timestamps for change detection
      description: >-
        A conservative "what changed since I last synced" summary — one timestamp per data
        category (`movies`/`episodes` each split into `watched_at`/`paused_at`/`watchlisted_at`,
        plus `shows.watchlisted_at`, `lists.updated_at`, and `favorites`), plus an overall `all`
        timestamp that's the max of everything. A client polling this can skip refetching a
        category whose timestamp hasn't advanced since its last check. Every timestamp defaults to
        the Unix epoch when a user has no data in that category yet, never `null` — there's always
        a timestamp to compare against. Identical in content to the internal `GET
        /sync/last-activities`; only the path's underscore (vs. the internal endpoint's hyphen)
        differs, matching every other /v3 path segment's snake_case convention.
      responses:
        '200':
          description: Activity timestamps.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncLastActivities' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/up_next:
    get:
      tags: [Sync]
      operationId: getUpNext
      summary: Computed next-unwatched-episode per show
      description: >-
        One entry per show the user has started watching or is currently streaming, each carrying
        its next unwatched episode (when known) plus progress context. Unlike the internal `GET
        /up-next` (which excludes shows the user dropped from Up Next unless the caller passes
        `?include_dropped=true`), this endpoint always includes dropped shows and flags them via
        `dropped` instead — a flag with no meaning if it were always absent by default.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 40 }
      responses:
        '200':
          description: Up Next items.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncUpNextItem' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/up_next/drop:
    post:
      tags: [Sync]
      operationId: dropUpNext
      summary: Drop a show from Up Next
      description: >-
        Hides one show from `GET /sync/up_next` (it still shows up with `dropped: true`) without
        touching watch history. Requires the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SingleMediaRequest' }
      responses:
        '200':
          description: Dropped.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DroppedResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/up_next/undrop:
    post:
      tags: [Sync]
      operationId: undropUpNext
      summary: Restore a dropped show to Up Next
      description: >-
        Reverses `POST /sync/up_next/drop`. 404 if the resolved item isn't currently dropped (as
        opposed to never having been on Up Next at all — both look the same from here). Requires
        the `write` scope on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SingleMediaRequest' }
      responses:
        '200':
          description: Restored.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DroppedResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The `ids` block didn't resolve, or the show isn't currently dropped.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
  /sync/tracked:
    get:
      tags: [Sync]
      operationId: getTrackedShows
      summary: The user's tracked shows
      description: >-
        Unbounded, like `/sync/favorites` and `/sync/ratings` — a tracked-show list is small by
        nature. Tracking a show is independent of watchlisting or watching it; it's purely "notify
        me / surface this in my calendar and Up Next."
      responses:
        '200':
          description: Tracked shows, most-recently-tracked first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncTrackedShow' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    post:
      tags: [Sync]
      operationId: addTrackedShow
      summary: Track a show
      description: >-
        Single item, not a batch — tracking is a one-off action, not a bulk-sync operation.
        Idempotent: tracking an already-tracked show is still a 200. Requires the `write` scope on
        an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SingleMediaRequest' }
      responses:
        '200':
          description: Tracked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrackedResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity
    delete:
      tags: [Sync]
      operationId: removeTrackedShow
      summary: Untrack a show
      description: >-
        Single item. 404 if the resolved item isn't currently tracked. Requires the `write` scope
        on an API key credential.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SingleMediaRequest' }
      responses:
        '200':
          description: Untracked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TrackedResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The `ids` block didn't resolve, or the show wasn't tracked.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity

  # ─── Calendar ─────────────────────────────────────────────────────────
  /calendar/shows/{start}/{days}:
    get:
      tags: [Calendar]
      operationId: getCalendarShows
      summary: Global airing schedule
      description: >-
        Every episode airing in the date range, grouped by date, across FlickList's whole catalog
        (filtered to shows above a small popularity floor) — not scoped to any user. No credential
        is used or checked — same anonymous-discovery convention as the Lists tag's `/lists/community`.
        Mirrors the internal `GET /calendar/shows/:start/:days`.
      parameters:
        - name: start
          in: path
          required: true
          schema: { type: string, format: date }
          description: "`YYYY-MM-DD`."
        - name: days
          in: path
          required: true
          schema: { type: integer, minimum: 1, maximum: 180 }
          description: >-
            Length of the range starting at `start`, 1-180 — values over 180 return `400`, not a
            silent clamp. FlickList doesn't impose Trakt's 33-day calendar cap; 180 is a
            performance ceiling, not a feature gate.
      responses:
        '200':
          description: Episodes airing in range, grouped by date.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncCalendarResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: []
  /calendar/my/shows/{start}/{days}:
    get:
      tags: [Calendar]
      operationId: getMyCalendarShows
      summary: Personalized airing schedule
      description: >-
        Episodes airing in the date range for shows the user tracks, has watchlisted (`watching`
        or `plan_to_watch`), or has any watch history for — no popularity floor, since these are
        shows the user already has a relationship with. Mirrors the internal `GET
        /calendar/my/shows/:start/:days`.
      parameters:
        - name: start
          in: path
          required: true
          schema: { type: string, format: date }
          description: "`YYYY-MM-DD`."
        - name: days
          in: path
          required: true
          schema: { type: integer, minimum: 1, maximum: 180 }
          description: "Length of the range starting at `start`, 1-180 — values over 180 return `400`."
      responses:
        '200':
          description: Episodes airing in range, grouped by date.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SyncCalendarResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity

  # ─── Lists (public read/discovery) ──────────────────────────────────
  /lists/{id}:
    get:
      tags: [Lists]
      operationId: getPublicList
      summary: Public list metadata, by numeric id
      description: >-
        Resolves any list by its bare numeric id — the lookup Kometa-style third-party tools use
        to build off a community list without needing a FlickList account. Serves `public` lists
        to anyone; `unlisted` and `private` lists are only served back to their own owner (send a
        credential to get the owner-override). A non-owner request for an unlisted/private list id
        gets a 404, not a 403 — existence is never leaked. This differs from FlickList's own
        website, where an unlisted list's `/username/slug` address is readable by anyone who has
        the link; the sequential integer id here would make every unlisted list enumerable by
        crawling ids in order, so `/v3` is stricter on purpose.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: List metadata, with the owner's username.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicSyncList' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *publicSecurity
  /lists/{id}/items:
    get:
      tags: [Lists]
      operationId: getPublicListItems
      summary: Public list items, by numeric id
      description: Same privacy/ownership rules as `GET /lists/{id}`. Paginated, unlike `/sync/lists/{id}/items` which returns a caller's own list in full.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: One page of list items, in list order.
          headers:
            X-FlickList-Page: { description: Echoes the requested page., schema: { type: integer } }
            X-FlickList-Limit: { description: Echoes the effective page size (clamped to 1-100)., schema: { type: integer } }
            X-FlickList-Page-Count: { description: Total pages available at this `limit`., schema: { type: integer } }
            X-FlickList-Item-Count: { description: Total items on the list., schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/SyncListItem' }
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *publicSecurity
  /lists/community:
    get:
      tags: [Lists]
      operationId: getCommunityLists
      summary: Browse public lists
      description: >-
        Every public, non-empty list on FlickList, most-recent-first by default. No credential is
        used or checked — this is anonymous discovery, same as browsing community lists on the
        website while signed out.
      parameters:
        - name: sort
          in: query
          schema: { type: string, enum: [recent, popular, biggest], default: recent }
          description: "`popular` orders by likes; `biggest` orders by item count. An unrecognized value is a 400, not a silent fallback."
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: One page of public lists.
          headers:
            X-FlickList-Page: { description: Echoes the requested page., schema: { type: integer } }
            X-FlickList-Limit: { description: Echoes the effective page size (clamped to 1-100)., schema: { type: integer } }
            X-FlickList-Page-Count: { description: Total pages available at this `limit`., schema: { type: integer } }
            X-FlickList-Item-Count: { description: Total matching lists across all pages., schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PublicSyncList' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: []
  /lists/search:
    get:
      tags: [Lists]
      operationId: searchLists
      summary: Search public lists
      description: >-
        Matches `q` against list name, description, and tags (case-insensitive substring), public
        and non-empty lists only. Exact name matches sort first, then by likes. `q` is required —
        unlike FlickList's internal search, which treats a blank query as "no results" with a
        200, this endpoint 400s instead so a caller doesn't mistake a bug for a genuinely empty
        result set. No credential is used or checked.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: Rejected with 400 if empty or all whitespace after trimming.
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: One page of matching public lists.
          headers:
            X-FlickList-Page: { description: Echoes the requested page., schema: { type: integer } }
            X-FlickList-Limit: { description: Echoes the effective page size (clamped to 1-100)., schema: { type: integer } }
            X-FlickList-Page-Count: { description: Total pages available at this `limit`., schema: { type: integer } }
            X-FlickList-Item-Count: { description: Total matching lists across all pages., schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PublicSyncList' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: []
  /lists/tags:
    get:
      tags: [Lists]
      operationId: getPopularTags
      summary: Popular tags across public lists
      description: Not paginated — a flat, ranked slice. No credential is used or checked.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        '200':
          description: Tags ranked by usage.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TagCount' }
        '429':
          $ref: '#/components/responses/RateLimited'
      security: []
  /users/{username}/lists:
    get:
      tags: [Lists]
      operationId: getUserPublicLists
      summary: One user's public lists
      description: >-
        Always that user's `public`-privacy lists only, regardless of caller — never unlisted or
        private, since no credential is used or checked. A nonexistent username is a 404. A real
        username whose profile privacy hides content is a 200 with an empty array, not a 404 —
        the account's existence is confirmed the same way FlickList's own public-profile page
        confirms it, just without content behind it.
      parameters:
        - name: username
          in: path
          required: true
          schema: { type: string }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      responses:
        '200':
          description: One page of the user's public lists.
          headers:
            X-FlickList-Page: { description: Echoes the requested page., schema: { type: integer } }
            X-FlickList-Limit: { description: Echoes the effective page size (clamped to 1-100)., schema: { type: integer } }
            X-FlickList-Page-Count: { description: Total pages available at this `limit`., schema: { type: integer } }
            X-FlickList-Item-Count: { description: Total matching lists across all pages., schema: { type: integer } }
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/PublicSyncList' }
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: []

  # ─── Identity ──────────────────────────────────────────────────────
  /me:
    get:
      tags: [Identity]
      operationId: getMe
      summary: Identity of the calling credential
      description: >-
        Deliberately narrower than the internal `/api/auth/me` — no email address, and no
        internal role/staff/verification flags. Use this to confirm which FlickList account a
        session or API key belongs to.
      responses:
        '200':
          description: Identity.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Identity'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
      security: *syncSecurity

components:
  parameters:
    TmdbIdPath:
      name: tmdb_id
      in: path
      required: true
      schema: { type: integer }
      description: The title's TMDB numeric ID.
    ApiKeyQuery:
      name: api_key
      in: query
      schema: { type: string }
      description: >-
        Catalog-only alternative to header auth, kept for drop-in TMDB-client compatibility: pass
        an `fs_live_` API key here instead of an `Authorization` header. Not accepted on Sync or
        Identity endpoints — those require a header.
    PageQuery:
      name: page
      in: query
      schema: { type: integer, minimum: 1, default: 1 }
    AppendToResponse:
      name: append_to_response
      in: query
      schema: { type: string }
      description: >-
        Comma-separated. Supported: `external_ids`, `credits`, `videos`, `release_dates`
        (movie), `content_ratings` (tv), `images`, `keywords`, `alternative_titles`,
        `translations`.

  responses:
    BadRequest:
      description: >-
        Malformed request or a failed validation rule described on the endpoint.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    Unauthorized:
      description: Missing, invalid, expired, or revoked credential.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: unauthorized }
    Forbidden:
      description: >-
        The credential is valid but not allowed to do this — most commonly an API key missing
        the scope the endpoint requires (`read` for Sync GETs and Identity, `write` for every
        Scrobble endpoint and every Sync write endpoint).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: forbidden, detail: "..." }
    NotFound:
      description: No matching record.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: not_found, detail: "movie with tmdb_id 999999999 not found" }
    RateLimited:
      description: Per-credential rate limit exceeded. Retry after the `Retry-After` header.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer, example: 60 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
          example: { error: rate_limited }

  securitySchemes:
    SessionToken:
      type: http
      scheme: bearer
      description: >-
        A session token from the device-code flow (or from FlickList's own login). Sent as
        `Authorization: Bearer <token>`. Full read/write access to the signed-in user's data —
        session tokens aren't scope-limited the way API keys are.
    ApiKeyBearer:
      type: http
      scheme: bearer
      description: >-
        An `fs_live_...` API key sent as `Authorization: Bearer fs_live_...`. On Sync and Identity
        endpoints the key must carry the `read` scope.
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: Same `fs_live_...` API key, sent via a dedicated header instead of `Authorization`.

  schemas:
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
          description: Machine-readable error code (e.g. `not_found`, `unauthorized`, `bad_request`, `rate_limited`).
        detail:
          type: [string, 'null']
          description: Human-readable detail. Omitted for some error codes (e.g. plain `unauthorized`).
      required: [error]

    # ── Shared identity block ──────────────────────────────────────
    IdsBlock:
      type: object
      description: >-
        Cross-source identifier block attached to every media object on the Sync surface. See the
        "The ids Object" guide section for the full contract — in short: `fldb` is permanent and
        always present, every other field is best-effort and nullable, and clients must ignore
        keys they don't recognize.
      properties:
        fldb:
          type: string
          description: FlickList's own canonical, permanent public ID (`flm_`/`flt_` prefix). Never reassigned.
          example: flm_2c47d1a9
        slug:
          type: [string, 'null']
          description: FlickList's URL slug for this title, when one exists.
        tmdb:
          type: [integer, 'null']
        imdb:
          type: [string, 'null']
          example: tt0111161
        tvdb:
          type: [integer, 'null']
        anilist:
          type: [integer, 'null']
      required: [fldb, slug, tmdb, imdb, tvdb, anilist]

    # ── Authentication ─────────────────────────────────────────────
    DevicePollPending:
      type: object
      description: Returned with HTTP 200 while the code is unclaimed, denied, or expired.
      properties:
        error:
          type: string
          enum: [authorization_pending, expired_token, access_denied]
        error_description:
          type: string
      required: [error, error_description]
    DevicePollAuthorized:
      type: object
      description: Returned with HTTP 200 once the person has approved the device.
      properties:
        access_token:
          type: string
          description: Identical to `token` — both keys carry the same session token.
        token:
          type: string
        user:
          $ref: '#/components/schemas/UserProfile'
        expires_at:
          type: string
          format: date-time
          description: RFC 3339 timestamp. Session tokens are valid for 30 days from issue.
      required: [access_token, token, user, expires_at]
    AuthResult:
      type: object
      properties:
        token:
          type: string
        user:
          $ref: '#/components/schemas/UserProfile'
        expires_at:
          type: string
          format: date-time
          example: "2026-08-30T12:00:00.000Z"
      required: [token, user, expires_at]
    UserProfile:
      type: object
      description: The full FlickList account profile — broader than the `Identity` schema returned by `/v3/me`.
      properties:
        id: { type: string, format: uuid }
        username: { type: string }
        email: { type: string, format: email }
        display_name: { type: [string, 'null'] }
        avatar_url: { type: [string, 'null'] }
        role_id: { type: integer }
        role_level: { type: integer }
        is_staff: { type: boolean }
        is_system: { type: boolean }
        email_verified: { type: boolean }
        has_password: { type: boolean }
        created_at: { type: string, format: date-time, example: "2026-01-04T18:22:41.000Z" }
        timezone: { type: [string, 'null'] }
      required:
        - id
        - username
        - email
        - display_name
        - avatar_url
        - role_id
        - role_level
        - is_staff
        - is_system
        - email_verified
        - has_password
        - created_at
        - timezone

    # ── Identity (/v3/me) ──────────────────────────────────────────
    Identity:
      type: object
      properties:
        id: { type: string, format: uuid }
        username: { type: string }
        display_name: { type: [string, 'null'] }
        avatar_url: { type: [string, 'null'] }
        created_at: { type: string, format: date-time, example: "2026-01-04T18:22:41.000Z" }
        timezone: { type: [string, 'null'] }
      required: [id, username, display_name, avatar_url, created_at, timezone]

    # ── Sync ────────────────────────────────────────────────────────
    ShowSummary:
      type: object
      properties:
        title: { type: string }
        year: { type: [integer, 'null'] }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [title, year, ids]
    WatchedEpisode:
      type: object
      properties:
        number: { type: integer }
        plays: { type: integer }
        last_watched_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
      required: [number, plays, last_watched_at]
    WatchedSeason:
      type: object
      properties:
        number: { type: integer, description: "Always > 0 — specials (season 0) are excluded." }
        episodes:
          type: array
          items: { $ref: '#/components/schemas/WatchedEpisode' }
      required: [number, episodes]
    WatchedShow:
      type: object
      properties:
        show: { $ref: '#/components/schemas/ShowSummary' }
        plays: { type: integer, description: Sum of every episode's play count. }
        last_watched_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        reset_at: { type: [string, 'null'], format: date-time, description: "Reserved for a future feature. Always null today." }
        seasons:
          type: array
          items: { $ref: '#/components/schemas/WatchedSeason' }
      required: [show, plays, last_watched_at, reset_at, seasons]
    WatchedMovie:
      type: object
      properties:
        title: { type: string }
        year: { type: [integer, 'null'] }
        plays: { type: integer }
        last_watched_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [title, year, plays, last_watched_at, ids]
    HistoryEvent:
      type: object
      properties:
        id: { type: integer, description: "Opaque, monotonically increasing event ID." }
        type: { type: string, enum: [movie, episode] }
        title: { type: string }
        year: { type: [integer, 'null'] }
        season_number: { type: [integer, 'null'] }
        episode_number: { type: [integer, 'null'] }
        episode_name: { type: [string, 'null'] }
        watched_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [id, type, title, year, season_number, episode_number, episode_name, watched_at, ids]
    SyncPlayback:
      type: object
      properties:
        media_type: { type: string, enum: [movie, episode] }
        title: { type: [string, 'null'] }
        season_number: { type: [integer, 'null'] }
        episode_number: { type: [integer, 'null'] }
        episode_name: { type: [string, 'null'] }
        progress:
          type: number
          format: float
          description: Percent complete, 0-100 (not a 0-1 fraction — see the guide's pagination/rate-limits page for the note on this).
          example: 42.5
        paused: { type: boolean }
        updated_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        ids:
          anyOf:
            - $ref: '#/components/schemas/IdsBlock'
            - type: 'null'
          description: Null when the resume point has no resolved catalog match — the client's own progress is still meaningful without one.
      required: [media_type, title, season_number, episode_number, episode_name, progress, paused, updated_at, ids]
    SyncWatchlistItem:
      type: object
      properties:
        status: { type: string, enum: [plan_to_watch, watching, completed, on_hold, dropped] }
        progress:
          type: [number, 'null']
          format: float
          description: Fraction watched, 0.0-1.0 (a different scale than `SyncPlayback.progress`, which is 0-100 — see the guide).
          example: 0.65
        added_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        started_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        title: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        media_type: { type: [string, 'null'], enum: [movie, tv, null] }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [status, progress, added_at, started_at, completed_at, title, year, media_type, ids]
    SyncRating:
      type: object
      properties:
        rating: { type: number, format: float, minimum: 0.5, maximum: 10, description: Half-point increments. }
        rated_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        season_number: { type: [integer, 'null'] }
        episode_number: { type: [integer, 'null'] }
        title: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        media_type: { type: [string, 'null'], enum: [movie, tv, null] }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [rating, rated_at, season_number, episode_number, title, year, media_type, ids]
    SyncList:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        slug: { type: string }
        description: { type: [string, 'null'] }
        privacy: { type: string, enum: [private, public, unlisted] }
        is_ranked: { type: boolean }
        is_smart: { type: boolean, description: "Rule-based list. Its filter rules aren't exposed on /v3 — fetch /sync/lists/{id}/items for resolved contents." }
        tags:
          type: array
          items: { type: string }
        item_count: { type: integer }
        likes: { type: integer }
        created_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        updated_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
      required: [id, name, slug, description, privacy, is_ranked, is_smart, tags, item_count, likes, created_at, updated_at]
    SyncListItem:
      type: object
      properties:
        note: { type: [string, 'null'] }
        added_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        position: { type: integer }
        title: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        media_type: { type: [string, 'null'], enum: [movie, tv, null] }
        season_number: { type: [integer, 'null'] }
        episode_number: { type: [integer, 'null'] }
        episode_name: { type: [string, 'null'] }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [note, added_at, position, title, year, media_type, season_number, episode_number, episode_name, ids]
    SyncFavorite:
      type: object
      properties:
        title: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        media_type: { type: [string, 'null'], enum: [movie, tv, null] }
        favorited_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [title, year, media_type, favorited_at, ids]

    # ── Sync: activity, Up Next, tracked shows ───────────────────────
    SyncCategoryActivities:
      type: object
      properties:
        watched_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        paused_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        watchlisted_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
      required: [watched_at, paused_at, watchlisted_at]
    SyncShowActivities:
      type: object
      properties:
        watchlisted_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
      required: [watchlisted_at]
    SyncListActivities:
      type: object
      properties:
        updated_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
      required: [updated_at]
    SyncLastActivities:
      type: object
      properties:
        all:
          type: string
          format: date-time
          description: The max of every timestamp below — the single value to compare against a locally cached "last synced at."
          example: "2026-05-14T02:10:33.000Z"
        movies: { $ref: '#/components/schemas/SyncCategoryActivities' }
        episodes: { $ref: '#/components/schemas/SyncCategoryActivities' }
        shows: { $ref: '#/components/schemas/SyncShowActivities' }
        lists: { $ref: '#/components/schemas/SyncListActivities' }
        favorites: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
      required: [all, movies, episodes, shows, lists, favorites]
    SyncUpNextItem:
      type: object
      properties:
        title: { type: string }
        media_type: { type: string, enum: [tv], description: "Always \"tv\" in practice — Up Next is show-only." }
        status: { type: [string, 'null'], description: "The show's production status (e.g. \"Returning Series\", \"Ended\")." }
        ids: { $ref: '#/components/schemas/IdsBlock' }
        next_season_number: { type: [integer, 'null'] }
        next_episode_number: { type: [integer, 'null'] }
        next_episode_name: { type: [string, 'null'] }
        next_air_date: { type: [string, 'null'], format: date }
        awaiting_next_episode:
          type: boolean
          description: >-
            True when the user has watched every ingested episode but the show is still airing —
            no next episode is known yet, as opposed to the show having genuinely ended.
        last_watched_season: { type: [integer, 'null'] }
        last_watched_episode: { type: [integer, 'null'] }
        last_watched_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        episode_available: { type: boolean, description: True if the next episode has already aired (or airs today). }
        progress_percent: { type: number, format: float, description: "Watched episodes / total episodes * 100." }
        dropped:
          type: boolean
          description: >-
            True when this show is hidden from Up Next via `POST /sync/up_next/drop`. Unlike the
            internal Up Next endpoint (which excludes dropped shows by default), this list always
            includes them so the flag has something to filter.
      required: [title, media_type, status, ids, next_season_number, next_episode_number, next_episode_name, next_air_date, awaiting_next_episode, last_watched_season, last_watched_episode, last_watched_at, episode_available, progress_percent, dropped]
    SyncTrackedShow:
      type: object
      properties:
        title: { type: string }
        year: { type: [integer, 'null'] }
        media_type: { type: string, enum: [movie, tv] }
        status: { type: [string, 'null'] }
        notify: { type: boolean }
        tracked_at: { type: string, format: date-time, example: "2026-05-14T02:10:33.000Z" }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [title, year, media_type, status, notify, tracked_at, ids]

    # ── Calendar ──────────────────────────────────────────────────────
    SyncCalendarEpisode:
      type: object
      properties:
        show_title: { type: string }
        season_number: { type: integer }
        episode_number: { type: integer }
        episode_title: { type: [string, 'null'] }
        air_date: { type: string, format: date }
        air_time: { type: [string, 'null'], description: "Local air time (e.g. \"20:00\"), when known." }
        runtime: { type: [integer, 'null'] }
        network: { type: [string, 'null'] }
        ids: { $ref: '#/components/schemas/IdsBlock' }
      required: [show_title, season_number, episode_number, episode_title, air_date, air_time, runtime, network, ids]
    SyncCalendarDay:
      type: object
      properties:
        date: { type: string, format: date }
        items:
          type: array
          items: { $ref: '#/components/schemas/SyncCalendarEpisode' }
      required: [date, items]
    SyncCalendarResponse:
      type: object
      properties:
        start: { type: string, format: date }
        end: { type: string, format: date }
        days:
          type: array
          items: { $ref: '#/components/schemas/SyncCalendarDay' }
      required: [start, end, days]

    # ── Lists (public read/discovery) ──────────────────────────────
    PublicListOwner:
      type: object
      description: >-
        Deliberately just a username — never the owner's internal id or email, matching how
        FlickList anonymizes owner identity on every other public-facing surface.
      properties:
        username: { type: string }
      required: [username]
    PublicSyncList:
      description: >-
        `SyncList` plus the owner's username — a caller resolving a list by bare numeric id has no
        other way to know whose list it is. The two schemas' fields sit at the same level on the
        wire (this isn't a nested `list` object).
      allOf:
        - $ref: '#/components/schemas/SyncList'
        - type: object
          properties:
            owner: { $ref: '#/components/schemas/PublicListOwner' }
          required: [owner]
    TagCount:
      type: object
      properties:
        tag: { type: string }
        count: { type: integer }
      required: [tag, count]

    # ── Sync/Scrobble: write-side ids input ─────────────────────────
    WriteIds:
      type: object
      description: >-
        Input-side identifier block for every `/v3` write item — the counterpart to the read-side
        `IdsBlock`. All four keys are optional, but resolution needs at least one to match a
        catalog row. Resolution precedence is `fldb` > `tmdb` > `imdb` > `tvdb`: the first key
        present (in that order) is the one used, the rest are ignored for that item. `fldb`
        self-describes via its `flm_`/`flt_` prefix and resolves without `media_type`; every other
        key requires a valid `media_type` on the same item to disambiguate — a movie and an
        unrelated show adaptation can otherwise share the same external-ID namespace. An item
        whose `ids` block doesn't resolve to any catalog row (empty block, an unrecognized
        `media_type`, or no matching external id) is unresolvable: batch endpoints report it in
        `not_found` rather than failing the request; single-item endpoints (Scrobble, `POST
        /sync/playback`) 404 instead.
      properties:
        fldb:
          type: [string, 'null']
          example: flm_7d3a95e0
        tmdb: { type: [integer, 'null'] }
        imdb: { type: [string, 'null'], example: tt0111161 }
        tvdb: { type: [integer, 'null'] }
    NotFoundEntry:
      type: object
      description: >-
        Echoes an unresolvable batch item back so the caller can tell which one failed. `season`
        and `episode` are present only when the original item specified them — omitted entirely
        otherwise, not sent as `null`.
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        season: { type: integer }
        episode: { type: integer }
      required: [ids]
    BulkAddResponse:
      type: object
      properties:
        added: { type: integer, description: Count of items successfully applied. }
        existing:
          type: integer
          description: >-
            Present on `POST /sync/history` only: items skipped as exact duplicates of an
            already-recorded play — same item with the same explicit `watched_at`. This is the
            endpoint's idempotency mechanism: always send an explicit `watched_at` (reuse the
            same value when retrying a timed-out request) and a retry can never double-record a
            play; the duplicate lands here instead of in `added`. If you omit `watched_at`, the
            server stamps arrival time, and a blind retry after an ambiguous failure records a
            second play.
        not_found:
          type: array
          items: { $ref: '#/components/schemas/NotFoundEntry' }
      required: [added, not_found]
    BulkRemoveResponse:
      type: object
      properties:
        removed: { type: integer }
        not_found:
          type: array
          items: { $ref: '#/components/schemas/NotFoundEntry' }
      required: [removed, not_found]
    ListItemsAddResponse:
      type: object
      properties:
        added: { type: integer, description: Items newly added to the list. }
        existing: { type: integer, description: "Items that were already on the list — still a success, not a not_found." }
        not_found:
          type: array
          items: { $ref: '#/components/schemas/NotFoundEntry' }
      required: [added, existing, not_found]
    ListItemsRemoveResponse:
      type: object
      properties:
        removed: { type: integer }
        not_found:
          type: array
          items: { $ref: '#/components/schemas/NotFoundEntry' }
      required: [removed, not_found]

    # ── Scrobble ─────────────────────────────────────────────────────
    ScrobbleWriteRequest:
      type: object
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type:
          type: string
          enum: [movie, show, tv]
          description: >-
            Note this is "show", not "tv" — the write-side `media_type` vocabulary differs from
            the read-side `SyncWatchlistItem`/`SyncRating`/etc. `media_type` fields, which use
            "tv". Always required in the request body. Only used for resolution when `ids` has no
            `fldb` (fldb self-describes and ignores this field); in that case it must be "movie"
            or "show" or the item is unresolvable.
        season: { type: [integer, 'null'], description: Required with episode for a show. }
        episode: { type: [integer, 'null'] }
        progress:
          type: number
          format: float
          description: Percent complete, 0-100. Out-of-range values are clamped, not rejected.
          example: 42.5
      required: [ids, media_type, progress]
    ScrobbleStartResponse:
      type: object
      properties:
        action: { type: string, enum: [start] }
        progress: { type: number, format: float }
        id:
          type: [integer, 'null']
          description: >-
            The upserted `playback_progress` row id. Null when the item was already recently
            marked watched — start skips creating a new resume point in that case.
      required: [action, progress, id]
    ScrobblePauseResponse:
      type: object
      properties:
        action: { type: string, enum: [pause] }
        progress: { type: number, format: float }
        id:
          type: [integer, 'null']
          description: >-
            The updated `playback_progress` row id. Null when there was no active (non-paused)
            resume point matching this item — pause never creates a new row.
      required: [action, progress, id]
    ScrobbleStopResolved:
      type: object
      description: Returned when the stop produced a watch-history outcome.
      properties:
        action: { type: string, enum: [stop] }
        progress: { type: number, format: float, description: The effective progress used to decide the outcome. }
        watch_status: { type: string, enum: [partial, watched, preview] }
        id:
          type: [integer, 'null']
          description: >-
            The new `watch_history` row id, when one was inserted. Null when the item was already
            recorded as watched recently (cross-service dedup) even though the outcome still
            resolved.
      required: [action, progress, watch_status, id]
    ScrobbleStopUnresolved:
      type: object
      description: >-
        Returned when there was nothing to reconcile into watch history: no matching session
        (`no_session`), a sub-1%-progress event on a session with no prior progress, discarded as
        a phantom stop (`discarded`), or progress stayed below the watched threshold and the
        resume point was kept as-is (`paused`).
      properties:
        action: { type: string, enum: [stop] }
        progress: { type: number, format: float }
        watch_status: { type: string, enum: [no_session, discarded, paused] }
      required: [action, progress, watch_status]
    ScrobbleStopResponse:
      anyOf:
        - $ref: '#/components/schemas/ScrobbleStopResolved'
        - $ref: '#/components/schemas/ScrobbleStopUnresolved'

    # ── Sync: write requests ─────────────────────────────────────────
    HistoryItem:
      type: object
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
        season:
          type: [integer, 'null']
          description: >-
            For a show item, set both season and episode together to mark one specific episode
            watched. Omit both to mark the show itself (no episode reference) watched instead.
            Setting only one of the two is treated the same as setting neither — but if you do set
            both and they don't resolve to a real episode on that show, the item lands in
            `not_found` rather than silently falling back to a show-level mark.
        episode: { type: [integer, 'null'] }
        watched_at:
          type: [string, 'null']
          format: date-time
          description: Defaults to the request time. More than 5 minutes in the future fails the whole batch with a 400.
      required: [ids, media_type]
    HistoryBulkRequest:
      type: object
      properties:
        items:
          type: array
          maxItems: 1000
          items: { $ref: '#/components/schemas/HistoryItem' }
      required: [items]
    WatchlistItemReq:
      type: object
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
      required: [ids, media_type]
    WatchlistBulkRequest:
      type: object
      properties:
        items:
          type: array
          maxItems: 1000
          items: { $ref: '#/components/schemas/WatchlistItemReq' }
      required: [items]
    RatingAddItem:
      type: object
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
        rating: { type: number, format: float, minimum: 0.5, maximum: 10, description: Half-point increments. }
        season: { type: [integer, 'null'], description: Required if episode is set. }
        episode: { type: [integer, 'null'] }
        rated_at: { type: [string, 'null'], format: date-time, description: Defaults to the request time. }
      required: [ids, media_type, rating]
    RatingAddRequest:
      type: object
      properties:
        items:
          type: array
          maxItems: 1000
          items: { $ref: '#/components/schemas/RatingAddItem' }
      required: [items]
    MediaIdentItem:
      type: object
      description: A bare media/episode identity with no payload — used by the unrate and unrate-adjacent bulk endpoints.
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
        season: { type: [integer, 'null'] }
        episode: { type: [integer, 'null'] }
      required: [ids, media_type]
    MediaIdentBulkRequest:
      type: object
      properties:
        items:
          type: array
          maxItems: 1000
          items: { $ref: '#/components/schemas/MediaIdentItem' }
      required: [items]
    PlaybackWriteRequest:
      type: object
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
        season: { type: [integer, 'null'] }
        episode: { type: [integer, 'null'] }
        progress: { type: number, format: float, description: "Percent complete, 0-100. Clamped, not rejected, when out of range." }
        paused: { type: [boolean, 'null'], description: Defaults to false. }
      required: [ids, media_type, progress]
    PlaybackUpsertResponse:
      type: object
      properties:
        id: { type: integer, description: The written playback_progress row id. }
        progress: { type: number, format: float }
        paused: { type: boolean }
      required: [id, progress, paused]
    ListCreateRequest:
      type: object
      properties:
        name: { type: string, minLength: 1, maxLength: 200, description: Trimmed before the length check. }
        description: { type: [string, 'null'] }
        privacy: { type: [string, 'null'], enum: [private, public, unlisted, null], description: Defaults to private. }
      required: [name]
    ListItemWriteEntry:
      type: object
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
        season: { type: [integer, 'null'] }
        episode: { type: [integer, 'null'] }
        note: { type: [string, 'null'] }
      required: [ids, media_type]
    ListItemsBulkRequest:
      type: object
      properties:
        items:
          type: array
          maxItems: 1000
          items: { $ref: '#/components/schemas/ListItemWriteEntry' }
      required: [items]
    FavoriteItemReq:
      type: object
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
      required: [ids, media_type]
    FavoritesBulkRequest:
      type: object
      properties:
        items:
          type: array
          maxItems: 1000
          items: { $ref: '#/components/schemas/FavoriteItemReq' }
      required: [items]
    SingleMediaRequest:
      type: object
      description: >-
        The single-item request body shared by Up Next drop/undrop and tracked-shows
        track/untrack — a bare media identity, no payload. Unlike the bulk endpoints, an
        unresolvable `ids` block here is a 404, not a `not_found` batch entry.
      properties:
        ids: { $ref: '#/components/schemas/WriteIds' }
        media_type: { type: string, enum: [movie, show, tv] }
      required: [ids, media_type]
    DroppedResponse:
      type: object
      properties:
        dropped: { type: boolean }
      required: [dropped]
    TrackedResponse:
      type: object
      properties:
        tracked: { type: boolean }
      required: [tracked]

    # ── Catalog: shared building blocks ────────────────────────────
    Genre:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
      required: [id, name]
    ExternalIdsBlock:
      type: object
      properties:
        imdb_id: { type: [string, 'null'] }
        tvdb_id: { type: [integer, 'null'] }
    CreditPerson:
      type: object
      properties:
        id: { type: integer, description: TMDB person ID. }
        name: { type: string }
        profile_path: { type: [string, 'null'] }
        gender: { type: integer, description: "Always 0 — FlickList doesn't track this field." }
        character: { type: [string, 'null'], description: Cast only. }
        order: { type: [integer, 'null'], description: Cast only. }
        known_for_department: { type: [string, 'null'], description: "Cast only, always \"Acting\"." }
        job: { type: [string, 'null'], description: Crew only. }
        department: { type: [string, 'null'], description: Crew only. }

    # ── Catalog: movie/tv/season detail ────────────────────────────
    MovieDetail:
      type: object
      description: >-
        TMDB `movie_details` shape. Fields listed under "with append_to_response" only appear
        when requested via that query param.
      properties:
        id: { type: [integer, 'null'], description: TMDB ID. }
        imdb_id: { type: [string, 'null'] }
        title: { type: string }
        original_title: { type: string, description: "Currently mirrors title — not a distinct original-language title." }
        overview: { type: [string, 'null'] }
        tagline: { type: [string, 'null'] }
        release_date: { type: string, description: "ISO date, or \"\" when unknown." }
        status: { type: [string, 'null'] }
        runtime: { type: [integer, 'null'] }
        budget: { type: integer }
        revenue: { type: integer }
        vote_average: { type: number, format: float }
        vote_count: { type: integer }
        popularity: { type: number, format: float }
        poster_path: { type: [string, 'null'] }
        backdrop_path: { type: [string, 'null'] }
        adult: { type: boolean }
        original_language: { type: [string, 'null'] }
        homepage: { type: [string, 'null'] }
        belongs_to_collection:
          type: [object, 'null']
          properties:
            id: { type: integer }
            name: { type: [string, 'null'] }
        genres:
          type: array
          items: { $ref: '#/components/schemas/Genre' }
        production_companies: { type: array, items: {} }
        production_countries: { type: array, items: {} }
        spoken_languages: { type: array, items: {} }
        external_ids:
          allOf: [{ $ref: '#/components/schemas/ExternalIdsBlock' }]
          description: "With append_to_response=external_ids."
        credits:
          type: object
          description: "With append_to_response=credits."
          properties:
            cast: { type: array, items: { $ref: '#/components/schemas/CreditPerson' } }
            crew: { type: array, items: { $ref: '#/components/schemas/CreditPerson' } }
        videos:
          type: object
          description: "With append_to_response=videos."
          properties:
            results:
              type: array
              items:
                type: object
                properties:
                  key: { type: string }
                  name: { type: string }
                  site: { type: string }
                  type: { type: string }
                  official: { type: boolean }
      required: [id, title, original_title, release_date, budget, revenue, vote_average, vote_count, popularity, adult, genres]
    TvDetail:
      type: object
      description: TMDB `tv_details` shape.
      properties:
        id: { type: [integer, 'null'], description: TMDB ID. }
        name: { type: string }
        original_name: { type: string, description: "Currently mirrors name — not a distinct original-language title." }
        overview: { type: [string, 'null'] }
        tagline: { type: [string, 'null'] }
        first_air_date: { type: string, description: "ISO date, or \"\" when unknown." }
        last_air_date: { type: [string, 'null'] }
        status: { type: [string, 'null'] }
        number_of_seasons: { type: integer }
        number_of_episodes: { type: integer }
        episode_run_time:
          type: array
          items: { type: integer }
          description: Zero or one entry (average runtime), not a real per-season array.
        vote_average: { type: number, format: float }
        vote_count: { type: integer }
        popularity: { type: number, format: float }
        poster_path: { type: [string, 'null'] }
        backdrop_path: { type: [string, 'null'] }
        adult: { type: boolean }
        original_language: { type: [string, 'null'] }
        homepage: { type: [string, 'null'] }
        in_production: { type: boolean, description: "True only when status is \"Returning Series\"." }
        genres:
          type: array
          items: { $ref: '#/components/schemas/Genre' }
        networks: { type: array, items: {} }
        production_companies: { type: array, items: {} }
        production_countries: { type: array, items: {} }
        spoken_languages: { type: array, items: {} }
        origin_country: { type: array, items: { type: string } }
        created_by: { type: array, items: {} }
        seasons:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              season_number: { type: integer }
              name: { type: [string, 'null'] }
              overview: { type: [string, 'null'] }
              poster_path: { type: [string, 'null'] }
              air_date: { type: [string, 'null'] }
              episode_count: { type: [integer, 'null'] }
        external_ids:
          allOf: [{ $ref: '#/components/schemas/ExternalIdsBlock' }]
          description: "With append_to_response=external_ids."
        credits:
          type: object
          description: "With append_to_response=credits."
          properties:
            cast: { type: array, items: { $ref: '#/components/schemas/CreditPerson' } }
            crew: { type: array, items: { $ref: '#/components/schemas/CreditPerson' } }
      required: [id, name, original_name, first_air_date, number_of_seasons, number_of_episodes, vote_average, vote_count, popularity, adult, in_production, genres, seasons]
    SeasonDetail:
      type: object
      properties:
        id: { type: integer, description: "0 when the season isn't found in FlickList's catalog." }
        season_number: { type: integer }
        name: { type: string }
        overview: { type: string }
        poster_path: { type: [string, 'null'] }
        air_date: { type: [string, 'null'] }
        episodes:
          type: array
          items:
            type: object
            properties:
              id: { type: integer, description: "TMDB episode ID when known, else FlickList's internal ID." }
              episode_number: { type: integer }
              season_number: { type: integer }
              name: { type: [string, 'null'] }
              overview: { type: [string, 'null'] }
              air_date: { type: [string, 'null'] }
              runtime: { type: [integer, 'null'] }
              still_path: { type: [string, 'null'] }
              vote_average: { type: number, format: float }
              vote_count: { type: integer }
              guest_stars:
                type: array
                description: "Only present with append_to_response=credits."
                items: { $ref: '#/components/schemas/CreditPerson' }
      required: [id, season_number, name, overview, episodes]

    # ── Catalog: list/discover/recommendations/collection card ────
    MediaListCard:
      type: object
      description: >-
        The TMDB-shaped card used by every list-style catalog endpoint (popular, now_playing,
        upcoming, trending, discover, recommendations, and collection parts). Several fields are
        placeholders in this shape and only carry real values on the movie/tv detail endpoints:
        `overview` is always `""`, `genre_ids` is always `[]`, `original_language` is always
        `"en"`, and `release_date`/`first_air_date` is always `""`. Fetch the detail endpoint for
        the real values.
      properties:
        id: { type: [integer, 'null'], description: TMDB ID. }
        title: { type: string, description: "Movie results only." }
        name: { type: string, description: "TV results only." }
        original_title: { type: string, description: "Movie results only; currently a duplicate of title." }
        original_name: { type: string, description: "TV results only; currently a duplicate of name." }
        release_date: { type: string, description: "Movie results only. Always \"\" in this shape." }
        first_air_date: { type: string, description: "TV results only. Always \"\" in this shape." }
        poster_path: { type: [string, 'null'] }
        backdrop_path: { type: [string, 'null'] }
        vote_average: { type: number, format: float }
        vote_count: { type: integer }
        popularity: { type: number, format: float }
        media_type: { type: string, enum: [movie, tv] }
        poster_url: { type: [string, 'null'], description: "Absolute, ready-to-use image URL." }
        backdrop_url: { type: [string, 'null'] }
        adult: { type: boolean, description: "Always false — FlickList's catalog excludes adult titles." }
        original_language: { type: string, description: "Always \"en\" in this shape." }
        genre_ids: { type: array, items: { type: integer }, description: "Always [] in this shape." }
        overview: { type: string, description: "Always \"\" in this shape." }
        imdb_id: { type: [string, 'null'] }
      required: [id, media_type, vote_average, vote_count, popularity, adult, original_language, genre_ids, overview]
    MediaListPage:
      type: object
      description: >-
        On popular/now_playing/upcoming/trending, `total_pages`/`total_results` are an *estimate*
        derived from whether the current page was full (there's no expensive exact COUNT behind
        these). Discover runs a real query and returns an exact count instead, capped at 500
        pages.
      properties:
        page: { type: integer }
        total_pages: { type: integer }
        total_results: { type: integer }
        results:
          type: array
          items: { $ref: '#/components/schemas/MediaListCard' }
      required: [page, total_pages, total_results, results]
    RecommendationsPage:
      type: object
      properties:
        page: { type: integer, description: "Always 1 — the page query param has no effect here." }
        total_pages: { type: integer, description: "Always 1." }
        total_results: { type: integer }
        results:
          type: array
          items: { $ref: '#/components/schemas/MediaListCard' }
      required: [page, total_pages, total_results, results]
    CollectionDetail:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, description: "\"\" when unknown." }
        overview: { type: string, description: "Always \"\" — not populated on this endpoint." }
        poster_path: { type: [string, 'null'] }
        backdrop_path: { type: [string, 'null'] }
        parts:
          type: array
          items: { $ref: '#/components/schemas/MediaListCard' }
      required: [id, name, overview, parts]
    NetworkDetail:
      type: object
      properties:
        id: { type: integer }
        name: { type: string, description: "\"\" when unknown." }
        logo_path: { type: [string, 'null'], description: Always null today. }
        origin_country: { type: string, description: Always "" today. }
      required: [id, name, logo_path, origin_country]

    # ── Catalog: search ─────────────────────────────────────────────
    SearchResultCard:
      type: object
      properties:
        id: { type: [integer, 'null'], description: TMDB ID. }
        title: { type: string, description: "Duplicate of name, present for TMDB-shape compatibility." }
        name: { type: string }
        original_title: { type: string, description: Duplicate of name. }
        original_name: { type: string, description: Duplicate of name. }
        poster_path: { type: [string, 'null'] }
        backdrop_path: { type: [string, 'null'], description: Always null on search results. }
        poster_url: { type: [string, 'null'] }
        vote_average: { type: number, format: float }
        popularity: { type: number, format: float }
        media_type: { type: string, enum: [movie, tv] }
        adult: { type: boolean, description: Always false. }
        original_language: { type: string, description: "Always \"en\"." }
        genre_ids: { type: array, items: { type: integer }, description: "Always []." }
        overview: { type: string, description: "Always \"\"." }
      required: [id, name, media_type, vote_average, popularity, adult, original_language, genre_ids, overview]
    SearchPage:
      type: object
      description: >-
        `page` echoes the query param but isn't used to slice results, and `total_pages` is
        always `1` — this endpoint returns a single batch of up to 20 matches rather than true
        pagination. For more precise results, use `/discover/{movie,tv}` with filters instead.
      properties:
        page: { type: integer }
        total_pages: { type: integer, description: "Always 1." }
        total_results: { type: integer, description: "Count of results actually returned (max 20), not a true total match count." }
        results:
          type: array
          items: { $ref: '#/components/schemas/SearchResultCard' }
      required: [page, total_pages, total_results, results]
    FindResultCard:
      type: object
      description: Unlike `MediaListCard`, this shape carries the real release/air date.
      properties:
        id: { type: [integer, 'null'] }
        title: { type: string, description: "Movie results only; currently a duplicate of name." }
        name: { type: string, description: "TV results only; currently a duplicate of title." }
        original_title: { type: string }
        original_name: { type: string }
        release_date: { type: string, description: "Movie results only. Real date, or \"\" when unknown." }
        first_air_date: { type: string, description: "TV results only. Real date, or \"\" when unknown." }
        poster_path: { type: [string, 'null'] }
        backdrop_path: { type: [string, 'null'] }
        poster_url: { type: [string, 'null'] }
        backdrop_url: { type: [string, 'null'] }
        vote_average: { type: number, format: float }
        popularity: { type: number, format: float }
        media_type: { type: string, enum: [movie, tv] }
        adult: { type: boolean, description: Always false. }
        original_language: { type: string, description: "Always \"en\"." }
        genre_ids: { type: array, items: { type: integer }, description: "Always []." }
        overview: { type: string, description: "Always \"\"." }
      required: [media_type, vote_average, popularity, adult, original_language, genre_ids, overview]
    FindResult:
      type: object
      properties:
        movie_results:
          type: array
          items: { $ref: '#/components/schemas/FindResultCard' }
        tv_results:
          type: array
          items: { $ref: '#/components/schemas/FindResultCard' }
        person_results:
          type: array
          items: {}
          description: "Always [] — /v3/find doesn't resolve people yet."
      required: [movie_results, tv_results, person_results]

    # ── Catalog: people ─────────────────────────────────────────────
    PersonListCard:
      type: object
      properties:
        id: { type: integer, description: TMDB person ID. }
        name: { type: string }
        profile_path: { type: [string, 'null'] }
        known_for_department: { type: [string, 'null'] }
        popularity: { type: number, format: float }
        adult: { type: boolean, description: Always false. }
        gender: { type: integer, description: Always 0. }
        known_for: { type: array, items: {}, description: "Always [] — not populated on this endpoint." }
      required: [id, name, popularity, adult, gender, known_for]
    PeoplePage:
      type: object
      properties:
        page: { type: integer }
        total_pages: { type: integer, description: "Exact count, capped at 500 pages." }
        total_results: { type: integer }
        results:
          type: array
          items: { $ref: '#/components/schemas/PersonListCard' }
      required: [page, total_pages, total_results, results]
