> ## Documentation Index
> Fetch the complete documentation index at: https://docs.member.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Media workflow

> Upload, organize, attach, transcribe, edit transcripts, and search media.

Media stores files for content, pages, playlists, community attachments, and public embeds.

## The `mio media` command group

The `mio media` command group drives the media library end to end from the CLI — the full upload lifecycle included, no raw API calls required:

* **`media files`** — library CRUD plus ingest. `mio media files upload ./clip.mp4 --title "Intro"` runs create → presigned S3 PUT → finalize in one command (large files chunk automatically). Also `replace`, `finalize`, `transcode`, `register-synthetic`, `durable-url`, and per-file `cards` / `chapters`.
* **`media folders`** — CRUD plus `move`.
* **`media playlists`** — CRUD, `set-cover`, and `playlists items` to curate contents.
* **`media search`** — hybrid search over the team's transcripts.
* **`media transcripts`** — `get` / `vtt` / `content` / `versions`, plus `edit` / `revert` for authored transcripts.
* **`media attachments`** — inspect and manage media attachment rows.
* **`media hub-media`** / **`media hub-playlists`** — publish standalone files / playlists to a hub.

### Durable image URLs for page trees

The `variants` map on a file is imgproxy-signed and expires (see [Image variant URLs and expiry](#image-variant-urls-and-expiry)). For a URL you can inline into a page-builder image node, use `durable-url` — it prints the non-expiring [durable image URL](#durable-image-urls-mio-2525), already scoped and ready to store:

```bash theme={null}
mio media files durable-url file_hero --hub hub_abc123 --preset large-1440 --publish
```

The command joins the file's `durable_variants` entry with the **required `?hub_id=`** param so it resolves for `--hub`, and the URL 404s until the file is published public to that hub — `--publish` does that inline (or run `mio media hub-media publish --hub hub_abc123 --file-id file_hero --visibility public` beforehand). `--preset` picks one variant (`thumbnail-160`, `medium-720`, `large-1440`, `webp-medium`); omit it to print them all. Durable URLs are image-only.

### Curate a playlist

```bash theme={null}
mio media playlists create --title "Getting Started" --hub-id hub_abc123 --visibility public
mio media playlists items add    --playlist-id pl_abc --file-id file_intro
mio media playlists items list   --playlist-id pl_abc
mio media playlists items reorder it_xyz --playlist-id pl_abc --position 2
mio media playlists items remove  it_xyz --playlist-id pl_abc --yes
mio media playlists set-cover pl_abc --file-id file_cover
```

`set-cover` and `items add` take the **file id** (the media id is resolved for you). `items remove` and `items reorder` take the **item id** — the `id` returned by `items list`, not the file id.

### Visibility: the three-layer model

Whether a member or visitor can actually **open** a piece of media is decided by an **AND-gate across three independent layers** — each layer can only *narrow* access, never widen it. If any layer in the path is more restrictive than the viewer, they get **"Access restricted."**

1. **The file** — `media files upload` creates files at **`visibility: private` by default.** Change it with `media files update <id> --visibility public` (values: `public`, `unlisted`, `private`).
2. **The playlist** it's reached through (if any) — also `public` / `unlisted` / `private`; defaults to `private` on create. Set it with `media playlists update <id> --visibility public`.
3. **The hub publication** — the `hub_media` row written by `media hub-media publish` / `media hub-playlists publish --visibility` (values `public` / `members` / `private`). **This defaults to `members`,** so a freshly published item is invisible to *anonymous* visitors until you republish with `--visibility public`.

<Warning>
  A public playlist of **private files** still gates — the file layer wins. To make content reachable by an **anonymous** visitor, all three layers must be `public`. A **logged-in hub member** also passes `unlisted`/`private` files and a `members` publication, but a `private` file or a `private` playlist still blocks them. When content unexpectedly shows "Access restricted," check the file's visibility first (it's the most common trap, since it defaults to private).
</Warning>

### Publish media to a hub

`media hub-media publish` (standalone file) and `media hub-playlists publish` (playlist) write the `hub_media` row that surfaces the asset on the hub's `/content` grid and homepage content-grid.

```bash theme={null}
mio media hub-playlists publish --hub hub_abc123 --playlist-id pl_abc \
  --visibility public
```

<Warning>
  Pair the publish with `--visibility public` for anonymous visibility. `--published-at` is optional — the CLI now defaults it to *now* when unset, so the card publishes immediately; pass an explicit past/future RFC3339 timestamp only to backdate or schedule.
</Warning>

For the full walk-through of assembling these into a hub, see [Create a hub](/guides/creating-a-hub).

## Upload as an admin

Admin media routes are team-scoped:

```text theme={null}
POST   /api/v1/teams/{team_id}/files                        create file row + get presigned S3 PUT URL
POST   /api/v1/teams/{team_id}/files/{file_id}/finalize     confirm upload, transition PENDING → READY
GET    /api/v1/teams/{team_id}/files
GET    /api/v1/teams/{team_id}/files/{file_id}
PATCH  /api/v1/teams/{team_id}/files/{file_id}
DELETE /api/v1/teams/{team_id}/files/{file_id}
```

The upload flow creates a file row and returns a presigned S3 PUT URL in the same response (`POST /files`). Upload directly to S3 using that URL, then call `/finalize` to confirm the upload succeeded and begin processing.

### Registering a synthetic file (seed/admin shortcut)

```text theme={null}
POST /api/v1/admin/teams/{team_id}/files/synthetic
```

Auth is `require_team_owner`. Creates a **READY** file + media row directly, without the upload → finalize → transcode pipeline — so operators, seeders, and CLI tooling can reproduce a "seeded document" shape without a real upload.

Request body (JSON:API envelope, `type: "files"`):

```json theme={null}
{
  "data": {
    "type": "files",
    "attributes": {
      "title": "Q3 Onboarding Guide",
      "asset_kind": "document",
      "collection": "documents",
      "original_filename": "onboarding.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 0,
      "visibility": "private",
      "description": "Synthetic seed file"
    }
  }
}
```

`title` is required. `asset_kind` is `document` or `pdf` (default `document`) — synthetic files are download-only; transcoded kinds (video/audio/image) aren't supported since there's no real upload behind them to transcode. `collection`, `original_filename`, `mime_type`, `size_bytes`, `visibility`, and `description` are all optional.

<Note>
  **Pick a real `mime_type`.** It defaults to `application/octet-stream`, which is a generic blob to every client — mime-based document branches (text-document readers, library previews) deliberately exclude it, so a placeholder registered at the default renders as an unknown file rather than as content. For a text/markdown lesson, pass `mime_type: "text/markdown"`. Hub starter scaffolds do this automatically for their placeholder documents.
</Note>

### Byte-less synthetic files have no playback URL

A synthetic file registered with `size_bytes: 0` (the default) is **metadata only** — no object is ever written to storage. There is nothing to sign, so surfaces that would normally return a signed URL return `null` instead:

* In `GET /playlist/{playlist_id}/files`, a byte-less item comes back with `playback_url: null` and `expires_at: null`. The item is **still returned**, with its `title`, `description`, `position` and `file_id` intact — only the URL is absent. Each row also carries `mime_type` (the media's raw MIME, e.g. `video/mp4` or `text/markdown`) — use it as a synchronous kind hint when switching between items, so a player can pick a text vs. media renderer without waiting on a slower signal.
* In `GET /file/{file_id}/media` (and its authed sibling `GET /api/teams/{team_id}/files/{file_id}/media`), a byte-less file returns **200 with `playback_url: null` and `expires_at: null`** — not a 404. `mime_type`, `duration_seconds`, `variants` and `cards` are unaffected. This is the payload a player fetches, so branch on `playback_url == null` to render a reader instead of mounting a media provider.
* Real uploaded files in the same playlist are unaffected and keep their signed URLs, so the suppression is per item, not per response.

The content of a byte-less text lesson is its `description` field, which is markdown-capable. Render that rather than pointing a player or a download link at the file.

### Reading time for text lessons

A text lesson has no playback duration, so hub card projections estimate how long it takes to **read** instead.

* `GET /api/hub/{hub_id}/playlists/{playlist_id}/item-cards` — each row carries `reading_time_seconds` (integer, nullable). It is populated only when `asset_kind` is `document`; every other kind is `null`. A document with an empty body is `null` too, so `null` means "no estimate", not "a very short read".
* The estimate is **200 words per minute, rounded half-up, with a one-minute floor**, serialized in seconds so it composes with `duration_seconds` without a unit switch. Words are counted on the Markdown-stripped body, so fenced code blocks and link URLs don't inflate the number. Divide by 60 to render "*n* min read".
* Unlike `description`, `reading_time_seconds` is **not withheld on a locked row**. It is a size signal in the same disclosure tier as `duration_seconds` and `thumbnail`, so a future-scheduled teaser can still show "12 min read" while its body stays `null`.

<Note>
  **`duration_seconds` on a playlist card is TOTAL time, not playback time.** On `GET /api/hub/{hub_id}/playlist-cards`, `duration_seconds` is the summed playback duration of the playlist's time-based media **plus** the reading time of its `document` items — the same per-item numbers the item-cards projection returns, so a card total always agrees with the items inside it. A documents-only playlist therefore reports its read time rather than `0`. The per-**item** `duration_seconds` on item-cards is unchanged and stays playback-only. Treat the playlist total as display copy: it is not a progress denominator (member progress is completion-count based, see `viewer_progress`).

  One bound to be aware of: when computing the **playlist-card total**, each lesson body is read up to 50,000 characters (roughly 10,000 words, a \~50-minute read). A lesson longer than that contributes a clipped reading time to the total. This keeps an anonymous, unauthenticated card request from having to read unbounded prose. The per-item `reading_time_seconds` on item-cards is **not** clipped — it is always the exact figure for the full body, so an outlier lesson's own card and the playlist total can disagree.
</Note>

**Important:** the request body has no `s3_path` field — it is always server-generated (team-namespaced), so a caller can never point a registered file at another team's S3 key. Download/playback routes blindly sign whatever `s3_path` is stored, so a client-supplied path would be a cross-tenant read vector.

The route does not emit upload/transcode events and the file is not indexed into media search (nothing was actually uploaded or validated). It is **not idempotent** — every call mints a fresh `media_id` and a new row; callers that re-run must guard against duplicates themselves.

## Organize files

Folders and playlists help structure media libraries:

```text theme={null}
GET  /api/v1/teams/{team_id}/folders
POST /api/v1/teams/{team_id}/folders
GET  /api/v1/teams/{team_id}/playlists
POST /api/v1/teams/{team_id}/playlists
```

## Use media in pages and content

Attachment routes connect media to page sections, hub pages, playlist covers, and other targets. The generated API reference lists each attachment route under `media-admin-attachments`.

## Member uploads

Members can request upload URLs for member-owned uploads:

```text theme={null}
POST /api/v1/hub/{hub_id}/files/upload-url
```

## Search and transcripts

Media search supports title, description, transcript, and semantic search when embeddings are enabled.

```text theme={null}
GET /api/v1/teams/{team_id}/search/media
GET /api/v1/hub/{hub_id}/search/media
GET /api/v1/teams/{team_id}/media/{media_id}/transcript
GET /api/v1/teams/{team_id}/media/{media_id}/transcript.vtt
```

### Hub media search access control

The hub-scoped route (`/api/v1/hub/{hub_id}/search/media`) supports both authenticated and unauthenticated callers:

| Caller                   | Hub visibility    | Result                                            |
| ------------------------ | ----------------- | ------------------------------------------------- |
| Authenticated hub member | Public or private | Published media attached to visible content nodes |
| Authenticated non-member | Public hub        | Public media on public content nodes only         |
| Authenticated non-member | Private hub       | `403`                                             |
| Unauthenticated          | Public hub        | Public media on public content nodes only         |
| Unauthenticated          | Private hub       | `403`                                             |

Anonymous callers may search without a JWT on public hubs. Results are limited to media with `visibility=public` attached to published, public content nodes.

Unlike community search (`/api/v1/hubs/{hub_id}/search/community`), these 403 responses carry only a `detail` message — no machine-readable `errors[0].code` (e.g. no `hub_membership_required`/`hub_private`). Match on status code, not error code, when handling denial here.

### Rate limits (hub search)

* **Members**: 30 requests per 60 seconds, keyed by `contact_id`.
* **Anonymous**: 10 requests per 60 seconds, keyed by client IP (`X-Forwarded-For` first, then socket).

### Pagination metadata

The `meta` object on search responses includes:

| Field       | Type    | Description                                                                                                    |
| ----------- | ------- | -------------------------------------------------------------------------------------------------------------- |
| `has_more`  | boolean | `true` if additional results exist beyond the current page                                                     |
| `is_capped` | boolean | `true` if the internal RRF candidate set hit its limit (100); refining the query may surface different results |

<Note>
  `title` and `content_node_id` on media search result items are **nullable**. `title` is null when the file has no title set; `content_node_id` is null when the media item is not linked to a content node. Always handle `null` for both fields.
</Note>

## Transcript editing (MIO-751)

Any active team member can edit a media transcript after it has been generated — this uses the same team-membership gate as the other transcript routes, not an admin-only check. Edits are non-destructive: each edit creates a new active version while archiving the previous one, forming an append-only version ledger. Only one version is active at a time.

```text theme={null}
GET   /api/v1/teams/{team_id}/media/{media_id}/transcript/content          word-level editable content
PATCH /api/v1/teams/{team_id}/media/{media_id}/transcript                  edit transcript (creates new active version)
GET   /api/v1/teams/{team_id}/media/{media_id}/transcript/versions         list all versions
GET   /api/v1/teams/{team_id}/media/{media_id}/transcript/versions/{version}  fetch a specific version
POST  /api/v1/teams/{team_id}/media/{media_id}/transcript/revert           revert to a prior version (append-only)
```

All routes are JSON:API and require team-member authentication.

### Edit flow

1. Fetch word-level content via `GET .../transcript/content` to get the current editable representation.
2. Submit edits via `PATCH .../transcript`. The backend creates a new active version, archives the prior version, then refreshes lexical search indexes and semantic embeddings automatically.
3. To undo an edit, call `POST .../transcript/revert` with the target version number. Revert is append-only — it creates a new version whose content matches the specified prior version; it does not delete history.

### VTT captions after editing

Edited versions do not yet regenerate WebVTT captions. `GET .../transcript.vtt` returns `404` for any version created by an edit. Caption regeneration from edited words is a planned follow-up.

### Transcription language

A per-team default transcription language controls which language the transcription provider uses. The language can be overridden per media item.

```text theme={null}
GET /api/v1/teams/{team_id}/settings/transcription-language   read current default
PUT /api/v1/teams/{team_id}/settings/transcription-language   set default
```

**Language resolution order:** `media.transcription_language` → team setting → server default (`en`).

Setting the value to `auto` enables provider-side language detection.

### Domain events

When a transcript is edited or reverted the following events are emitted:

| Event                       | Fired when                                                   |
| --------------------------- | ------------------------------------------------------------ |
| `media.transcript.edited`   | `PATCH .../transcript` succeeds and a new version is created |
| `media.transcript.reverted` | `POST .../transcript/revert` succeeds                        |

## Chapters: authored vs. auto-generated

Chapters mark timestamped sections within a media item. A file can have **authored** chapters (admin-entered, transcript-independent) or **auto** chapters (derived from the current transcript) — never both at once. Authored chapters, when present, always take precedence.

```text theme={null}
GET /api/v1/teams/{team_id}/files/{id}/chapters
PUT /api/v1/teams/{team_id}/files/{id}/chapters
```

`GET` returns the **effective** chapter list: authored rows if any are active, otherwise the current transcript's auto chapters, otherwise an empty list. It never 404s for "no chapters" — an authorized file with none returns `data: []`.

`PUT` atomically replaces the full authored chapter list for the file:

```json theme={null}
{
  "data": {
    "type": "file_chapters",
    "attributes": {
      "chapters": [
        { "title": "Introduction", "start": 0 },
        { "id": "existing-chapter-id", "title": "Getting started", "start": 15000 }
      ]
    }
  }
}
```

* `start` is milliseconds from the beginning of the media.
* Include a chapter's existing `id` to keep updating it across calls; omit `id` for a new chapter. An `id` that doesn't belong to an active authored row on this file is a 422 (`Chapter '{id}' does not belong to file '{file_id}'.`), not a 404.
* Up to 100 chapters per file; duplicate `id`s or duplicate `start` values in the same request are rejected.
* Every `start` must be `<=` the media's known duration in milliseconds, or the request is rejected with 422.

**Send `PUT` with an empty `chapters` array (`{"data": {"type": "file_chapters", "attributes": {"chapters": []}}}`) to clear all authored chapters and revert the file to auto (transcript-derived) chapters.**

Both endpoints respond with the same effective-chapter resource shape, including `source` (`"authored"` or `"auto"`) so clients can tell which mode is active:

```json theme={null}
{
  "data": [
    {
      "type": "file_chapters",
      "id": "chapter-uuid",
      "attributes": {
        "title": "Introduction",
        "start": 0,
        "end": 15000,
        "position": 0,
        "source": "authored"
      }
    }
  ]
}
```

Replacing authored chapters regenerates the authored chapter VTT file and refreshes chapter search indexing. If a video's media is re-uploaded/replaced, previously authored chapters are re-applied against the new media once it reaches READY.

## Image variant URLs and expiry

Imgproxy variant URLs are signed and time-limited. The default TTL is **24 hours**. After the URL expires imgproxy rejects the request regardless of the signature.

The API returns a `variants_expires_at` timestamp alongside the `variants` map, and `cover_url_expires_at` alongside `cover_url` on playlist resources, so clients know when to refresh:

```json theme={null}
{
  "data": {
    "attributes": {
      "variants": {
        "small-400": "https://imgproxy.member.dev/<signature>/exp:1749740400/rs:fill:400/...",
        "large-1440": "https://imgproxy.member.dev/<signature>/exp:1749740400/rs:fill:1440/..."
      },
      "variants_expires_at": "2026-06-10T14:00:00Z",
      "cover_url": "https://imgproxy.member.dev/<signature>/exp:1749740400/...",
      "cover_url_expires_at": "2026-06-10T14:00:00Z",
      "expires_at": "2026-06-10T14:05:00Z"
    }
  }
}
```

`expires_at` describes the `playback_url` (CloudFront, \~300 s TTL). `variants_expires_at` describes the imgproxy `variants` map (24–48 h TTL). Use the right timestamp for each URL type.

Both `playback_url` and `expires_at` are nullable, and they are always null together — a byte-less synthetic file has no stored object to sign (see [Byte-less synthetic files have no playback URL](#byte-less-synthetic-files-have-no-playback-url)). Treat a null `playback_url` as "this item is not playable media" rather than as an error.

The `exp:{unix_timestamp}` component is sealed inside the HMAC signature. imgproxy rejects any request whose current clock time exceeds the embedded expiry.

**Client guidance:** do not cache imgproxy URLs (variants, cover) beyond their `*_expires_at` companion. Re-fetch the media or playlist resource to get a fresh set of signed URLs. This closes the "leaked URL grants permanent access" risk class — a URL that leaks after its expiry is unusable.

Avatar URLs on community and discussion/comment resources also carry matching expiry fields (`photo_url_expires_at`, `photo_thumbnail_url_expires_at`, `author_photo_url_expires_at`, `author_photo_thumbnail_url_expires_at`). Null means no image or unsigned dev mode; re-fetch the parent resource for a fresh URL.

## Durable image URLs (MIO-2525)

The `variants` map above is great for displaying an image right after you fetch it, but it's the wrong thing to persist. Every URL in `variants` is imgproxy-signed with a short TTL (24–48 hours, see `variants_expires_at`), and a fresh set is signed on every read. If you inline one of those URLs into stored content — a page tree, a rich-text block, anything that outlives the request — it rots within about two days.

For that use case, image files also expose `durable_variants`: a map of the same preset names to a **stable, non-expiring** URL of the form:

```text theme={null}
{API_ORIGIN}/api/v1/file/{file_id}/image/{variant}
```

This route never carries a signature or `exp` in the URL itself. Instead, it 302-redirects to a freshly-signed variant on every request, resolving access at request time rather than at sign time. Store or inline the `durable_variants` URL, not the `variants` URL — it's the one that's safe to keep around.

<Note>
  `durable_variants` is present on image files and empty (`{}`) for non-image files, same as `variants`.
</Note>

<Note>
  The public media/playlist read routes on this page (`/file/{file_id}/...`, `/playlist/{playlist_id}/...`) are canonically served under `/api/v1` (MIO-2839). The un-prefixed root paths remain live for backward compatibility but are undocumented legacy aliases hidden from the OpenAPI schema — the edge Worker in front of custom branded domains only forwards `/api/v1/*`, so the root form is unreachable there. Always build clients against the `/api/v1` form.
</Note>

```json theme={null}
{
  "data": {
    "attributes": {
      "variants": {
        "thumbnail-160": "https://imgproxy.member.dev/<signature>/exp:1749740400/rs:fill:160:160/...",
        "large-1440": "https://imgproxy.member.dev/<signature>/exp:1749740400/rs:fill:1440/..."
      },
      "variants_expires_at": "2026-06-10T14:00:00Z",
      "durable_variants": {
        "thumbnail-160": "https://api.member.dev/api/v1/file/f_abc123/image/thumbnail-160",
        "medium-720": "https://api.member.dev/api/v1/file/f_abc123/image/medium-720",
        "large-1440": "https://api.member.dev/api/v1/file/f_abc123/image/large-1440",
        "webp-medium": "https://api.member.dev/api/v1/file/f_abc123/image/webp-medium"
      }
    }
  }
}
```

Fetch this from the file resource:

```text theme={null}
GET /api/teams/{team_id}/files/{file_id}
```

`data.attributes.durable_variants` returns all four presets: `thumbnail-160`, `medium-720`, `large-1440`, `webp-medium`.

From the CLI, `mio media files durable-url <file_id> --hub <hub> [--preset <name>] [--publish]` prints these URLs with `?hub_id=` already appended — see [Durable image URLs for page trees](#durable-image-urls-for-page-trees).

### Hub scoping is required

The durable image route is hub-scoped — append `?hub_id={hub_id}` to the stored URL when rendering inside a hub:

```text theme={null}
GET /api/v1/file/{file_id}/image/{variant}?hub_id={hub_id}
```

The image must be published into that hub (public hub media). Access is re-evaluated on **every** request, not cached from when the URL was minted, so private, unpublished, members-only, cross-tenant, or not-yet-scheduled media is never served to an anonymous viewer — those all return **404**, with no way to distinguish "doesn't exist" from "exists but denied." A request without a resolvable `?hub_id=` also returns 404.

<Warning>
  Inline the full `durable_variants` URL **with `?hub_id=` appended**. Omitting `hub_id`, or inlining the bare `durable_variants` URL without it, produces a 404 for every viewer.
</Warning>

### Anonymous access

Logged-out visitors can load durable images for public hub media — the route 302s straight to a signed image URL, no auth required for public content.

**Caching caveat:** anonymous 302 responses are cacheable at the edge. If media is later made private, a previously-cached *public* redirect can keep serving until that CDN cache entry expires (bounded by the signed image target's own \~24-hour expiry either way). Credentialed (member) requests are never shared-cacheable, so this staleness window only applies to anonymous traffic.

## HLS video playback and session expiry

READY transcoded videos are served via CloudFront signed HLS URLs. The signed custom policy grants access to every playlist and segment file under the video's media folder for the TTL window (default: **4 hours**).

`GET /api/v1/file/{file_id}/media` returns `data.meta.playback` for READY videos:

```json theme={null}
{
  "data": {
    "type": "media_playback",
    "meta": {
      "playback": {
        "kind": "video",
        "hls_manifest": "https://cdn.member.dev/team/media/media/hls/master.m3u8?Policy=...&Signature=...&Key-Pair-Id=...",
        "hls_expires_at": "2026-06-12T10:12:25+00:00",
        "duration_seconds": 240,
        "captions": [],
        "chapters": [],
        "thumbnails": [{ "language": null, "url": "https://cdn.member.dev/team/media/media/media.vtt?Policy=...&Signature=...&Key-Pair-Id=..." }]
      }
    }
  }
}
```

The `hls_expires_at` field is the exact UTC datetime when the CloudFront signed policy expires. CloudFront validates the policy on every HLS child playlist and segment request, so a long video will fail mid-playback if the policy expires before the player finishes.

`thumbnails` is a signed WebVTT storyboard track for hover seek-preview thumbnails, sharing the same shape as `captions`/`chapters` (`language` is always `null` since a storyboard has no spoken language). It is populated whenever the transcoder wrote a storyboard sidecar for the video, independent of transcription — an untranscribed video can still have thumbnails. It's `[]` when no sidecar exists. The cue image references inside the VTT resolve relative to the VTT URL and do not inherit its signed query parameters automatically; the player must append the same `Policy`/`Signature`/`Key-Pair-Id` params to those image requests (the folder-wildcard policy already authorizes them).

**Client guidance:** refresh the playback payload (re-call `GET /api/v1/file/{file_id}/media`) before `hls_expires_at`. Caption, chapter, and thumbnail URLs share the same TTL and are refreshed alongside the manifest. The player must propagate the `Policy`, `Signature`, and `Key-Pair-Id` query parameters from `hls_manifest` to all child HLS requests (quality playlists and `.ts` segment files) as well as to storyboard image requests.
