Changelog
2026-08-03
ForceClean persistence bug fixed (TranscodingPreset)
forceClean on TranscodingPreset could be set to true but never actually
persisted as false, on both create and update.
Root cause: the field carried a gorm default:true tag. GORM omits
zero-value fields with a default tag from INSERT (letting the DB default
apply) — so forceClean: false from the frontend was silently written as
true on CreateTranscodingPreset. Separately, UpdateTranscodingPreset
used a bare h.db.Model(&preset).Updates(in) — GORM's struct-based Updates
skips all zero-value fields (not just ones with a default tag), so flipping
forceClean true → false on an existing preset silently did nothing either.
Fix: dropped default:true from the ForceClean gorm tag in
models.go, and changed UpdateTranscodingPreset to .Select(...) an
explicit column list instead of a bare Updates(in), so zero-value fields
(ForceClean, DropSecondAudio) are actually written.
CreateChannel no longer treats a streamer 500 as success
CreateChannel's per-origin loop previously excluded 500 from its failure
check (res.StatusCode != 200 && ... && res.StatusCode != 500) — a genuine
streamer-side rejection (bad payload, invalid config) was silently treated as
a successful origin, and the ChannelOrigin row got persisted even though the
streamer never actually created the .stream file.
Fix: only 200/201/204 count as success now. The streamer's actual
response body is captured and returned in failures[].response (in addition
to the existing request/response logging in pkg/StreamerClient), so the
real rejection reason is visible in the API response itself.
Passthrough channels no longer require a TranscodingPreset
Reverts a short-lived change where every channel — including passthrough —
had to select a TranscodingPreset just to supply
chunkDuration/fragmentDuration/netTimeout/forceClean/dropSecondAudio.
Forcing a full ABR ladder selection onto a passthrough channel (which never
builds a transcoders block at all) was the wrong tradeoff.
CreateChannelRequest's ingestConfig now accepts these five fields
directly for passthrough channels with no preset selected — PlayoutAdmin
exposes them as a standalone "Ingest Tuning" section in that case (see
SectionTranscode.jsx in PlayoutAdmin). No backend/model change was needed —
ingestConfig was always just a client-supplied JSON blob; only the
frontend's validation and preset requirement changed.
2026-07-31
Ingest/Transcode preset merge
IngestPreset domain added (GET/POST /ingest-presets, PUT /ingest-presets/:id,
version history), alongside a restructuring of where ingest-tuning fields live:
ChunkDuration,FragmentDuration,NetTimeout,ForceClean,DropSecondAudiomoved fromIngestPresetontoTranscodingPreset. These are top-level fields in the streamer's.streamfile (sessionAttribute.chunkDuration, etc.) regardless of whether anything is actually transcoded, soTranscodingPresetbecame their sole source — required on every channel at the time (see the 2026-08-03 revert above).IngestPresetis address-only as of this merge:Transport,UDPHost,UDPPort,Status. Its purpose is "one already-known-good multicast address, reusable across N streamers" — not ingest tuning.- Both
TranscodingPresetandIngestPresetfollow the same snapshot-before-mutate versioning pattern:Version int, a*Historytable (TranscodingPresetHistory/IngestPresetHistory) written before every update,Update*handlers bumpVersionand mutate the row in place. Safe becauseChannel.IngestConfig/TranscodeConfigare copied from the resolved preset at channel-creation time — a channel never holds a live reference back to the preset row.
Note: UpdateIngestPreset still uses the same bare Updates(in) pattern
that caused the ForceClean bug above — IsDefault: false on an update will
likely hit the same zero-value-skip issue. Not yet fixed; flagged here since
it's the same bug class.
2026-07-23
Channel draft/ready status lifecycle
Channel.Status added (draft | ready), defaulting to draft on every
CreateChannel regardless of what the client sends. PATCH
/channels/:id/status (UpdateChannelStatus) flips it explicitly — no other
side effects on Origins/streamer state. SetChannelGroupMemberships is
guarded against assigning a draft channel to a ChannelGroup.
This is orthogonal to ChannelType (live/scheduled), which describes the
ingest shape — Status describes whether the channel is considered
finished/confirmed.
2026-07-14
Channel Groups + Transcoding Presets introduced
Two significant additions landed together:
Channel Groups (/channel-groups) — a curated, ordered list of channels
with a per-group LCN (logical channel number), the artifact an OTT client
fetches to render its channel guide:
- GET/POST /channel-groups, GET/PUT/DELETE /channel-groups/:id
- PUT /channel-groups/:id/channels (SetChannelGroupMemberships) — full
replace: deletes all existing memberships for the group and recreates from
the request body, rather than diffing add/remove
- ChannelGroupMembership enforces a channel can't appear twice in the same
group (idx_group_channel) and no two channels in a group can share an LCN
(idx_group_lcn)
- GetChannelGroupByID/SetChannelGroupMemberships preload
Channels.Channel.Origins.Streamer and call RewritePublicPlaybackURL so
the frontend gets streamer.publicHost-based playback URLs, not the
(possibly Docker-internal) stored PlaybackURL string
- Status: draft | published, plus a simple Version counter — no
snapshot/rollback history yet (deliberately simplest-shape-for-now, unlike
the preset versioning below)
Transcoding Presets (/transcoding-presets) — reusable rendition
ladders, replacing hand-built per-channel CMAF profile arrays:
- GET/POST /transcoding-presets, PUT /transcoding-presets/:id,
GET /transcoding-presets/:id/history
- A ladder is always an array of renditions (no single/ABR split — a
length-1 ladder behaves like the old "single" case)
- Channel.TranscodingPresetID — nullable FK, nil on the legacy
custom-profile path
G1 OTT metadata fields added to Channel: ContentProvider,
Description, Genres, Keywords, Tags, Languages, CustomGroups,
PrimaryGenre — all optional, all pq.StringArray where plural.
2026-06-30
Dashboard stats endpoint added
POST /stats/dashboard (GetDashboardStats) — pings every registered
streamer and probes UDP ingest for one origin per channel, all in parallel,
within a 10s context timeout. Returns channel counts (total/live/scheduled —
"scheduled" here is heuristic: any channel whose IngestConfig has a
defaultUrl key), origin health per streamer, and per-channel ingest health
(healthy/unhealthy/unknown — unknown for pl:///-only origins with no
real UDP address to probe).
Note this predates the ChannelType live/scheduled split (2026-07-14) — the
"scheduled" heuristic here (presence of defaultUrl) is a proxy, not a
direct Type check, and hasn't been revisited since Type became the real
source of truth.
2026-06-25 and earlier
Domain established
Extracted from a flat domain/service/ package into the current nested
domain/playback/ structure (handlers/, routes/wire.go + routes.go,
models.go, validation.go, helper.go, config.go) — following the same
domain-driven pattern as distribution. Channel/ChannelOrigin migrated to
reference distribution.Streamer directly rather than a local duplicate.
Core channel CRUD, multi-origin fan-out to Castis Streamer nodes via
streamerclient.CreateStream/DeleteStream, and pkg/response-based
pagination were already in place from the domain's original introduction
(2026-05-18) and carried forward through this restructuring largely intact.