Trailer Videos
How to upload, replace, clear, and render course trailer videos after the direct bucket MP4 cutover.
Course Trailer Videos
Audience: admin frontend, web/mobile frontend, backend Scope:
course-admin.controller.ts,course-admin.service.ts,course-admin-read.service.ts,discovery-customer.service.ts,packages/storage/src/upload-type.ts
What changed
Course trailers are now direct MP4 objects in storage, not video_asset records and not part of the lesson-video HLS pipeline.
Current contract:
courses.trailerRefstores the canonical storage key- read APIs resolve that key into
trailerUrl - trailers use
UploadType.TRAILER, which maps topublic/trailer/* - frontend should play the trailer from
trailerUrl
Example:
{
"trailerRef": "public/trailer/course/trading-course.mp4",
"trailerUrl": "https://cdn.bullhouse.com/public/trailer/course/trading-course.mp4"
}Because trailer objects are public storage objects, they do not need signed URL refresh in bucket mode.
Upload or replace a trailer
Endpoint
POST /api/admin/courses/:id/trailer
Authorization: Bearer <admin-token>
Content-Type: multipart/form-dataField name:
file: binary MP4 file
Requirements from current implementation:
- permission:
COURSES_UPDATE - MIME must be
video/mp4 - filename extension must be
.mp4 - max size is
COURSE_TRAILER_MAX_FILE_SIZE_MB(default250)
The controller uses a dedicated trailer Multer storage + file-size cap, then CourseAdminService.uploadTrailer() persists the uploaded key into courses.trailerRef.
cURL example
curl -X POST "https://api.example.com/api/admin/courses/42/trailer" \
-H "Authorization: Bearer $TOKEN" \
-F "file=@/absolute/path/to/trailer.mp4"Success response
{
"message": "Course trailer uploaded successfully",
"data": {
"id": 42,
"title": "Trading Masterclass",
"trailerRef": "public/trailer/course/trading-masterclass.mp4",
"trailerUrl": "https://cdn.example.com/public/trailer/course/trading-masterclass.mp4"
}
}Replace semantics
The backend currently follows this order:
- validate file
- upload new object with
UploadType.TRAILER - update
courses.trailerRef - best-effort delete previous managed trailer object
This means the course only points at the new trailer after upload succeeds.
Important cleanup rule:
- old objects are deleted only when the previous ref starts with
public/trailer/ - legacy non-storage refs (for example old UUID-style trailer references) are not deleted automatically
Clear a trailer
Endpoint
DELETE /api/admin/courses/:id/trailer
Authorization: Bearer <admin-token>Success response
{
"message": "Course trailer cleared successfully",
"data": {
"id": 42,
"trailerRef": null,
"trailerUrl": null
}
}Current behavior:
- backend sets
courses.trailerRef = null - then best-effort deletes the old managed object if it was under
public/trailer/*
How frontend should use it
Canonical rule
- Persist or trust
trailerRefas the canonical storage reference - Render
trailerUrlin the player - Do not invent trailer URLs in the frontend
const trailerRef = course.trailerRef;
const trailerUrl = course.trailerUrl;
video.src = trailerUrl ?? "";For course trailers, frontend usually does not need to call /api/upload/signed-url/*key, because the stored key is uploaded as a public trailer object.
Where trailerUrl is resolved
The backend already resolves trailer URLs on course reads.
Admin detail
GET /api/admin/courses/:id
Authorization: Bearer <admin-token>Returns trailerRef + trailerUrl in CourseResponseDto.
Customer detail
GET /api/courses/:slugReturns published sellable course detail with trailerRef + trailerUrl.
Mobile mirror
GET /api/mobile/courses/:slugMounted customer/mobile course routes mirror the same course detail contract, so mobile clients should also consume trailerUrl directly. Treat it as the same public preview field as /api/courses/:slug.
Rendering examples
Plain HTML video
<video controls preload="metadata" poster="/placeholder-course-poster.jpg">
<source src="https://cdn.example.com/public/trailer/course/trading-masterclass.mp4" type="video/mp4" />
</video>React
export function CourseTrailer({ trailerUrl }: { trailerUrl: string | null }) {
if (!trailerUrl) return null;
return (
<video controls preload="metadata" playsInline>
<source src={trailerUrl} type="video/mp4" />
Your browser does not support MP4 playback.
</video>
);
}Public URL behavior
In bucket mode, trailer keys live under public/trailer/*, so URL behavior is the same as other public uploads:
- direct URL
- no expiry timestamp
- no signed URL refresh loop
- usable in standard
<video>tags
That is intentional. Trailers are preview media, not protected lesson playback.
If you need protected streaming, use the lesson video HLS pipeline instead of course trailers.
Local development caveat
There is one important difference in STORAGE_DRIVER=local mode.
StorageUrlResolver returns local URLs as /${key}. For a trailer key like:
public/trailer/course/trading-masterclass.mp4it resolves to:
/public/trailer/course/trading-masterclass.mp4But the current Nest app only statically serves /uploads/*, not /public/*.
So in local-driver mode:
- trailer upload still succeeds
trailerRefis still correcttrailerUrlmay resolve to/public/trailer/...- that URL is not directly renderable unless you either:
- run bucket mode locally (recommended for parity), or
- extend backend static serving to expose
/public/*
In bucket mode with RustFS/S3-compatible storage, trailer URLs are directly renderable.
Error cases
| Status | When |
|---|---|
400 | missing file |
413 | trailer exceeds COURSE_TRAILER_MAX_FILE_SIZE_MB |
415 | MIME is not video/mp4 or filename is not .mp4 |
401 / 403 | missing auth or missing COURSES_UPDATE permission |
502 | storage upload failed or upload did not return a storage key |
Trailer vs lesson video
Do not mix these two systems.
| Use case | Correct path |
|---|---|
| Public marketing/preview trailer | Course trailer endpoint + trailerUrl |
| Protected enrolled-student playback | Lesson video upload + HLS pipeline |
Lesson videos:
- go through
video_asset - are transcoded to HLS
- use tokenized playback URLs
- are access-controlled
Course trailers:
- are direct MP4 objects
- stay in object storage
- resolve to
trailerUrl - are intended for public preview playback