Skip to main content
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 transcriptsget / 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). For a URL you can inline into a page-builder image node, use durable-url — it prints the non-expiring durable image URL, already scoped and ready to store:
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

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 filemedia 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.
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).

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.
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.
For the full walk-through of assembling these into a hub, see Create a hub.

Upload as an admin

Admin media routes are team-scoped:
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)

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"):
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.
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.

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.
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.
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:

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:

Search and transcripts

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

Hub media search access control

The hub-scoped route (/api/v1/hub/{hub_id}/search/media) supports both authenticated and unauthenticated callers: 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.
  • 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:
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.

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.
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.
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:

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.
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:
  • 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 ids 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:
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:
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). 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:
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.
durable_variants is present on image files and empty ({}) for non-image files, same as variants.
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.
Fetch this from the file resource:
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.

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:
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.
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.

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:
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.