Skip to content

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.goServeAsset, 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 changeVITE_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/:idpurge 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.