Skip to content

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/ready status lifecycle
  • Multi-origin fan-out (one channel → multiple streamer nodes), with per-origin failure reporting
  • Live (type=live) and scheduled (type=scheduled, backed by a MediaPlaylist) 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 into routes.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:

  • timedAlternativeUrlpl:///scheduled, with an empty scheduled: [] 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-new pl:// key via PATCH, only updates to a key that already exists.
  • defaultUrl → reserved for scheduled channels with a linked MediaPlaylistID, or live channels with a FallbackPlaylistID. Resolves each playlist item's SMIL path via resolvePlaylistFilePaths (which gates on the playlist being status=ready) into a pl:///default or pl:///fallback array. Mutually exclusive in practice — a request is either scheduled+MediaPlaylistID or 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 TranscodingPreset is selected (required whenever the channel actually transcodes) — its chunkDuration/fragmentDuration/netTimeout/ forceClean/dropSecondAudio are 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:/// LoopingestConfig 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 draft channel cannot be added to a group — SetChannelGroupMemberships guards against it (see Draft/Ready status below).
  • GetChannelGroupByID and SetChannelGroupMemberships both preload Channels.Channel.Origins.Streamer and call RewritePublicPlaybackURL, so the response carries PublicPlaybackURL built from streamer.publicHost — not the possibly-Docker-internal stored PlaybackURL string.
  • Deliberately simple lifecycle for now: Status (draft/published) + a Version counter, 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.