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). Alsoreplace,finalize,transcode,register-synthetic,durable-url, and per-filecards/chapters.media folders— CRUD plusmove.media playlists— CRUD,set-cover, andplaylists itemsto curate contents.media search— hybrid search over the team’s transcripts.media transcripts—get/vtt/content/versions, plusedit/revertfor 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
Thevariants 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:
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.”- The file —
media files uploadcreates files atvisibility: privateby default. Change it withmedia files update <id> --visibility public(values:public,unlisted,private). - The playlist it’s reached through (if any) — also
public/unlisted/private; defaults toprivateon create. Set it withmedia playlists update <id> --visibility public. - The hub publication — the
hub_mediarow written bymedia hub-media publish/media hub-playlists publish --visibility(valuespublic/members/private). This defaults tomembers, so a freshly published item is invisible to anonymous visitors until you republish with--visibility public.
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.
Upload as an admin
Admin media routes are team-scoped: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)
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 withsize_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 withplayback_url: nullandexpires_at: null. The item is still returned, with itstitle,description,positionandfile_idintact — only the URL is absent. Each row also carriesmime_type(the media’s raw MIME, e.g.video/mp4ortext/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 siblingGET /api/teams/{team_id}/files/{file_id}/media), a byte-less file returns 200 withplayback_url: nullandexpires_at: null— not a 404.mime_type,duration_seconds,variantsandcardsare unaffected. This is the payload a player fetches, so branch onplayback_url == nullto 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.
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 carriesreading_time_seconds(integer, nullable). It is populated only whenasset_kindisdocument; every other kind isnull. A document with an empty body isnulltoo, sonullmeans “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_secondswithout 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_secondsis not withheld on a locked row. It is a size signal in the same disclosure tier asduration_secondsandthumbnail, so a future-scheduled teaser can still show “12 min read” while its body staysnull.
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.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 undermedia-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.
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-Forfirst, then socket).
Pagination metadata
Themeta 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.Edit flow
- Fetch word-level content via
GET .../transcript/contentto get the current editable representation. - 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. - To undo an edit, call
POST .../transcript/revertwith 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.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:
startis milliseconds from the beginning of the media.- Include a chapter’s existing
idto keep updating it across calls; omitidfor a new chapter. Anidthat 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 duplicatestartvalues in the same request are rejected. - Every
startmust be<=the media’s known duration in milliseconds, or the request is rejected with 422.
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:
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 avariants_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)
Thevariants 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:
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.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:
?hub_id= also returns 404.
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:
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.