Skip to main content
Media stores files for content, pages, playlists, community attachments, and public embeds. A file is one uploaded asset: a video, an audio track, an image, a PDF, or another document. Your team’s media library holds every file the team has uploaded. Files belong to the team, not to a single hub, so any of your team’s hubs can use them. You can work with the library in three ways:
  • From the admin: the Library page at app.member.dev, the quickest way to upload and organize files.
  • With the mio CLI: the whole media lifecycle from a terminal, including publishing to a hub.
  • With the API: the requests the admin and the CLI send, for your own integration.

From the admin

The admin is the web app at app.member.dev where creators run their hubs. Its Library page is where you upload files, sort them into folders, and build playlists. Anyone on the team can use it. If you are new to the admin, Getting started in the admin takes you from sign-up to your first hub. Two more terms:
  • A folder groups files for your own housekeeping. Folders can sit inside other folders. Members never see them.
  • A playlist is an ordered list of files, such as the lessons of a course. You can publish a playlist to a hub, and one file can be in several playlists.
The Library is team-level. Choosing which files and playlists a hub shows its members belongs to the hub, not to the Library, and the admin has no screen for it yet. Until it does, publish with the CLI or the API, as described in Publish media to a hub.

Open the Library

Select Library in the admin’s sidebar. The Upload media to your library card on the Getting started page opens it too, with Go to library. The page has two parts:
  • A left panel titled Library. Its Folders section starts with All files and then shows your folders as a nested tree: a folder that holds other folders has a chevron that opens and closes them. A Library section below lists two more pages, Playlists and Library settings. On a phone, open the panel with the navigation button above the files.
  • The main area. Its heading says where you are: All files, or the path of the open folder. Above the files, an Upload row opens the upload dialog, and a toolbar holds a Search files… box and a grid/list switch. When a folder has subfolders, they are listed above its files.
Use the grid/list switch to change how files are shown:
  • Grid view shows a card for each file, with a thumbnail and the title. A card shows Processing while the file is still being processed after upload.
  • List view shows the same files as a table. Select the Title or Size column heading to sort by it.
In either view, select a file to open its detail panel. To find files:
  • Search matches text in file titles and ignores case. It does not search transcripts. To search transcripts, use Search and transcripts.
  • Sort in list view: select the Title or Size column heading. Without a sort, files stay in Default order (the order the API returns them).
  • Folders: select a folder in the left panel to see only the files stored directly in it. The heading becomes a path such as All files › Courses › Module 1; select any part of it to go up a level, or use the back button. Select All files to see every file again.
The Library keeps what you are looking at in the page address, so you can bookmark a view or send it to a teammate. For example, /library?view=list&sort=title opens the list view sorted by title, and /library?media=<file_id> opens that file’s detail panel.
The Library is in alpha. The Library loads 20 files at a time as you scroll, and sort works only on the files loaded so far. Keep scrolling to the end to see every file in the right order. The folder tree shows the first 5,000 folders; if you have more, a note under the tree says so.

Upload files

  1. Select Upload in the row above the files. The Upload media dialog opens.
  2. Drop files onto the drop zone, or select Browse files. You can add several files at once.
  3. Each file gets a row with a progress bar. Up to three files upload at the same time; the rest wait as Queued. A row reads Done when its file has uploaded.
In each row:
  • The × button cancels an upload in progress.
  • Retry starts a failed or canceled upload again. A failed row shows the reason, such as a full storage quota or too many uploads in the last hour.
  • The trash button clears the row from the list. It does not delete a file that has already uploaded.
You can close the dialog with Close while files are still uploading. Stay on the Library page until they finish. Files larger than 100 MB (104,857,600 bytes, the same threshold as the CLI) upload in parts automatically. There is nothing to switch on. A new file:
  • takes its file name, extension included, as its title. Change it in the detail panel.
  • lands at the top level of the Library, even when a folder is selected. Move it afterwards.
  • starts as private. Select the file and choose Change visibility in the selection bar to set Private, Public, or Unlisted; see Visibility: the three-layer model.
With the API, an upload is three requests: create the file, PUT the bytes to storage, then finalize. See Upload with the API.

Organize files in folders

