Shop It Docs
Developer Resourcescourse

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.trailerRef stores the canonical storage key
  • read APIs resolve that key into trailerUrl
  • trailers use UploadType.TRAILER, which maps to public/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-data

Field 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 (default 250)

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:

  1. validate file
  2. upload new object with UploadType.TRAILER
  3. update courses.trailerRef
  4. 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 trailerRef as the canonical storage reference
  • Render trailerUrl in 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/:slug

Returns published sellable course detail with trailerRef + trailerUrl.

Mobile mirror

GET /api/mobile/courses/:slug

Mounted 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.mp4

it resolves to:

/public/trailer/course/trading-masterclass.mp4

But the current Nest app only statically serves /uploads/*, not /public/*.

So in local-driver mode:

  • trailer upload still succeeds
  • trailerRef is still correct
  • trailerUrl may 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

StatusWhen
400missing file
413trailer exceeds COURSE_TRAILER_MAX_FILE_SIZE_MB
415MIME is not video/mp4 or filename is not .mp4
401 / 403missing auth or missing COURSES_UPDATE permission
502storage upload failed or upload did not return a storage key

Trailer vs lesson video

Do not mix these two systems.

Use caseCorrect path
Public marketing/preview trailerCourse trailer endpoint + trailerUrl
Protected enrolled-student playbackLesson 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