Agora

Video streaming

HLS and DASH end to end — package a video once as CMAF, serve the package behind your authorization with media.access.streamRoute(), and play it with useMediaStream.

Streaming a video takes three pieces:

  1. a transformer that packages the upload into segments and manifests;
  2. a route that serves the package to whoever may watch it;
  3. a player in the browser.

The library ships all three.

Package it

config/media.ts
import { authorizers, defineConfig, transformers } from '@adonis-agora/media'

export default defineConfig({
  collections: [
    {
      name: 'lessons',
      acceptsMimeTypes: ['video/*'],
      access: { read: authorizers.authenticated() },
      transformers: [transformers.video()],
    },
  ],
})

Run it after the upload. The library never runs a heavy transform on its own: see Running a transform.

Serve it

start/routes.ts
import media from '@adonis-agora/media/services/main'

const serveStream = media.access.streamRoute()
router.get('/media/:id/stream/:conversion/*', serveStream)

Play it

app/controllers/lessons_controller.ts
async show(ctx: HttpContext) {
  const lesson = await Lesson.findOrFail(ctx.params.id)
  return ctx.inertia.render('lessons/show', {
    lesson,
    video: await media.access.stream(ctx, lesson.videoMediaId),
  })
}
inertia/pages/lessons/show.tsx
import { useMediaStream } from '@adonis-agora/media-react'

const ref = useRef<HTMLVideoElement>(null)
useMediaStream(ref, video, { hls: () => import('hls.js'), dash: () => import('dashjs') })

return <video ref={ref} controls playsInline />

See Video player for the hook.

HLS, DASH, or both

transformers.video() writes both by default, for the price of one. Its segments are CMAF (fragmented MP4), the format HLS and DASH share, so one copy of the video is described by an HLS master playlist (index.m3u8) and by a DASH manifest (manifest.mpd).

You don't choose a format per browser on the server. The player does, on the client (useMediaStream): Safari and iPhones play HLS natively, everything else uses hls.js, and DASH is there for dash.js and for DRM setups that need it.

You needConfigure
Both formats (default)transformers.video()
Only onetransformers.video({ formats: ['hls'] }) or ['dash']
Players older than fMP4 HLS (iOS 9, some TVs)transformers.video({ segments: 'ts', formats: ['hls'] })
An adaptive-bitrate laddertransformers.video({ engine: engines.ffmpeg({ ladder: engines.ladders.standard }) })

Without ffmpeg, the default engine (mediabunny, pure JS) remuxes the source as is: one rendition, no re-encode. See The video transformer for every option, the engines and the CMAF/TS trade-off.

The conversion's meta records what was written: hlsEntry and dashManifest (the manifests), segments, durationSeconds, width, height, videoCodec, audioCodec and segmentCount.

The stream route

media.access.streamRoute(options?) returns an AdonisJS handler for /media/:id/stream/:conversion/*. The URL mirrors the package, so a manifest's relative references (media_0.m3u8, chunk-0-00001.m4s) resolve back to the same route. Nothing has to be rewritten to point at your app.

For every request, it:

  1. Loads the record and checks the file. The file must be one the transformer wrote (the conversion's persisted file list). Anything else, ../ included, gets 404, so no caller input reaches the disk as a path.
  2. Authorizes the read with options.authorize or, failing that, the collection's access.read, exactly like the delivery route: 401 for an anonymous request, 403 for a stranger. A valid package signature skips this (next section).
  3. Answers a manifest (.m3u8, .mpd) as text, with its content type.
  4. Answers a segment per delivery.mode, or the route's mode:
    • signed: a redirect to a short-lived URL on the storage, minted for that request, so a long video never outlives it;
    • public: a redirect to the object;
    • proxy: the bytes, streamed through the app.
OptionDefault
authorizethe collection's access.readWho may watch.
modedelivery.modeHow segments reach the player.
signedTtlSecondsdelivery.signedTtlSecondsLifetime of a segment's redirect URL.
cacheControl'private, max-age=60'For manifests and redirects.
segmentCacheControl'private, max-age=86400'For segments streamed in proxy mode. A segment never changes.
expiresInsee belowLifetime of the package signature.

media.access.serveStream(ctx, record, conversion, file, options) is the same, for a controller that finds the record its own way.

Session or signature

A player fetches a manifest, then dozens of segments. Each of those requests must be authorized, and there are two ways to do it:

  • By session (no delivery.signing). The browser sends its cookies with every request, so each segment goes through authorize. hls.js and dash.js send same-origin cookies by default. This is the simplest setup.
  • By signature (delivery.signing configured). media.library.stream() signs the manifest URLs for the whole package: the HMAC covers the record and the conversion, not a single file. The route serves a signed request without asking the app, and adds the same ?exp=&sig= to every reference in the manifests it answers. Segment requests then carry the authorization along, with no session and no database check per segment, and keep working even from another origin. A manifest requested by session gets a fresh signature in the same way.

A signature lives for expiresIn, which defaults to delivery.signing.expiresIn. It is never shorter than the video plus an hour, so a viewer doesn't lose access halfway through a long lesson.

Redirects to storage and CORS

hls.js and dash.js fetch segments with XHR/fetch. When the route redirects a segment to a storage host (signed/public mode), that host must allow your app's origin (Access-Control-Allow-Origin on the bucket). In proxy mode, everything stays same-origin.

media.library.stream() and media.access.stream()

const video = await media.access.stream(ctx, id) // authorizes, like media.access.image()
const video = await media.library.stream(id)      // doesn't: you already did

// {
//   src: '/media/<id>/stream/video/index.m3u8?exp=…&sig=…',
//   format: 'hls',
//   sources: [
//     { src: '…/index.m3u8?…', format: 'hls', type: 'application/vnd.apple.mpegurl', conversion: 'video' },
//     { src: '…/manifest.mpd?…', format: 'dash', type: 'application/dash+xml', conversion: 'video' },
//   ],
//   width: 1920, height: 1080, durationSeconds: 754.2,
// }
  • Which conversions count: every conversion that carries a manifest. HLS sources come first, because iPhones play HLS natively. With several video conversions (say lesson and lesson_ts), conversions: ['lesson_ts', 'lesson'] restricts them and sets their order exactly, conversion by conversion; within one conversion, its HLS master still comes before its MPD.
  • Not packaged yet: stream() throws TransformNotReadyError until a package exists. The route answers that error with 404.
  • Where the URLs point: at delivery.streamPath (default /media/:id/stream/:conversion). Change it if you mount the route elsewhere:
config/media.ts
defineConfig({ delivery: { streamPath: '/videos/:id/:conversion' } })
// router.get('/videos/:id/:conversion/*', media.access.streamRoute())

Lower level: HlsDeliveryHandler

HlsDeliveryHandler is the earlier, framework-agnostic read path for the HLS side of a package. It rewrites every playlist reference to URLs you build, with segments presigned inline, and leaves authorization to your route. It is still exported for apps that need a custom URL scheme. See Serving HLS by hand. For anything new, prefer streamRoute(): it covers DASH, authorizes for you, and never hands out a segment URL that expires mid-video.

On this page

Partner

Your product as the official integration for Media, right here.

Become a partner →