Skip to content

Media Upload & Consumption Guide

End-to-end reference for uploading media into CoreAPI and consuming it afterward — what happens to the bytes, what gets fixed automatically, what each endpoint returns, and how a channel or CMS actually plays the result back.


1. Where uploads actually go

Browser (tus-js-client)
  → PATCH chunks → PlayoutAdmin nginx (/playout/files/)
  → proxy_pass ${TUSD_URL}
  → tusd container
  → tusd writes directly to SeaweedFS's S3 API (bucket = MINIO_BUCKET)
  → on completion, tusd POSTs a webhook → http://coreapi:3000/hooks/tusd

CoreAPI never sees the raw bytes during upload — tusd streams straight to SeaweedFS. CoreAPI only gets involved via the prepare handshake (before) and the webhook (after).

Entry point: AddContentSheet.jsx, opened from the media library's sidebar upload action. MediaUpload.jsx (the old standalone /media/upload page) has been removed — it never sent a transcodingProfileId and had no route pointed at it.

The two-step handshake

Step 1 — POST /api/media/prepare, before any bytes move:

{
  "title": "Hero Banner",
  "mediaType": "video",
  "mimeType": "video/mp4",
  "fileSize": 52428800,
  "originalName": "hero-raw.mp4",
  "fileName": "hero-banner.mp4",
  "tenant": "jastv",
  "folder": "marketing",
  "transcodingProfileId": 1
}
Returns a short-lived (10 minute) token. CoreAPI stores everything (title, tenant, folder, chosen profile, etc.) on an UploadToken row, keyed by that token — nothing about the actual file exists yet.

Step 2 — TUS upload, with the token embedded in TUS metadata:

new tus.Upload(file, {
  endpoint: "/playout/files/",
  metadata: { filename: file.name, filetype: file.type, uploadToken: token },
}).start()

Step 3 — the webhook (POST /hooks/tusd, post-finish event only): CoreAPI looks up the token, creates the Media row (status: processing, still sitting at the temp upload key), marks the token used, and kicks off background processing.


2. What happens automatically after upload (processMedia)

In order, for every upload:

  1. Filer move — SeaweedFS's Filer HTTP API moves the file server-side (mv.from, zero bytes re-transferred) from its temp key to its real one:
  2. No transcoding profile selected → flat legacy key: <tenant>/<folder>/<file>
  3. Video with a profile selected → per-media folder: <tenant>/<folder>/<mediaID>/source/<file>
  4. status flips to ready once this lands.

  5. Normalize (video only) — runs before anything else touches the file, and overwrites the same storage key:

  6. Trims to the shorter of the video/audio stream duration (fixes AV drift from some downloaded/edited sources) — only if they actually disagree by more than 0.3s.
  7. Full remux (-c:v copy, fresh container, -movflags +faststart) — this is what eliminates multi-mdat files that some sources have, which LG webOS's demuxer chokes on.
  8. Two-pass loudnorm (EBU R128, default target -23 LUFS, configurable via LOUDNORM_TARGET_LUFS) — every video ends up at the same perceived loudness regardless of source.
  9. Best-effort: on any failure, logs and continues with the original file untouched. Nothing downstream needs to know a fix happened — every consumer sees the corrected file by the time it reads the same key.

  10. Probe (ffprobe) — Duration, VideoCodec, VideoProfile, PixelFormat, AudioCodec, populated from the already-normalized file, so duration is accurate.

  11. Thumbnail (background) — thumbnailType(mimeType) picks the strategy:

Type Mechanism Automatic?
Video ffmpeg frame-grab (10% in, min 1s, max first-3s)
Image ffmpeg scale
PDF ghostscript, first page only
PPT / PPTX / ODP LibreOffice headless → temp PDF → same ghostscript path, first slide only ✅ (new)
Anything else Skipped — no thumbnail, uploads and stores fine regardless

PPT/PPTX support needs libreoffice installed in the CoreAPI image (added to the Dockerfile — a real image-size cost, several hundred MB, accepted deliberately for this).

  1. CMAF transcode (video with a real, non-empty rendition profile) — per rendition: ffmpeg → HLS/fMP4 segments under hls/<height>p/, plus a Castis-style smil/ladder.smil (the merged, non-fragmented MP4s + playlist scheduling actually consumes). ManifestKey (HLS) and SmilKey (Castis) are set once done.

File acceptance: there is no whitelist anywhere, frontend or backend. Any file type uploads successfully; unrecognized types just get no thumbnail and classify as TypeOther.


3. Transcoding profiles (media.TranscodingProfile)

Not the same thing as playback.TranscodingPreset (the live-channel ABR ladder from the Playback domain) — this is the VOD ladder used for uploaded files.

GET /api/media/transcoding-profiles?category=video
{
  "success": true,
  "data": [
    { "ID": 1, "name": "Standard 3-Rung Ladder", "isDefault": true, "renditions": [...] },
    { "ID": 2, "name": "Lite 2-Rung Ladder", "renditions": [...] },
    { "ID": 3, "name": "Passthrough (no transcode)", "renditions": [] }
  ]
}

Choice transcodingProfileId sent Result
Real ladder (e.g. id 1) that ID Full CMAF transcode, ManifestKey+SmilKey set, per-media folder layout
Passthrough (id 3, seeded) that ID No ffmpeg run — transcodingStage jumps straight to done. Still gets the per-media folder layout. No SmilKey — usable for direct playback/CMS, not eligible for scheduled-channel playlists (that path hard-requires a SMIL).
Nothing selected (undefined) omitted Old flat legacy layout, no per-media folder, no ladder — functionally identical to Passthrough for playback purposes, just a different storage layout

transcodingStage progression to poll on GET /media/:id: queuedprobetranscodepackagedone (or failed). Passthrough skips straight from queued/probe to done.


4. Deleting media

DELETE /api/media/:id
DELETE /api/media/:id?purge=false   # DB-only, keep storage files

purge defaults to true — deleting a media record now actually removes its files from SeaweedFS: - Transcoded media (had a profile): one recursive delete of the whole <folder>/<mediaID>/ prefix — covers the source file, thumbnail, every HLS rendition segment, and the SMIL tree in one pass. - Flat-layout media: removes StorageKey, ThumbnailKey, NormalizedKey individually.

Pass ?purge=false if you specifically want the old DB-only soft-delete behavior.

Auditing what's actually in SeaweedFS

GET /api/media/storage-audit?prefix=&limit=20000
Read-only. Cross-references every object in the bucket against every Media row's expected keys/prefixes: - Orphans — files in storage nothing in the DB accounts for (stuck temp/ uploads, or leftovers from before the purge fix existed) - Missing — a DB row references a key/prefix with nothing actually there

Returns counts + a capped 100-item sample of each, plus scanTruncated if the bucket is bigger than limit. Doesn't delete anything — use DELETE /media/:id for that, or a manual pass once you've reviewed the sample.


5. Consuming media afterward

Field Points at Used by
storageKey The (normalized, defragmented) original file Direct playback, CDN links, media library preview
thumbnailKey A JPEG preview Media library grid/list views
manifestKey HLS master.m3u8 Browser-based preview of the transcoded ladder
smilKey Castis ladder.smil Channel scheduled/fallback playlists onlyresolvePlaylistFilePaths requires this to be set; Passthrough/no-profile media can't be used here

See the Assets Guide for exactly how storageKey turns into a fetchable URL.