Create a folder: select the + button (New folder) next to Folders in the left panel. In the Create folder dialog, enter a Name of up to 255 characters and select Create. When a folder is open, the new folder goes inside it; otherwise it goes at the top level. Folders show as a nested tree, and you can also open a subfolder from the list above the files. Rename, move, or delete a folder: open the ⋯ menu (Folder actions) next to the folder in the left panel.
  • Rename opens Rename folder. Enter the new name and select Rename.
  • Move opens Move folder. Choose the new parent folder, or No folder (root) for the top level, and select Move here. Everything inside the folder moves with it. You cannot move a folder into itself or into one of its own subfolders, so those are greyed out.
  • Delete asks you to confirm, then deletes the folder. You can delete only an empty folder: while it still holds files or other folders, the admin shows an error and keeps it. Move or delete its contents first.
Move files into a folder: select one or more files (see Select several files) and choose Move to folder, or use Move in a file’s detail panel. In the Move to folder dialog, choose a folder, or No folder (root) to take the files out of any folder, and select Move here. With the API, see Organize files with the API.

Select several files

In grid view, point at a card and select the check box in its top-left corner. In list view, use the check box at the start of each row. A bar appears at the bottom of the page with the number selected and these actions: The × button in the bar clears the selection. Delete selected (the trash button) deletes the selected files, and you can also delete files one at a time from the detail panel.

Build playlists

Playlists have their own page. In the left panel, select Playlists (under Library) to open Library playlists. It has two tabs, All and Deleted. Create a playlist: select New playlist in the page header. In the New library playlist dialog, enter a Name of up to 255 characters and create it. A playlist created this way starts as private. To change that, or to add a description, open the playlist’s ⋯ menu (Playlist actions) and choose Settings. Add files to a playlist:
  1. Select the files (see Select several files).
  2. Choose Add to playlist in the selection bar.
  3. Choose a playlist and select Add.
The files go to the start of the playlist, in the order you selected them, and the admin confirms how many it added, for example Added 3 files. A playlist holds each file only once. A file that is already in it is not added again, and for now the admin counts it as failed, for example Added 2 files, 1 failed. Reorder and remove files: on Library playlists, select a playlist to open it. Its files are listed in playlist order, with the length of each video or audio file.
  • Drag a file by its handle to move it. With a keyboard, focus the handle, press Space or Enter to pick the file up, use the arrow keys to move it, then press Space to drop it or Escape to cancel.
  • The remove button at the end of a row takes the file out of the playlist. The file stays in the Library.
  • A file that was deleted from the Library shows as Unavailable file until you remove it.
Select the playlist again to close it. Rename or delete a playlist: open the ⋯ menu next to the playlist on Library playlists. Rename opens Rename playlist; enter the new title and select Rename. Delete asks you to confirm, then deletes the playlist. Its files stay in the Library. A deleted playlist moves to the Deleted tab, where Restore brings it back; a restored playlist stays unpublished until you publish it to a hub.

Library settings

Select Library settings in the left panel (under Library) to set how new uploads are handled. The Uploads group has Default language (“What transcripts are written in, unless a file says otherwise.”). Make your changes and select Save, or Discard to drop them.

View and edit a file

Select a file in grid or list view. Its detail panel opens on the right, headed with the file’s title. At the top, the panel previews the file:
  • Video and audio play in the Membership.io player. While a new video or audio file is still processing, the player shows that instead.
  • Images are shown in the panel.
  • PDFs open in an embedded viewer.
  • Other file types show Preview not available.
Below the preview, edit the Title (required) and the Description, then select Save. The buttons at the bottom:
  • Move opens the Move to folder dialog for this file.
  • Replace opens Replace media. Drop in, or browse to, one new file to take this file’s place. The file keeps its ID, its folder, and its place in every playlist, and the new bytes are processed again. The admin cannot replace a file with one larger than 100 MB; use mio media files replace, which uploads in parts (see Wait for processing, and replace a file).
  • Delete asks Delete file?, then deletes the file and its stored copy. This cannot be undone. A file whose upload has not finished cannot be deleted yet; the admin shows an error instead.
  • Download opens the original file in a new browser tab. If your browser blocks pop-ups, it opens in the current tab instead.

The folder tree and Library pages

