Skip to content

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 by wantsOptimizeImage (MediaType==Image && TranscodingProfileID != nil). Uses ffmpeg's built-in libwebp encoder (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, sets Media.OptimizedKey.
  • rasterizeDocument (handler_document.go) — gated by wantsRasterizeDocument (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, one MediaSlide row per page. DurationSec on each slide is a default only, copied from the profile's defaultSlideDurationSec at 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 stoppedsyncCount 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:

  1. 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).
  2. Full remux (-c:v copy, fresh container, -movflags +faststart) — this is what defragments a multi-mdat source as a side effect, even without re-encoding video.
  3. Two-pass loudnorm (EBU R128, default -23 LUFS, configurable via LOUDNORM_TARGET_LUFS env — 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:

  1. DELETE /media/:id never purged storage by default — the frontend never passed ?purge=true (the only caller in MediaList.jsx was a bare fetch(..., {method:'DELETE'})), so "Delete" in the UI only ever soft-deleted the DB row (gorm.Model's DeletedAt). Fixed: purge now defaults to true — pass ?purge=false to opt into the old DB-only behavior.
  2. Even with purge=true, cleanup only ever removed StorageKey/ ThumbnailKey/NormalizedKey individually — missing the entire hls/+smil/ tree for transcoded video (every CMAF rendition segment, both master playlists) and, as of today, optimized// slides/ for image/document. Fixed: transcoded media (any TranscodingProfileID != 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 ignored json.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/upload page. Confirmed dead (no route, no import anywhere) and, separately, broken — it never sent transcodingProfileId at 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:

  • TranscodingProfile model — 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 one ffmpeg pass per rendition, output as CMAF (fragmented MP4 + HLS playlist per rendition, master.m3u8 tying 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)
(Previously: Browser → nginx (/assets/) → CoreAPI:3000 → SeaweedFS)

What changed:

models.goPublicURL()/ThumbURL() now include the Bucket field in the returned path:

before: /assets/jastv/banner/hero.jpg
after:  /assets/playtelly/jastv/banner/hero.jpg
No DB migration needed — 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.goPublicURL() and ThumbURL() now return relative paths:

before: http://localhost:15173/media-assets/jastv/banner/hero.jpg
after:  /assets/jastv/banner/hero.jpg

config.goAssetBaseURL 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