Captions API
WebVTT caption tracks for lessons, plus parsed transcript
OptiLearn lessons can carry one or more WebVTT (.vtt) caption tracks per video. Tracks back the native HTML5 <track> element and a separate transcript panel that lets students click cues to seek the player.
Storage: caption files live in R2 under lms/<institutionId>/courses/<courseId>/lessons/<lessonId>/captions/<file>.
Authoring is gated by canManageCourses(user). Reads are open to anyone who can see the lesson — the lesson route itself enforces enrollment.
GET /api/lessons/[id]/captions
List every CaptionTrack on a lesson. Ordered with the default track first so <track default> lands on the right element.
Response
{
"data": [
{
"id": "ct_...",
"lessonId": "...",
"blockId": null,
"language": "en",
"label": "English",
"vttUrl": "https://files.opticrm.app/lms/.../captions/lecture.vtt",
"isDefault": true,
"createdAt": "..."
}
]
}
blockId distinguishes tracks when a lesson has multiple VideoBlock blocks (Content v2). Null = lesson-level video.
POST /api/lessons/[id]/captions
Multipart upload of a .vtt file. multipart/form-data body:
| Field | Required | Notes |
|---|---|---|
file | yes | The .vtt file (max 1 MB) |
language | yes | ISO 639-1 code, must be in supported list |
label | yes | Human-readable, 1–80 chars |
blockId | no | When the lesson has multiple video blocks |
isDefault | no | true / 1 / on to win the <track default> slot |
Validation
- File must be
text/vtt,text/plain, empty mime +.vttextension, or have a.vttextension (some browsers report empty mime for VTT) - File contents must start with the
WEBVTTmagic header languagemust be inSUPPORTED_CAPTION_LANGUAGES- File size capped at 1 MB
Side effects
- Uploaded to R2 server-side (small text payload, no presigned PUT needed)
- If
isDefault: true, any existing default track on the same(lesson, blockId)is demoted - New
CaptionTrackrow written
Response: the created track row, status 201.
curl -X POST /api/lessons/lsn_123/captions \
-F "file=@english.vtt" \
-F "language=en" \
-F "label=English" \
-F "isDefault=true"
PATCH /api/lessons/[id]/captions/[captionId]
Edit label, language, or isDefault. Body:
{
"label": "English (UK)",
"language": "en",
"isDefault": true
}
All fields optional. Toggling isDefault: true demotes other defaults on the same (lesson, blockId) scope.
Response: the updated row.
DELETE /api/lessons/[id]/captions/[captionId]
Removes the row + the underlying R2 object. R2 cleanup is best-effort — if the bucket delete fails (object already gone, transient error), the row is still dropped so the UI doesn't get stuck.
Response: { data: { id } }.
GET /api/lessons/[id]/captions/[captionId]/transcript
Fetches the .vtt file from R2, parses it into a JSON cue array, and returns it. Backs the transcript panel — clicking a cue seeks the player.
Response
{
"data": {
"captionId": "ct_...",
"language": "en",
"label": "English",
"cues": [
{ "start": 0.0, "end": 4.2, "text": "Welcome to today's lecture." },
{ "start": 4.2, "end": 9.1, "text": "We'll cover three main ideas..." }
]
}
}
Caching
revalidate = 3600(1 hour) — VTT files are immutable post-upload (re-uploading creates a new track + R2 key)- Response carries
Cache-Control: private, max-age=3600so any in-front CDN that varies on Cookie can still share entries underneath
Errors
| Status | Code | Reason |
|---|---|---|
| 404 | NOT_FOUND | Caption track doesn't exist |
| 502 | INTERNAL_ERROR | R2 fetch failed |
Related
- Architecture — File Uploads
- i18n — UI translation (separate from caption languages)