The Library has a Library column on the left with All files, a Folders section, Playlists, and Library settings. The + button (New folder) next to Folders creates a folder. With no folders, the section says No folders. The folder tree. Select the arrow beside a folder to show or hide its subfolders, or select the folder’s name to open it. Each row has a Folder actions menu (Rename, Move, Delete), which appears when you point at the row or focus it. Delete works as in Organize files in folders, and the dialog asks you to confirm. The tree works with the keyboard. Focus a folder, then press: Playlists. Select Playlists to see a page titled Library playlists, with All and Deleted tabs. Each playlist is a row with its title, its description and the date it was last updated, and a visibility label. If there are many playlists, select Load more. With none, the page says No library playlists yet.
  • New playlist (top right) opens New library playlist. Enter a Name and select Create playlist.
  • Select a row to open it. Its files are listed in order with a drag handle and a remove button on each. Drag a file, or pick it up with the space or Enter key and move it with the arrow keys, to reorder. A playlist with no files says “No items in this playlist yet.”
  • The Playlist actions menu on a row has Settings, Rename, and Delete. Settings opens Playlist settings, where you change the title, description, visibility, which hub it is Available in (Every hub or one hub), the cover image, and attached documents. Delete asks you to confirm. A deleted playlist keeps its files in the Library and can be restored.
  • The Deleted tab lists deleted playlists, each with a Restore button. Restored playlists stay unpublished until you publish them to a hub.
Library settings. Select Library settings to set the Default language for transcripts, under Uploads: what they are written in unless a file says otherwise. It has two special choices besides the languages:
  • Use the system default uses the Membership.io default language.
  • Detect automatically lets the transcription service detect the language of each file.
When you change it, an Unsaved changes bar appears with Discard and Save. Select Save to apply your choice, and the admin confirms with “Library settings saved.” The change affects new transcription requests, not transcripts that already exist. See also Transcription language. Not available yet in these pages: storage usage, and a switch to turn transcription of new uploads on or off.

What the Library does not do yet

For these, use the mio media command group or the API:
  • publish files and playlists to a hub, or change what a hub shows its members;
  • edit transcripts, chapters, or cards, or search transcripts.

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; files over 100 MB upload in parts automatically (see Large files: multipart). 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.

Large files: multipart

mio media files upload and mio media files replace switch to S3 multipart automatically for any file larger than 100 MB (104,857,600 bytes; the CLI’s MB is 1024 × 1024 bytes). A file of 100 MB or less goes up as one presigned PUT, so a 45 MB video or an 18 MB PDF is a single PUT with no flag, and a 101 MB file goes multipart with no flag.
  • --multipart forces the multipart path at any size.
  • --part-size-mb sets the part size in MB: default 16, minimum 5 (S3’s floor for every part except the last). It is read only on the multipart path. A value under 5 on that path exits 2 before any request is sent.
Either path ends with the file at status_upload: READY. A multipart upload runs init → presign and PUT each part → complete → finalize. A multipart replace ends at its complete call, which relinks the file itself, so there is no separate finalize. --help lags on released binaries. Through v0.22.0, mio media files upload --help says “Single-part upload only (multipart for very large files is a follow-on)” and mio media files replace --help says “Single-part upload.” Both sentences are wrong: those versions dispatch exactly as described here, and multipart has been automatic above 100 MB since v0.8.0. From the release after v0.22.0, both helps state this threshold, rendered from the value the CLI dispatches on.

Wait for processing, and replace a file

upload --wait holds until the file finishes processing. --timeout (default 5m) caps the wait, and running out, or a FAILED status, exits 1. For a video it waits at most 30 seconds, or --timeout if that is shorter, for the transcode to start. A backend with video processing switched off never starts one, so after that window the command warns on stderr and exits 0. The warning also fires when a busy queue is slow to start, so check mio media files retrieve <file_id> before treating exit 0 as transcoded. mio media files replace <file_id> <path> swaps in new bytes and keeps the file id. The file gets a new media_id. Until the new bytes are processed, status_transcode, status_transcribe and duration_seconds read null and the old transcript is detached. The file’s timed cards are cleared. From v0.24.0 (the release after v0.23.0), replace takes --wait and --timeout too, and they work as they do on upload with one more condition: --wait first waits until the file points at the replacement’s media_id, so it cannot return on the old media’s READY. On v0.23.0 and earlier, replace --wait exits 2 with unknown flag: --wait; instead, poll mio media files retrieve <file_id> until media_id changes and the statuses you need read READY.

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 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.
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 with the API

