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
}
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:
- 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: - No transcoding profile selected → flat legacy key:
<tenant>/<folder>/<file> - Video with a profile selected → per-media folder:
<tenant>/<folder>/<mediaID>/source/<file> -
statusflips toreadyonce this lands. -
Normalize (video only) — runs before anything else touches the file, and overwrites the same storage key:
- 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.
- Full remux (
-c:v copy, fresh container,-movflags +faststart) — this is what eliminates multi-mdatfiles that some sources have, which LG webOS's demuxer chokes on. - 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. -
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.
-
Probe (
ffprobe) —Duration,VideoCodec,VideoProfile,PixelFormat,AudioCodec, populated from the already-normalized file, so duration is accurate. -
Thumbnail (background) —
thumbnailType(mimeType)picks the strategy:
| Type | Mechanism | Automatic? |
|---|---|---|
| Video | ffmpeg frame-grab (10% in, min 1s, max first-3s) |
✅ |
| Image | ffmpeg scale |
✅ |
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).
- CMAF transcode (video with a real, non-empty rendition profile) — per rendition:
ffmpeg→ HLS/fMP4 segments underhls/<height>p/, plus a Castis-stylesmil/ladder.smil(the merged, non-fragmented MP4s + playlist scheduling actually consumes).ManifestKey(HLS) andSmilKey(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: queued → probe → transcode → package → done (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
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 only — resolvePlaylistFilePaths 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.