Changelog
2026-08-21
Channel metadata UI wired up (ContentProvider, Genres, Service List Membership)
ContentProvider has been a real Channel column since 2026-07-14 and
UpdateChannel already accepted it, but no field for it existed anywhere in
PlayoutAdmin — added to ChannelGeneralTab.jsx's Discovery & EPG section.
Additional Genres changed from free-text tags to a fixed toggle-chip grid.
No Genre vocabulary table was added — options are a live client-side
aggregation of distinct values already in use across all channels (same
approach the Channel Groups Genres tab below uses), seeded with a small
starter list so the grid isn't empty on a fresh install. Keywords/Tags/
Languages/CustomGroups got the same treatment as an addable combobox
(pick an existing aggregated value or type a new one).
Service List Membership — GetChannelGroupMemberships
(GET /channels/:id/groups) existed server-side already (see Domain
Structure) but was never called from the frontend. Now wired into
ChannelGeneralTab.jsx, showing every ChannelGroup a channel belongs to,
that group's status, and the channel's LCN within it.
/channels/:id/watch removed — real On-Air Status tab added instead
The standalone live-preview page (ChannelPreview.jsx) is gone. It wasn't
decorative — real Shaka Player DASH/HLS playback — so its useful pieces
(all-origins playback-URL list, live player-event log, UDP-publisher badge)
moved into the existing ChannelStatusPanel sidebar's Origin Playback tab,
which already had its own working Shaka Player instance.
The sidebar's On-Air Status tab, previously a placeholder, is now real —
backed by the existing GetDashboardStats (2026-06-30). Shows live ingest-
detected/healthy status, the probed ingest URL, and per-origin streamer
online/offline. Honest about the one real limitation carried over from that
endpoint: only a channel's first origin ever gets ingest-probed. Source
Status and Activities tabs remain placeholders — no backing data exists yet.
Channel Group creation — 3 steps, real filters, Genres aggregation tab
ChannelGroupCreateSheet.jsx restructured from 2 steps (Channels, LCN) to 3
(Basic Info, Selection, LCN) — Basic Info split out of the old combined
first step. The Selection step's channel picker gained a Filters panel
(Content Providers / Genres / Languages / Custom Groups), each a real,
live-aggregated distinct-value list from existing channels, AND-combined
when multiple are active. A "Templates" filter chip is shown but disabled —
no backing concept exists for it anywhere (not even a hardcoded list), so it
isn't faked.
ChannelGroupList.jsx gained a Genres tab alongside the existing
groups table — a real aggregation (genre → channel count), not a managed
list. No genre create/edit/delete UI, since genre isn't a real entity.
Multi-rendition SMIL duplication fixed (transcode-mode channels)
Feeding the streamer a full multi-rendition ladder.smil on a channel that
actually transcodes (not passthrough) caused it to build an independent
decoder per source rendition, each re-encoding into the entire target
ladder — confirmed live against the real streamer: a 2-rendition source SMIL
fed into a 4-rung transcode config produced 8 output representations,
not 4. The streamer has no track-selection logic of its own; it just
decodes and re-encodes everything it's handed.
Fix: resolvePlaylistFilePaths now resolves to Media.ProgressiveKey
(the single top-rung merged MP4, already produced by the transcode
pipeline) instead of Media.SmilKey, whenever the channel isn't full
passthrough — derived from the same len(TranscodeConfig) == 0 signal
ValidateCreateChannel already used. Full passthrough is the one case that
still gets the real multi-rendition SMIL, since that's genuinely what it
needs (an untouched ABR ladder delivered to viewers). Applies to both a
scheduled channel's default content and a live channel's fallback content.
DELETE added for Transcoding Presets, Ingest Presets, and Media Playlists
DeleteTranscodingPreset — real in-use guard (Channel.TranscodingPresetID
count, 409 if referenced). DeleteIngestPreset — guards only on IsDefault,
since no real FK-backed usage relation exists for it. Both wired as
DELETE /transcoding-presets/:id / DELETE /ingest-presets/:id
(2026-08-18).
DeletePlaylist (domain/media, DELETE /playlists/:id) — MediaPlaylist
had no delete at any layer until now (no handler, no route, no frontend
call). Same in-use-guard shape, checked via a raw query against channels
(not a typed join — domain/media doesn't import domain/playback).
Channel and ChannelGroup delete endpoints already existed server-side
(since the 2026-07-14 introduction) — only their frontend delete buttons
were missing; added to ChannelList.jsx/ChannelGroupList.jsx.
Publish UDP moved from TranscodingPreset to the channel itself
TranscodingPreset.Publishers (added 2026-08-18, below) was removed again
the same day, once it became clear a preset can be reused across many
channels — baking a UDP publish destination into it would make every
channel sharing that preset collide on the same multicast/unicast address.
Publish UDP output is now channel-level state in PlayoutAdmin's channel
creation wizard (useChannelForm.js), folded straight into that channel's
ingestConfig.publishers at creation. No CoreAPI change was needed either
direction — Channel.IngestConfig is stored as an opaque jsonb blob.
Passthrough mode added to live TranscodingPreset
TranscodingPreset.PassthroughMode string ("" / "full" / "video",
nullable, no schema risk on the existing populated table). "full" means
genuine no-transcode passthrough — VideoRenditions/FilterChain are
force-cleared to [] server-side, and PlayoutAdmin's buildPayload omits
the transcoders key entirely (confirmed against the real streamer source:
no Decoder is ever constructed for an omitted/empty transcoders block).
"video" means video passthrough with audio still encoded — confirmed
against the real streamer source that this is enforced per-track inside its
Transcoder stage, not an approximation.
CreateTranscodingPreset/UpdateTranscodingPreset skip the "renditions
required" validation for either passthrough mode. PassthroughMode was
added to UpdateTranscodingPreset's explicit Select() column list for the
same zero-value-skip reason ForceClean needed it (2026-08-03) — reverting
"full"/"video" back to "" must not be silently dropped.
GET /transcoding-presets/:id and GET /ingest-presets/:id added (2026-08-17)
No single-preset fetch existed before this — only list and history reads.
Backs the new preset detail pages/sidebars (see PlayoutAdmin changes below).
The transcoding-preset variant also returns real InUse channel data (the
same query the delete guard above uses); the ingest-preset variant doesn't,
since no real usage relation exists for it.
IngestPreset probe-result caching (2026-08-06)
Probe streams are throwaway (deleted right after each request) — added
LastProbeResult/LastProbedAt on IngestPreset so "what did this source
look like" survives past the probe itself, for compatibility checks against
a selected TranscodingPreset.
Deinterlace filter chain on Transcoding Presets (2026-08-05)
FilterChain (bwdif/weave) added to TranscodingPreset, applied once
before the rendition ladder in the streamer payload.
PlayoutAdmin — sidebar summary + full detail pages for presets
TranscodingPresetDetail.jsx/IngestPresetDetail.jsx — new full-page
:id detail views, plus PresetInfoSheet.jsx/ChannelStatusPanel-style
sidebar summaries. IngestPresetDetail.jsx also carries several
not-yet-functional mock sections (Container/Decoder, Multicast/SRT access,
Audio pass-through, Subtitles, Timing, SCTE-35) matching design intent for
fields IngestPreset doesn't have columns for yet — clearly banner-flagged
as preview-only, matching the pattern TranscodingPresetDetail.jsx's own
mock sections (Encryption/DRM, Ad Signaling, Compute Target, Source
Requirements) already established.
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.