These are the requests the admin’s Upload row and mio media files upload send. Every media route is team-scoped: the team id is in the path, and the caller needs a platform access token for a member of that team or a team API key bound to it. See Authentication and API keys.
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. For example, to upload one video in a single PUT:
POST /files returns 201. All three attributes are required: title (1 to 512 characters), mime_type, and size_bytes. The request has no folder or visibility field, so a new file starts at the top level of the library and private; change either with PATCH /files/{file_id}. The PUT URL expires after 15 minutes. POST /files returns 413 when the upload would take the team over its storage quota and 429 when the team has made too many uploads in the last hour. /finalize returns 422 when storage has no object at the expected path or the uploaded object is empty. A zero-byte upload is recorded as FAILED.

List visibility and stale uploads

The default GET /files list omits upload-source media whose upload status is PENDING or FAILED once its media row is more than 15 minutes old. The filter is applied before cursor pagination. This affects the list only: GET /files/{file_id} can still return the file while its non-deleted file row and media row exist. A background cleanup checks every 15 minutes and deletes standalone, unreferenced library uploads that remain PENDING for more than 24 hours. Active multipart uploads are excluded from this cleanup; a separate seven-day sweep handles incomplete multipart uploads. Reference: Create file · Finalize file · List files · Update file · Delete file · Download a file If the S3 PUT fails, DELETE the file to clean up the orphaned row: an editor library upload still in PENDING can be deleted. Platform team members and team API keys may delete a READY or FAILED file, and a PENDING file only when it is an editor library upload (other PENDING files return 422). A hub owner using a contact session may delete only that PENDING case and gets 404 for anything else. To upload in parts instead, use the multipart routes. The API sets no size threshold; the CLI’s 100 MB switch is a client-side choice. S3 itself caps a single PUT at 5 GB, so a larger file has to use these routes.
PUT each part’s bytes to its part_url and keep the ETag S3 returns. After complete, call the same /finalize. The admin switches to these routes for files larger than 100 MB, the same threshold as the CLI, with parts of 10 MB. For a very large file it makes the parts bigger, so no upload needs more than 10,000 parts. Reference: Multipart init · Multipart part URL · Multipart complete · Multipart abort

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.The default ignores asset_kind: a pdf registered without a mime_type is stored as application/octet-stream too. mio media files register-synthetic sends a mime_type only when you pass --mime-type, so pass it with a PDF:
The --help example on v0.23.0 and earlier leaves --mime-type out and stores application/octet-stream; from v0.24.0 it passes application/pdf.

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 with the API

Folders and playlists help structure media libraries. These routes are the ones behind the Library’s folder and playlist actions (see From the admin):
Create a folder, then move a file into it. A file’s PATCH body needs its id:
Create a playlist, then add the file to it:
A playlist’s visibility defaults to private. An item’s position is 0-based and defaults to 0: the new item goes in at that position, the items from there on move one place later, and positions stay 0 to N−1. A playlist holds each file once; adding a file that is already in it returns 422 (Item already in playlist). Deleting a folder that still holds files or folders returns 409; moving a folder into itself or one of its subfolders returns 422. Reference: List folders · Create folder · Rename folder · Move folder · Delete folder · Create playlist · Update playlist · Delete playlist · List playlist items · Attach playlist item · Update playlist item · Delete playlist item

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.

Linking a hub search result

Each row on the hub route carries the ids a client needs to open the result. Check them in this order: All three are nullable. A row with none of them set has no page the caller can open, so render it as not clickable.

Decoration and lesson attachments

The hub route leaves out media that only decorate something. A file is left out when every attachment it has in this hub is a decorative one: hub branding art (logo, favicon, social image, background, widget and auth images) or a lesson or playlist thumbnail. Only attachments on live targets in the searched hub count, so a file used as a playlist cover in another hub is not hidden here, and attachments on comments, messages or deleted items are ignored. A file with no attachments at all, such as one published only inside a playlist, still appears, and so does a lesson’s own media. The team route (/api/v1/teams/{team_id}/search/media) still returns everything. Each row also carries attachment_role, the role the media plays on content_node_id: To keep a lesson’s extra files out of a list of lessons, drop rows whose attachment_role is attachment. The field is optional, so treat a missing value as null.
  • 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 and audio files are served via CloudFront signed HLS URLs. The signed custom policy grants access to every playlist and segment file under the file’s media folder for the TTL window (default: 4 hours). GET /api/v1/file/{file_id}/media returns data.meta.playback for READY videos and READY audio. kind is "video" or "audio"; branch on it rather than treating the presence of playback as meaning video. Audio has no storyboard, so its thumbnails is always [], and data.attributes.playback_url still points at the original file as a fallback. A video example:
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.