Playback Domain
Manages IPTV channels and their live stream origins on Castis Streamer nodes, plus the supporting systems a channel draws on: reusable transcoding/ingest presets, curated channel groups (EPG guides), and platform-health dashboard stats. A channel represents a named stream distributed across one or more Castis Streamer nodes; each origin handles its own ingest independently, but all origins of a channel share the same resolved TranscodingPreset.
Handles:
- Channel CRUD, including a
draft/readystatus lifecycle - Multi-origin fan-out (one channel → multiple streamer nodes), with per-origin failure reporting
- Live (
type=live) and scheduled (type=scheduled, backed by aMediaPlaylist) channels - Transcoding Presets — reusable rendition ladders + audio + ingest tuning, versioned with snapshot history
- Ingest Presets — reusable, address-only multicast sources, versioned with snapshot history
- Channel Groups — curated, ordered, per-group-LCN channel lists (the EPG guide artifact)
- G1 OTT metadata (content provider, genres, languages, custom groups, description)
- Dashboard stats — parallel streamer ping + per-channel UDP ingest probe
- Playback Groups (implemented in
handler_playback.go, not wired intoroutes.go— pending review) - VOD playback (planned)
- CDN source type (planned)
- Config-change push to already-running streams (edit currently updates DB only, see Editing a Channel)
Domain Structure
domain/playback/
├── config.go package playback — Config struct + ConfigFromEnv()
│ (no env vars yet — placeholder)
├── models.go package playback — Channel, ChannelOrigin, ChannelGroup,
│ ChannelGroupMembership, TranscodingPreset,
│ TranscodingPresetHistory, IngestPreset,
│ IngestPresetHistory, PlaybackGroup + consts
├── helper.go package playback — ComputeGroupStatus(), RewritePublicPlaybackURL()
├── validation.go package playback — ValidateCreateChannel, ParseID,
│ ValidateCreate/UpdateChannelGroup, ValidateSetMemberships
│
├── handlers/
│ ├── handler.go package handlers — Handler struct + NewHandler(db, cfg)
│ ├── handler_channels.go package handlers — GetChannels, GetChannelByID, CreateChannel,
│ │ UpdateChannel, DeleteChannel, ToggleStar,
│ │ UpdateChannelStatus, GetChannelGroupMemberships
│ ├── handler_channels_helpers.go package handlers — computeChannelQuality, qualityFromMaxHeight
│ ├── handler_channels_sync.go package handlers — syncChannelPlaylist (unexported — called by
│ │ CreateChannel for scheduled channels only)
│ ├── handler_channel_groups.go package handlers — GetChannelGroups, GetChannelGroupByID,
│ │ CreateChannelGroup, UpdateChannelGroup,
│ │ DeleteChannelGroup, SetChannelGroupMemberships
│ ├── handler_transcoding_presets.go package handlers — GetTranscodingPresets, CreateTranscodingPreset,
│ │ UpdateTranscodingPreset, ListTranscodingPresetHistory
│ ├── handler_ingest_presets.go package handlers — GetIngestPresets, CreateIngestPreset,
│ │ UpdateIngestPreset, ListIngestPresetHistory
│ ├── handler_stats.go package handlers — GetDashboardStats
│ └── handler_playback.go package handlers — Playback Groups CRUD + device assignment
│ (NOT routed — see checklist above)
│
└── routes/
├── wire.go package routes — Setup() — only entry point main.go calls
└── routes.go package routes — registerRoutes() — internal
Data Model
erDiagram
Channel {
int id
int sid
string name
string quality
string sourceType
string type "live | scheduled"
string status "draft | ready"
json ingestConfig
json transcodeConfig
int transcodingPresetId FK
int mediaPlaylistId FK "scheduled channels only"
string contentProvider
string description
string_array genres
string_array languages
string_array customGroups
bool starred
}
ChannelOrigin {
int id
int channelId FK
int streamerId FK
string ingestMode "udp (only mode implemented)"
string ingestUrl
string streamId
string playbackUrl
string playbackUrlHls
string status
}
ChannelGroup {
int id
string name
string status "draft | published"
int version
}
ChannelGroupMembership {
int id
int channelGroupId FK
int channelId FK
int lcn "unique per group"
}
TranscodingPreset {
int id
string name
string code "unique"
bool isDefault
int version
json videoRenditions "ladder — length 1 behaves like old single"
string audioCodec
int audioBitrate
string chunkDuration
string fragmentDuration
string netTimeout
bool forceClean
bool dropSecondAudio
}
IngestPreset {
int id
string name
string code "unique"
bool isDefault
int version
string transport
string udpHost
int udpPort
}
Channel ||--o{ ChannelOrigin : "has many"
ChannelOrigin }o--|| Streamer : "targets (distribution domain)"
Channel }o--o| TranscodingPreset : "resolves ingest+transcode config from"
ChannelGroup ||--o{ ChannelGroupMembership : "has"
ChannelGroupMembership }o--|| Channel : "references"
TranscodingPresetHistory / IngestPresetHistory are write-only snapshot
logs (one row per version, taken before every update) — never read by
streamerclient or CreateChannel, purely a "what did vN look like"
answer for the version-history UI. Safe because a Channel never holds a
live reference back to the preset row — CreateChannel copies the resolved
ingestConfig/transcodeConfig in at creation time.
Channel Creation Flow
sequenceDiagram
participant Client
participant CoreAPI
participant PostgreSQL
participant Streamer
Client->>CoreAPI: POST /api/v1/playback/channels
CoreAPI->>CoreAPI: Validate + BuildStreamID from name
CoreAPI->>CoreAPI: Patch ingestConfig — reserve timedAlternativeUrl,<br/>defaultUrl (scheduled/fallback playlist)
CoreAPI->>PostgreSQL: Save Channel record (Status always "draft")
loop For each origin
CoreAPI->>PostgreSQL: Lookup Streamer by ID
CoreAPI->>CoreAPI: Check Streamer.Role == "ingest" (UDP mode only)
CoreAPI->>CoreAPI: BuildPayload (merge ingest + transcode)
CoreAPI->>Streamer: POST /api/streams/:streamId
Streamer-->>CoreAPI: 200/201/204 (success) or any other status (failure)
CoreAPI->>PostgreSQL: Save ChannelOrigin + PlaybackURL (only on success)
end
opt type=scheduled with MediaPlaylistID
CoreAPI->>Streamer: PATCH scheduled pl:/// slot with playlist SMIL
end
CoreAPI-->>Client: 201 { success, data, failures[] }
Partial success
If some origins succeed and others fail, the channel is still created. Failures are reported in the failures array, each with the streamer's actual response body (failures[].response). Only if all origins fail is the channel rolled back and a 502 returned.
Streamer status 500 is a real failure (fixed 2026-08-03)
An earlier version of this handler treated a 500 from the Castis Streamer as non-fatal — a genuine rejection was silently recorded as a successful origin. Only 200/201/204 count as success now; see changelog.
Role check
CreateChannel rejects a UDP-mode origin whose streamer has Role: "cproxy" — UDP ingest requires Role: "ingest" (see Distribution for the full Streamer.Role concept). This is enforced silently per-origin, not as an upfront validation error.
Stream ID Generation
Channel names are slugified into stream IDs:
"BBC World" → "bbc_world.stream"
"CNN HD" → "cnn_hd.stream"
The stream ID is shared across all origins of the same channel.
Ingest Config — reserved keys
ingestConfig is a client-supplied JSON blob, but CreateChannel always
patches in two reserved keys before it's sent to the streamer or persisted,
regardless of what the client sends:
timedAlternativeUrl→pl:///scheduled, with an emptyscheduled: []array reserved. This is the slot a separate time-based scheduling API PATCHes later — it must exist from creation time, since Castis won't accept a brand-newpl://key viaPATCH, only updates to a key that already exists.defaultUrl→ reserved for scheduled channels with a linkedMediaPlaylistID, or live channels with aFallbackPlaylistID. Resolves each playlist item's SMIL path viaresolvePlaylistFilePaths(which gates on the playlist beingstatus=ready) into apl:///defaultorpl:///fallbackarray. Mutually exclusive in practice — a request is either scheduled+MediaPlaylistIDor live+FallbackPlaylistID, never both.
Where chunk/fragment/timeout/forceClean come from
As of the 2026-07-31 ingest/transcode merge, these five fields are top-level
ingestConfig fields (matching the streamer's own wire format — see
POST /api/streams/path/:id in the Castis Streamer API docs), sourced one of
two ways:
- A
TranscodingPresetis selected (required whenever the channel actually transcodes) — itschunkDuration/fragmentDuration/netTimeout/forceClean/dropSecondAudioare used, regardless of passthrough. - Passthrough, no preset selected (fixed 2026-08-03) — the client sends
these five fields directly in
ingestConfig; there's no preset detour required just to get ingest tuning on a channel that isn't re-encoding anything.
{
"ingestConfig": {
"chunkDuration": "2s",
"fragmentDuration": "1s",
"netTimeout": "500ms",
"forceClean": true,
"failoverCount": 1
},
"origins": [
{ "streamerId": 1, "ingestUrl": "udp://239.0.21.41:5000?fifo_size=1000000&overrun_nonfatal=1", "ingestMode": "udp" }
]
}
pl:/// Loop — ingestConfig already contains the url field as a
pl:/// reference; ingestUrl in origins is still required by validation
but effectively superseded by whatever the resolved reserved keys end up
pointing at.
Transcoding Presets
Reusable rendition ladders — the "Transcoding Preset" picker in a channel's Transcode step selects from these. Always an array of renditions; a length-1 ladder behaves identically to the old "single" variant (no separate single/ABR type split).
Also the sole source of a channel's ingest tuning (see above) —
chunkDuration/fragmentDuration/netTimeout/forceClean/dropSecondAudio
moved here from IngestPreset in the 2026-07-31 merge, since these are
top-level ingestConfig fields the streamer needs regardless of whether
anything is actually re-encoded.
Versioned: every UpdateTranscodingPreset call snapshots the prior row to
TranscodingPresetHistory before mutating in place and bumping Version.
Editing a preset changes what future channel creations that select it
will use — existing channels are unaffected, since their config was copied
in at creation time.
| Route | Description |
|---|---|
GET /transcoding-presets |
List all presets |
POST /transcoding-presets |
Create — Version forced to 1 |
PUT /transcoding-presets/:id |
Update — snapshots history, bumps Version |
GET /transcoding-presets/:id/history |
List version snapshots, newest first |
Known bug, fixed 2026-08-03
ForceClean previously could never be persisted as false — see changelog for the root cause (a gorm default:true tag combined with GORM's zero-value-skip behavior on struct Updates()).
Ingest Presets
Address-only reusable multicast sources — "one already-known-good multicast
source, applied across N streamers," as opposed to retyping a UDP address
per channel. As of the 2026-07-31 merge this is only
Transport/UDPHost/UDPPort — no tuning fields; those live on
TranscodingPreset now (see above). Same versioning pattern as Transcoding
Presets.
| Route | Description |
|---|---|
GET /ingest-presets |
List all presets |
POST /ingest-presets |
Create — Version forced to 1 |
PUT /ingest-presets/:id |
Update — snapshots history, bumps Version |
GET /ingest-presets/:id/history |
List version snapshots, newest first |
Same bug class as Transcoding Presets — not yet fixed
UpdateIngestPreset still uses a bare h.db.Model(&preset).Updates(in) — flipping IsDefault true → false will likely hit the same GORM zero-value-skip issue documented in the Transcoding Presets fix above. Flagged, not yet fixed.
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.
PUT /channel-groups/:id/channels(SetChannelGroupMemberships) is a full replace: deletes every existing membership for the group and recreates from the request body, rather than diffing add/remove.- Enforced uniqueness: a channel can't appear twice in the same group
(
idx_group_channel), and no two channels in the same group can share an LCN (idx_group_lcn). - A
draftchannel cannot be added to a group —SetChannelGroupMembershipsguards against it (see Draft/Ready status below). GetChannelGroupByIDandSetChannelGroupMembershipsboth preloadChannels.Channel.Origins.Streamerand callRewritePublicPlaybackURL, so the response carriesPublicPlaybackURLbuilt fromstreamer.publicHost— not the possibly-Docker-internal storedPlaybackURLstring.- Deliberately simple lifecycle for now:
Status(draft/published) + aVersioncounter, no snapshot/rollback history (unlike the presets above) — a real future feature, not built here.
| Route | Description |
|---|---|
GET /channel-groups |
List, optional ?status=draft\|published filter |
GET /channel-groups/:id |
Full detail — ordered channel list with playback URLs |
POST /channel-groups |
Create — always starts draft |
PUT /channel-groups/:id |
Update name/description/status |
DELETE /channel-groups/:id |
Delete group + its memberships |
PUT /channel-groups/:id/channels |
Full-replace channel list + LCNs |
Draft/Ready Status
Every Channel starts Status: "draft" on creation, regardless of what the
client sends. PATCH /channels/:id/status (UpdateChannelStatus) flips it
to ready (or back) — no side effects on Origins or streamer state, just
the column. This is separate from ChannelType (live/scheduled), which
describes the ingest shape, not whether the channel is considered
finished/confirmed.
PATCH /channels/:id/status
Body: { "status": "ready" }
Dashboard Stats
POST /stats/dashboard — pings every registered streamer and probes UDP
ingest for one origin per channel, all in parallel, within a 10s timeout.
Returns channel counts (total/live/scheduled), per-streamer health, and
per-channel ingest health (healthy/unhealthy/unknown).
Scheduled-channel heuristic predates the Type field
This endpoint was added 2026-06-30, before the live/scheduled ChannelType split (2026-07-14). Its "scheduled" count is still a heuristic — any channel whose IngestConfig contains a defaultUrl key — rather than a direct Type == "scheduled" check. Hasn't been revisited since Type became the real source of truth.
Editing a Channel
PUT /channels/:id (UpdateChannel) supports partial updates to metadata
only — name, synopsis, and the G1/discovery fields (primaryGenre,
genres, keywords, tags, contentProvider, languages,
customGroups). Identity/ingest/transcode config are not editable here.
MediaPlaylistID is create-time only for now. Updating does not push
anything to the running stream on the streamer — that's a deferred sync
feature (see checklist at the top of this page).
Environment Variables
None currently — config.go is a placeholder (Config{}) for future
settings such as default stream quality or a max-origins-per-channel cap.
Changelog
See changelog.md
API Routes
Swagger spec is incomplete
playback.yaml currently only documents GET/POST /channels and GET /channels/:id — Channel Groups, Transcoding/Ingest Presets, dashboard stats, and the status/star endpoints are not yet in the OpenAPI spec. The routes tables above are the accurate reference until this is filled in.