Architecture
Design decisions for serving, storage, upload flow, and eventing. For the transcoding/optimize/rasterize pipelines themselves, see Transcoding.
Serving Architecture
Media bytes are served through cproxy, not CoreAPI, as of 2026-07-12:
sequenceDiagram
participant Browser
participant nginx
participant cproxy
participant SeaweedFS
Browser->>nginx: GET /assets/playtelly/jastv/vod/a.mp4
nginx->>cproxy: GET /playtelly/jastv/vod/a.mp4 (prefix stripped)
cproxy->>SeaweedFS: GET /playtelly/jastv/vod/a.mp4 (cache miss)
SeaweedFS-->>cproxy: 200, file bytes
cproxy-->>nginx: 200, cached response
nginx-->>Browser: 200, file bytes
CoreAPI's own GET/HEAD /assets/* and GET /assets/*/presign
(handler_media.go — ServeAsset, PresignAsset, mounted on the app
root, not under /api/v1) still exist as a manual rollback path, but
nginx doesn't point at them by default — confirmed in
PlayoutAdmin/nginx.conf.template's location /assets/ block, which
proxies straight to ${ASSET_SERVING_URL} (cproxy). A naive reading of
routes.go would suggest the browser hits CoreAPI for asset bytes; in
this topology it doesn't.
Known gap: cache invalidation isn't wired up. Renaming, deleting, or
reprocessing a Media row doesn't call cproxy's PurgeContent/
PurgeOrigin — a stale cached response can be served for up to the
origin's TTL after a mutation. See distribution domain's CProxyClient
for the existing purge functions; nothing in media's mutation handlers
calls them yet.
Upload Flow
sequenceDiagram
participant Client
participant CoreAPI
participant tusd
participant SeaweedFS
participant NATS
Client->>CoreAPI: POST /api/v1/media/prepare
CoreAPI->>PostgreSQL: Create UploadToken (used=false)
CoreAPI-->>Client: 201 { token, expiresAt }
Client->>tusd: TUS upload, metadata.uploadToken
tusd->>SeaweedFS: Write file to playtelly/<uploadID>
tusd->>CoreAPI: POST /hooks/tusd (post-finish)
CoreAPI->>PostgreSQL: Lookup token, create Media (status=processing)
CoreAPI->>PostgreSQL: Mark token used=true
CoreAPI->>NATS: media.processing.started
CoreAPI-->>tusd: 200 OK
CoreAPI->>SeaweedFS: filerMkdir + filerMove → final path
CoreAPI->>PostgreSQL: status=ready, storageKey=...
CoreAPI->>NATS: media.ready
CoreAPI->>CoreAPI: normalizeMedia (video only) → probe → generateThumbnail
Note over CoreAPI: then category dispatch — see transcoding.md
CoreAPI->>NATS: media.thumbnail.ready
Handler mapping: Prepare/TusdHook/tusPostFinish in
handler_upload.go; processMedia/filerMkdir/filerMove/
generateThumbnail in handler_process.go. tusPostFinish only
validates the request against the DB-stored uploadToken (token=? AND
used=false AND expires_at > ?) — there's no signature/HMAC check on the
webhook payload itself, so anything that can reach CoreAPI can call
/hooks/tusd directly if it also knows a valid token.
ValidatePrepare (validation.go) only 400s on blank title, blank
originalName, or fileSize <= 0 — everything else on PrepareInput
(width, height, duration, tags, dates, ...) is optional and unvalidated.
Asset URL Strategy
The API returns relative paths, never absolute URLs — the client
prepends its own base. Built in Media.PublicURL()/ThumbURL()
(models.go) and ToResponse (helper.go):
storageKey: "jastv/banner/hero.jpg"
url: "/assets/playtelly/jastv/banner/hero.jpg"
thumbnailUrl: "/assets/playtelly/jastv/banner/thumbs/hero.jpg"
React app (PlayoutAdmin) — two different link-building paths, not one:
// Path 1 — backend-prefixed, always correct. media.url is already
// "/assets/<bucket>/<key>" (built server-side, see above).
const previewUrl = import.meta.env.VITE_ASSET_BASE_URL + media.url
// Path 2 — frontend-prefixed, from a raw bucket-relative key (storageKey/
// optimizedKey/manifestKey/slide imageKey — none of which carry "/assets/"
// or a bucket segment themselves). src/utils/cdn.js's cdnUrl(key):
const CDN_URL = (import.meta.env.VITE_CDN_HOST || "http://localhost:9222/playtelly").replace(/\/$/, "")
return `${CDN_URL}/${key}`
Path 2 is a plain string concatenation — it has no idea /assets/
or a bucket segment need to be there at all; it just trusts that
VITE_CDN_HOST itself already ends in the right serving-route prefix
+ bucket (the local dev fallback bakes both in: :9222/playtelly).
Real bug hit on staging (2026-08-04): VITE_CDN_HOST was baked in at
build time as a bare host (https://stag.api.castis.io, no path at
all) — so every Path-2 link (source/HLS/optimized-webp/slide-image, all
in MediaDetailPanel.jsx) dropped both the route prefix and the bucket,
producing https://stag.api.castis.io/jastv/marketing/hero.jpg instead
of a working URL. Path-1 links (the plain download link) were
unaffected since media.url already carries what it needs.
Fix is a build-arg value change, not a code change — VITE_CDN_HOST
needs the serving route + bucket appended, e.g.
https://stag.api.castis.io/assets/cms (via CoreAPI's own route) or
https://cproxy-nuc-01.cdn.castis.io/files/cms (via cproxy directly) —
not yet fixed as of this writing.
Storage Layout
Single S3 bucket (MINIO_BUCKET, default playtelly), tenant-prefixed:
playtelly/
├── <uploadID> ← tusd writes here (status=processing)
└── <tenant>/<folder>/
├── <fileName> ← flat layout (image/document/no-profile)
├── <mediaID>/ ← per-media layout (video with a profile)
│ ├── source/ hls/ smil/
│ ├── optimized/ image only
│ └── slides/<stem>/ document only
└── thumbs/<stem>.jpg
Flat layout has no natural per-upload uniqueness — uniqueFlatKey
(handler_process.go) checks the DB for a storage_key collision before
the move and auto-suffixes the stem (kitchen-2.mp4, ...) if needed,
keeping file_name in sync. Without this, two same-named uploads would
silently overwrite each other in storage and the second would fail the
DB's unique index, stuck at processing forever with no surfaced error.
Rename = UPDATE folder/file_name in Postgres, zero S3 ops (cache
invalidation not wired, see Serving Architecture above).
Move = SeaweedFS filer mv.from, zero bytes transferred.
Status Lifecycle
pending → uploading → processing → ready
↘ error
| Status | Meaning |
|---|---|
pending |
record created, upload not started |
uploading |
TUS upload in progress |
processing |
tusd finished, pipeline running |
ready |
fully available |
error |
filer move or DB update failed |
archived |
soft-archived, excluded from default list |
Set via setStatus (handler_process.go). Separate from
TranscodingStage (queued/transcode/done/failed, category
pipeline progress only — see Transcoding).
Deletion & Storage Audit
DELETE /media/:id — purge defaults to true (handler_media.go:
purge := c.Query("purge") != "false"). Transcoded media (any
TranscodingProfileID) gets a single recursive delete of the whole
<folder>/<mediaID>/ prefix via purgeMediaStorage/
removeObjectsByPrefix; flat-layout media removes storageKey/
thumbnailKey individually.
GET /media/storage-audit?prefix=&limit=20000 (handler_storage_audit.go)
cross-references SeaweedFS objects against DB rows both directions —
orphans (nothing in DB) and missing (DB row, no object). Read-only,
doesn't delete anything itself.
NATS Events
Published from handler_process.go/handler_image.go/
handler_document.go/handler_transcode.go; subscribed (log-only today)
in handler_nats.go. See NATS for the client wrapper.
| Subject | Published when |
|---|---|
media.processing.started |
POST /media/:id/process called |
media.ready |
file moved to final path |
media.thumbnail.ready |
thumbnail saved |
media.optimize.started / .done |
image pipeline (handler_image.go) |
media.rasterize.started / .done |
document pipeline (handler_document.go) |
media.transcoding.started / .done |
video CMAF pipeline (handler_transcode.go) |
// domain/your-domain/handler_nats.go
natsclient.Subscribe("media.ready", func(data []byte) {
var e struct{ MediaID uint `json:"mediaId"`; StorageKey, TenantID string }
json.Unmarshal(data, &e)
// your logic — zero changes to media domain needed
})
Retry Stuck
POST /media/retry-stuck (RetryStuck, handler_process.go) re-triggers
processMedia for any record stuck in processing for more than 5
minutes. Safe to call repeatedly.