Changelog
2026-08-03
Category-aware transcoding profiles — image and document now real pipelines, not just seeded rows
Why: TranscodingProfile was video-only in practice — Category had
image/document reserved in the enum since the original design, but
nothing consumed them. New product requirement: optimize uploaded images
to webp for signage placements, and rasterize PDF/PPT/PPTX/ODP into
per-slide images for Tellyboard's digital signage scheduling.
Schema additions (models.go):
- TranscodingProfile.Code *string (nullable, unique) — stable
machine-readable identifier, same pattern as playback.TranscodingPreset.Code.
Nullable so UI-created profiles (which don't set one) never collide with
each other or with seeded rows (Postgres treats multiple NULLs as
distinct under a unique index).
- TranscodingProfileVersion.Settings datatypes.JSON (nullable) — category-specific
config, deliberately a separate column from Renditions, not an
overload of it (an array of renditions and an object of settings are
structurally unrelated; conflating them would make either shape
ambiguous to read). Video rows keep using Renditions exactly as
before, unaffected.
- image shape: {"format":"webp","quality":85,"maxWidth":1920,"maxHeight":1080}
- document shape: {"dpi":150,"colorScheme":"rgb","defaultSlideDurationSec":8}
- Media.OptimizedKey string — image category only, sibling to
ThumbnailKey's <dir>/thumbs/<stem>.jpg convention:
<dir>/optimized/<stem>.webp.
- MediaSlide (new table) — one row per rasterized document page:
MediaID, Order, ImageKey, DurationSec. Same shape/purpose as
MediaPlaylistItem (an ordered child list) but for a single document's
own pages. Must be registered in main.go's RegisterModels() call
or AutoMigrate never creates the table.
- Media.RetainSource removed — was hardcoded true at creation,
read by nothing, ever. Implied a "discard source after transcode"
control that never existed.
All of the above are additive/removals-of-unused-fields only — safe for
AutoMigrate on an existing deployment (including staging), no manual
migration script needed. AutoMigrate only adds missing columns/tables,
it never drops or alters existing ones.
New pipelines, both mirroring transcodeMedia's existing shape:
optimizeImage(handler_image.go) — gated bywantsOptimizeImage(MediaType==Image && TranscodingProfileID != nil). Uses ffmpeg's built-inlibwebpencoder (already a CoreAPI dependency for video/thumbnails — no new binary). Resize only ever shrinks (force_original_aspect_ratio=decrease+min(...,iw/ih), never upscales). Uploads to<dir>/optimized/<stem>.webp, setsMedia.OptimizedKey.rasterizeDocument(handler_document.go) — gated bywantsRasterizeDocument(MediaType==Document && TranscodingProfileID != nil). PPT/PPTX/ODP go through the same LibreOffice→PDF conversion the PPTX-thumbnail path already uses (convertOfficeToPDF), then ghostscript rasterizes every page (not just page 1, unlike the thumbnail path) to<dir>/slides/<stem>/page-N.jpg, oneMediaSliderow per page.DurationSecon each slide is a default only, copied from the profile'sdefaultSlideDurationSecat rasterize time — never fed into any encode command (these are still images, not a video). Tellyboard's own per-instance schedule duration is expected to override this at consumption time — this is the fallback baked into each slide, not a synced/authoritative value.- New route:
GET /media/:id/slides— returns the ordered page list, empty array (not an error) if unrasterized/passthrough.
Passthrough, for every category, same convention:
| Category | Signal | Effect |
|---|---|---|
| video | Renditions: [] |
skip the ladder, mark done |
| image | Settings.format missing/empty |
skip webp encode, mark done |
| document | Settings.dpi/colorScheme/defaultSlideDurationSec all unset |
skip rasterization, mark done |
Seeded: VIDEO-PASSTHROUGH, IMAGE-PASSTHROUGH, DOCUMENT-PASSTHROUGH.
Passthrough is checked on the parsed settings struct, not raw JSON
byte length — an explicit {} (e.g. from a UI "Passthrough" checkbox
that clears every field but still submits an object) is treated
identically to omitting settings entirely.
CreateTranscodingProfile/UpdateTranscodingProfile validation is now
category-aware: renditions required only for category=video;
settings required for everything else. Previously renditions was
unconditionally required, which would have 400'd on the very act of
creating an image/document profile via the API.
CRUD unblocked in PlayoutAdmin: ProfileEditSheet.jsx used to
hard-disable image/document as category options ("not implemented")
— meaning the seeded rows were the only image/document profiles that
could ever exist. Now a real category-specific settings form (format/
quality/dimensions for image; DPI/color scheme/default slide duration for
document), plus the Passthrough checkbox described above.
Seed behavior changed — now upserts by Code, not skip-if-exists.
seed.Run fires on every server boot; the old seedProfile skipped
entirely once a profile with the same name existed, so a seed file change
never reached an already-running deployment. seedMediaProfile now
converges the row (and its current version's content, updated in
place, not as a new version) to whatever this file currently defines,
every boot. Deliberately still never creates a new TranscodingProfileVersion
row per restart — seed data isn't a real user edit; real edits via
UpdateTranscodingProfile are unaffected and still always insert a new
version.
TranscodingStage — DB-default bug, same species as an earlier playback.TranscodingPreset.ForceClean fix
Bug: TranscodingStage had gorm:"...;default:'queued'". Application
code correctly computed the Go zero value "" ("not applicable") for
anything that wasn't video-with-a-profile, but GORM omits a zero-value
field with a default: tag from the INSERT entirely, letting
Postgres's own column default apply instead — so every image/document
upload silently got transcoding_stage='queued' written regardless of
what the code decided, and then sat there forever, since nothing was
ever going to advance a stage that was never really queued. Looked
identical to a stuck pipeline; was actually a database artifact.
Fix: dropped the default: tag. Existing rows created before this
fix keep their stale queued value — this doesn't retroactively fix
already-created records, only new uploads going forward. Not worth a
migration for what's expected to be a handful of test rows; re-upload to
verify.
Frontend had the mirror bug (MediaList.jsx): isSyncingMedia/
transcodeBucket/TranscodeStageBadge all treated "has a
transcodingProfileId" as "still processing" — true only for the
categories that actually had a pipeline. For image (before optimizeImage
existed) this meant the 4s poll interval (setInterval(() => fetchMedia(true), 4000))
never stopped — syncCount could never reach 0. Fixed via a
hasActiveTranscodePipeline(m) gate, now covering all three categories
since all three have real pipelines as of this entry.
Video: normalize step — mdat defrag, AV duration trim, loudnorm
Why: some downloaded/edited source MP4s have (a) audio/video streams
of different lengths, and (b) multiple mdat atoms that LG webOS's
demuxer chokes on. A team-validated manual ffmpeg batch script fixed
both — this ports that into the pipeline.
normalizeMedia (handler_normalize.go) runs synchronously in
processMedia, before extractMediaProbeData, for video only:
- Trims to the shorter of the video/audio stream duration (only if they disagree by >0.3s — avoids needless remuxing of already-in-sync files).
- Full remux (
-c:v copy, fresh container,-movflags +faststart) — this is what defragments a multi-mdatsource as a side effect, even without re-encoding video. - Two-pass loudnorm (EBU R128, default -23 LUFS, configurable via
LOUDNORM_TARGET_LUFSenv — measure pass first, then apply with the measured params for accurate linear correction instead of single-pass dynamic mode, which can audibly "pump").
Overwrites the same storageKey — nothing downstream needs to know a
fix happened; every consumer (probe, thumbnail, CMAF transcode source,
direct playback) sees the corrected file. Best-effort: logs and continues
with the original file on any failure.
Scope: video only. Audio-only files would need a per-container codec
choice (mp3→libmp3lame, wav→pcm_s16le — loudnorm can't -c:a copy, it
must decode+re-encode) that video's mp4/aac path doesn't have to make;
deliberately deferred rather than guessing wrong and re-encoding
someone's WAV into a lossy codec.
PPT/PPTX/ODP thumbnails — new LibreOffice dependency
thumbnailType() gained an "office" case; generateThumbnail converts
via convertOfficeToPDF (LibreOffice headless, isolated
-env:UserInstallation profile dir per call — soffice locks its
profile dir, so concurrent conversions sharing the default would collide)
then reuses the existing ghostscript PDF-thumbnail path, first
slide/page only. New Dockerfile dependency: libreoffice — a real
image-size cost (several hundred MB), accepted deliberately for this.
InferMediaType also fixed to classify legacy .ppt (binary format,
MIME application/vnd.ms-powerpoint) as document — it fell through to
other before since that MIME type doesn't contain the substrings
InferMediaType was matching on.
Deletion now actually deletes from SeaweedFS
Two bugs, both real:
DELETE /media/:idnever purged storage by default — the frontend never passed?purge=true(the only caller inMediaList.jsxwas a barefetch(..., {method:'DELETE'})), so "Delete" in the UI only ever soft-deleted the DB row (gorm.Model'sDeletedAt). Fixed:purgenow defaults totrue— pass?purge=falseto opt into the old DB-only behavior.- Even with
purge=true, cleanup only ever removedStorageKey/ThumbnailKey/NormalizedKeyindividually — missing the entirehls/+smil/tree for transcoded video (every CMAF rendition segment, both master playlists) and, as of today,optimized//slides/for image/document. Fixed: transcoded media (anyTranscodingProfileID != nil) now gets one recursive delete of the whole<folder>/<mediaID>/prefix — source, thumbs, hls, smil, optimized, slides are all nested under it, one pass covers everything. Flat-layout media still removes its individual keys.
New: GET /media/storage-audit
Read-only. Cross-references every object actually in the configured
SeaweedFS bucket against every Media row's expected keys/prefixes, both
directions — orphans (objects in storage nothing in the DB accounts
for — stuck temp/ uploads, or leftovers from before the purge fix
existed) and missing (a DB row references a key/prefix with nothing
actually there). Capped scan (?limit=, default 20000 objects,
scanTruncated in the response if the bucket's bigger than that),
?prefix= to scope it. Doesn't delete anything itself — pair with
DELETE /media/:id?purge=true or a manual pass.
Duplicate filename — silent corruption fixed
Bug: Media.StorageKey has a DB-level unique index. The flat-layout
key (m.Folder + "/" + m.FileName, used by everything except
video-with-a-profile) had zero collision checking. Two uploads
resolving to the same folder/fileName: the second one's filerMove
would silently overwrite the first media's actual file in storage (no
uniqueness awareness in the filer move itself), then the second one's own
UPDATE ... SET storage_key=... would fail the unique constraint and it'd
get stuck at status=processing forever — no error surfaced anywhere
(this all runs in a background goroutine).
Fix: uniqueFlatKey (handler_process.go) checks the DB for an
existing storage_key collision before the move, suffixing the filename
stem (kitchen-2.mp4, kitchen-3.mp4, ...) until it finds a free one —
same convention as any OS file manager. file_name is kept in sync with
whatever key actually got used. Known residual gap: two uploads
processed at the exact same instant with an identical
tenant/folder/filename could theoretically both pass the check before
either writes its row — narrow enough not to warrant a
transaction/advisory-lock for now.
Error logging — most of domain/media/handlers was silent on failure
Swept the whole package adding log.Printf on every DB/storage/exec
error path that previously returned an error to the client with nothing
server-side to diagnose from. Two real gaps found in the process, not
just missing log lines:
handler_query.go— every query (ListFolders/ListTenants/ListTags/Stats) chained off GORM calls without ever checking.Error. A DB failure looked identical to "no data yet." Now checked and surfaced as a real 500.handler_nats.go— all three NATS subscribers ignoredjson.Unmarshal's error, so a malformed event payload silently logged garbage zero-values instead of surfacing that something upstream was broken.
Removed
PlayoutAdmin/src/components/media/pages/MediaUpload.jsx— the standalone/media/uploadpage. Confirmed dead (no route, no import anywhere) and, separately, broken — it never senttranscodingProfileIdat all, so uploads through it always got the flat legacy layout no matter what. The sidebar's "Add Content" flow (AddContentSheet.jsx) is the only supported upload entry point now.Media.RetainSource+ its frontend "Source retention" toggle — see schema section above.
2026-07-19
MediaPlaylist domain — ordered multi-asset playback blocks
Why: Scheduling multiple media assets as one continuous, looping unit
(e.g. a rotation of hotel-promo clips) needed a way to combine several
already-uploaded Media rows into one playable artifact, without
re-uploading or duplicating source files.
New models: MediaPlaylist / MediaPlaylistItem — a playlist is an
ordered, named reference to existing Media rows. Regeneration (concat +
transcode + package) is triggered automatically on creation and on any
reorder, not run inline with the request — CreatePlaylist/
UpdatePlaylistOrder return immediately; a background goroutine
(regeneratePlaylist) does the actual work, with Status progressing
pending → concat → transcode → package → ready (or failed, with
ErrorMessage set).
Regeneration pipeline (handler_playlist.go):
1. Downloads each item's original source file (Media.Bucket/
StorageKey — never an already-transcoded rendition) to local temp,
in playlist order.
2. Concatenates + normalizes (scale/fps/setsar) via ffmpeg -filter_complex
concat — not the concat demuxer, which was found unreliable with
mismatched source resolutions/framerates during testing (different
source clips are not guaranteed to share resolution or frame rate).
Single-item playlists skip concat and simply stream-copy through.
3. Runs the same runFFmpegCMAF ladder-generation used by single-Media
uploads, against the concatenated master.
4. New: builds a second, SMIL-compatible output from the same encode —
concatenates each rendition's CMAF init segment + media segments into
one file, then remuxes fragmented → classic MP4
(buildFragmentedMP4FromCMAF). Required because Castis Streamer's SMIL
parser rejects fragmented MP4 outright (Invalid Format) — confirmed
by direct testing; only classic (non-fragmented) MP4 works as a SMIL
<video src> target. Costs one cheap remux pass per rendition, not a
second real encode.
5. Uploads both artifact trees to separate prefixes:
- <tenant>/playlist/<id>/hls/ — CMAF/HLS, same shape as single-Media
transcoding output.
- <tenant>/playlist/<id>/smil/ — ladder.smil + per-rendition classic
MP4s, for Castis Streamer scheduling.
SourceOrderHash — hash of the current item-ID order, compared against
what was last actually regenerated, so unrelated updates don't trigger
unnecessary re-encodes.
New routes: POST /api/v1/media/playlists — create + trigger regeneration GET /api/v1/media/playlists/:id — fetch with items + Media preloaded PUT /api/v1/media/playlists/:id/order — reorder + re-trigger if order changed
Deliberate scope decision: single-Media uploads do not get SMIL
output. Scheduling any individual asset on Castis goes through a 1-item
MediaPlaylist instead (the concat step is a no-op passthrough in this
case). This keeps exactly one code path producing SMIL output rather than
two that could drift out of sync.
Known issue, fixed inline: MediaPlaylistItem.Order (Go field) /
order (DB column) collides with the Postgres reserved keyword ORDER,
causing a syntax error in generated ORDER BY clauses. Currently worked
around by quoting (db.Order("\"order\" ASC")); flagged for a proper
rename to a non-reserved column name.
Backfill — CMAF transcoding pipeline (previously undocumented)
The following was implemented prior to today but never logged here:
TranscodingProfilemodel — reusable rendition-ladder definitions (Rendition{Height, BitrateKbps, Profile}as JSON), seeded with a default "Standard 3-Rung Ladder" (1080p/720p/480p, CMAF container).Media.TranscodingProfileID/TranscodingStage/ManifestKey— per-upload transcode tracking, mirroring the frontend wizard's Processing step (probe → transcode → package → thumbnail → done).transcodeMedia(handler_transcode.go) — runs oneffmpegpass per rendition, output as CMAF (fragmented MP4 + HLS playlist per rendition,master.m3u8tying them together), uploaded to<tenant>/<folder>/<mediaID>/hls/.- ffprobe-based codec/profile/pixel-format/duration extraction on upload
(
Media.VideoCodec,VideoProfile,PixelFormat,AudioCodec).
2026-07-12
VOD serving moved off CoreAPI — cproxy passthrough to SeaweedFS
Why: CoreAPI's /assets/* route (ServeAsset) streamed every media byte
through the same Fiber process handling DB writes, tusd webhooks, and
thumbnail generation — a control-plane service sitting in the data plane for
all VOD traffic. This removes CoreAPI from that path entirely.
New serving chain:
Browser → nginx (/assets/) → cproxy_vod:8080 → SeaweedFS:8333 (S3 gateway)
Browser → nginx (/assets/) → CoreAPI:3000 → SeaweedFS)
What changed:
models.go — PublicURL()/ThumbURL() now include the Bucket field in
the returned path:
before: /assets/jastv/banner/hero.jpg
after: /assets/playtelly/jastv/banner/hero.jpg
Bucket was already populated on every Media
row; this is a computed-field change only.
Why the bucket had to be added back in: cproxy and SeaweedFS's S3
gateway both require the bucket explicitly in the request path
(/<bucket>/<key>) — there's no server-side default to fall back on, unlike
CoreAPI's old ServeAsset which silently assumed a single configured
bucket via h.cfg.Bucket. Once a cache/CDN layer sits between the browser
and SeaweedFS, that implicit assumption no longer holds.
cproxy: new node (cproxy_vod), single static passthrough origin,
bucket-agnostic url-pattern: ^/.*$ — forwards whatever path it receives
straight to SeaweedFS. No per-tenant config yet; each tenant/bucket is
already distinguishable via the path itself, so no origin-key-per-tenant
work was needed for this phase.
nginx: /assets/ now proxies to ${ASSET_SERVING_URL} (cproxy)
instead of ${COREAPI_URL}. Trailing slash on proxy_pass strips the
/assets/ prefix before forwarding — cproxy/SeaweedFS have no such
namespace; /assets/ is purely PlayoutAdmin's own internal routing
convention now, decoupled from whatever actually serves the bytes behind it.
Frontend (PlayoutAdmin): MediaList.jsx now uses two separate
env-driven constants instead of window.location.origin:
const ASSET_BASE = import.meta.env.VITE_ASSET_BASE_URL || ""
const CDN_HOST = import.meta.env.VITE_CDN_HOST || ASSET_BASE
ASSET_BASE for in-app preview (thumbnails, video/audio/image players).
CDN_HOST for copyable/shareable links (URL rows, downloads) — currently
falls back to ASSET_BASE until a real public CDN exists.
CoreAPI's /assets/* route still exists as a rollback fallback but is
not in the active serving path. CoreAPI's role is now control-plane only.
See PlayoutAdmin changelog for compose/nginx/env details.
2026-06-19
Asset URL refactor — relative paths, unified /assets/ route
Why: Backend was baking ASSET_BASE_URL (frontend host) into API responses, coupling CoreAPI to wherever the frontend was deployed. Any misconfiguration broke all media URLs at once.
What changed:
models.go — PublicURL() and ThumbURL() now return relative paths:
before: http://localhost:15173/media-assets/jastv/banner/hero.jpg
after: /assets/jastv/banner/hero.jpg
config.go — AssetBaseURL field removed. Backend no longer reads ASSET_BASE_URL env var.
Route unified to /assets/* — previously CoreAPI served /assets/* but models.go returned /media-assets/..., causing a mismatch when hitting CoreAPI directly.
nginx location /media-assets/ replaced with location /assets/ — no path translation needed, same path end to end.
Client usage:
// React (PlayoutAdmin) — VITE_ASSET_BASE_URL baked at build time
const fullUrl = import.meta.env.VITE_ASSET_BASE_URL + media.url
// HTML pages served by CoreAPI directly
const fullUrl = window.location.origin + media.url
CoreAPI: domain/media/ nested package refactor, config.go added — see git log ca20011
PlayoutAdmin: VITE_ASSET_BASE_URL build arg, nginx.conf.template X-Forwarded-Host fix for tusd resume
2026-06-18
Nested package structure
Migrated domain/media/ from flat package to nested packages:
domain/media/
├── handlers/ — HTTP handler methods
├── repositories/ — GORM queries (MediaRepository)
└── routes/ — wire.go + routes.go
config.go added — all os.Getenv calls centralized, handlers use h.cfg.* instead.
CoreAPI: commit ca20011
2026-06-12
NATS events
Three events now published on processing milestones:
| Subject | When |
|---|---|
media.processing.started |
POST /media/:id/process called |
media.ready |
file moved to final path |
media.thumbnail.ready |
thumbnail saved to SeaweedFS |
2026-06-11
pkg/response migration
Removed local response helpers — all handlers now use pkg/response (Error, OK, Created, Accepted, Paginated).
2026-06-10
handler_nats.go
NATS subscribers separated from routes into dedicated handler_nats.go. RegisterSubscribers() called from wire.go.
2026-06-09
Initial implementation
- TUS upload flow — prepare token, post-finish hook, processMedia goroutine
- Thumbnail generation — ffmpeg (video/image), ghostscript (PDF)
- SeaweedFS asset serving via MinIO client
- Media CRUD — list, get, update, soft delete with optional S3 purge
- Filer move — zero-copy rename within same bucket