🎬 PlayoutGo Operations Manual v3.64.0

A Go multichannel broadcast playout platform. Demuxes MP4 (H.264/AAC), muxes live MPEG-TS, and streams via SRT, HLS and an optional RTMP push β€” with no FFmpeg or GStreamer on the on-air path. It builds into one self-contained binary. ffmpeg/ffprobe, when installed, power the extras: the gap slate, thumbnails, demo watermarking, local encodes, the CBR test and audio-language detection. Every feature, with the version that introduced it, is in the feature reference; what is still to come is in the roadmap.

Quick links:  Web UI  Β· HLS Viewer  Β· EPG index and M3U: copy them, with your feed token, from the console's πŸ“‘ EPG tab

Overview

PlayoutGo is a multi-tenant broadcast playout server designed to run 24/7 live channels from a library of uploaded MP4 files. Each channel has an independent playlist, schedule, and output pipeline. The system outputs to:

And one input: every channel has a live RTMP publishing point (rtmp_port, default 1935) that an operator can cut to and back from (Live insertion).

HLS is always active when a channel is running, even in SRT-only mode. This lets operators monitor playout in a browser without interrupting the SRT feed.

Feature Reference 3.60.0

Everything PlayoutGo does, by area, with the version that introduced it and where it is described. A later version in a description is when that part changed. What is still to come is in the roadmap; each version's details are in the changelog.

Channels & outputs

FeatureSinceWhat it does
Channels1.0.0One live stream with its own rundown, output settings, loop flag and status
Output modes hls / srt / both1.0.0HLS always on; SRT runs alongside; an SRT failure never stops HLS
SRT caller and listener1.0.0Dials out to an ingest or accepts pulls; reconnects in the background
SRT stream-ID routing, shared ports1.4.0Channels share a port when passphrase, latency, buffer and bandwidth match
SRT clean join1.4.0New receivers start on an IDR with SPS/PPS; tables sent on connect
SRT latency, buffer, max bandwidth1.2.0Bounded settings; latency in microseconds in the URL
SRT AES passphrase1.2.010–79 characters, never returned by the API; explicit remove control
SRT encryption enforced in listener mode3.48.0Callers with a missing or wrong passphrase are refused
SRT diagnostics1.11.0Bind and settings clashes in the error log; paste-ready connect URL; refusal summaries
HLS output1.0.04 s keyframe-aligned segments, 6-segment window, atomic playlist, PROGRAM-DATE-TIME (3.52.0)
HLS master playlist3.54.0index.m3u8 is a master for a multi-language or captioned channel; main.m3u8 is the media playlist; CODECS and measured BANDWIDTH
Measured master playlists3.55.0RESOLUTION from the SPS; CODECS and BANDWIDTH from the segments in the window, on every ABR rung too
Canonical HLS paths1.5.0/hls/<slug>-<id>/ everywhere; old aliases still work
Loop playlist, re-shuffle on loop1.5.0Replays after the last item; optionally re-ordered on each pass (2.95.0)
Restart on output-setting change1.73.0A running channel restarts so a changed output, rate or DVB identity takes effect
Idempotent start2.83.0Starting a running channel answers 200 already_running
RTMP push3.28.0Pushes the channel to YouTube, Facebook or Twitch; tallest ABR rung (3.52.0)
UDP / RTP output3.63.0The transport stream β€” tables, SCTE-35, constant-rate padding β€” to a multicast group or unicast address, raw or RTP, TTL, interface, DSCP, paced; an SRT receiver joins without restarting it
Alert webhooks3.63.0Signed JSON events when a channel or an output goes down or comes back, a file is unplayable, dead air airs, or a channel logs an error; retried; administrators only
SDI output3.62.0A DeckLink SDI port per channel: HD and SD formats, field order, 2/8/16 embedded audio channels, restarted when it fails; administrators only
Diagnostics endpoint1.0.0/diag: item status, file presence, HLS directory, recent errors

Rundown & scheduling

FeatureSinceWhat it does
Scheduler and auto-start1.0.010 s tick; starts a stopped channel when an anchor is due
Schedule-aware restart1.0.0Resumes the right item at the right offset, honouring trims
Rundown1.4.0Playlist and schedule in one ordered list beside the library
Anchored and floating items1.4.0πŸ”’ exact air times; πŸ”“ items chain by duration and reflow live
Gap, overrun and overlap detection1.4.0GAP and OVERLAP rows, +over badges, footer counts
NOW line, countdown, day separators1.4.0Live position marker and totals
Bulk actions, Duplicate, Undo1.4.0Selection, move, clear times, remove; 10-level undo taken only after the server agrees
ON-AIR guard1.4.0Asks before touching the playing item; the change runs inside the question (3.52.0)
Keyboard reorder3.47.0Move up / move down per row, with the same guard and undo
Channel timezone1.57.0IANA zone per channel drives the rundown, grids, repeats and imports
Day and Week grids1.67.0Hourly grids; drag media onto an exact minute
Gap filler (bin-packed pool)1.6.0Clips fitted into gaps longest-first, no back-to-back repeats (1.27.0)
Black-and-silence slate1.65.0Generated H.264/AAC slate covers gaps on every output
Generate1.19.0Random rundown by count or duration, with seed and no-repeat
Schedule import1.19.0CSV, native XML, BXF subset, XMLTV; air times become anchors
Fill day2.70.0Spreads the library across a day as minute anchors
Trim points1.38.0In and out points per item without re-encoding
Repeating days1.38.0Copies a day to N days, weekdays or listed dates
Manual control2.51.0Take next, restart item, jump β€” also during scheduled waits
Media-delete protection1.73.0Deleting an on-air or scheduled file is refused, naming the channel

Traffic

FeatureSinceWhat it does
Traffic tab3.49.0Plans the broadcast day before air: logs, library, templates, release, load
RPX import and export3.49.0ProTrack 696-column records read and written byte for byte
Log integrity check3.49.0Full 24 h, quarter-hour grid, graphics inside programs, DST day flagged
Release gate3.49.0Only released logs load; errors need an acknowledged, audited reason
Load into Rundown3.49.0Dry run first; programs anchored, the rest chained and trimmed
House IDs and learning from a log3.49.0Match files by house code; derive the profile, library and a day template
Day scheduler and graphics rules3.49.0Builds days from templates with rotations, station IDs and rule-made graphics events
Series rotation3.53.0A block airs the same episode again, the next one, or cycles; learned from the logs
Hour spots3.53.0X{DOW}{HH}00S opens the break ending on each hour; learned from the log
Fixed spots3.53.0A promo, filler or ID at an exact second, inside whichever break holds it
Rotation over several days3.53.0rotation_days: promos that have not aired for longest come first
Flat-template anchors and filler programs3.53.0A flat block starts on its second; short programs fill the time before it
Clock-change days3.58.0Per channel: by elapsed time (asks at load), or by clock time β€” the skipped hour's events dropped, the repeated hour aired once
Dayparts per weekday3.58.0A day of the week can have its own logo dayparts, learned from that day's log; a weekend log no longer changes the weekdays'
Up Next spots3.61.0X{DOW}{HH}{MM}S names the next program’s day and start; 15 s promos, placed before any program the library has a spot for
E/I by daypart3.61.0ei_suffix: the E/I graphic is the daypart logo plus _E (ENG_8_E in business hours, ENG_6_E otherwise)
Promo airing rules3.61.0Per promo, filler or ID: days and times it may air, first and last day, at most so many a day; never relaxed
Series starting points3.61.0series_starts: from a date, a series resumes at the episode chosen; learned series blocks start over when the episodes run out
Autonomous channels3.61.0A channel whose schedules arrive complete (3ABN RUSS) is imported and loaded, never generated
RapidPlay X2000 fields3.62.0Every column of the RapidPlay X2000 document read, kept and written back; H, '-' and B records, CO and OE events; SOM cues the file
Gaps before clock starts3.62.0Up to 10 s before a clock-start event, or at the end of the day, is a warning, as ProTrack’s own logs have
3ABN requirements3.64.0Every requirement 3ABN gave the traffic system, its source and the test that proves it; a test keeps the table true
Station ID rotation3.62.0id_codes: several station IDs rotate, least recently aired first, keeping room for the break’s Up Next spot
Titles and clock-start channels3.62.0titles, clock_every_event, file_ext: CANADA’s titles and .txt files, CANADA’s and PRAIZ’s clock start on every event
Promo minimum gap3.59.0promo_min_gap_secs: a promo airs again no sooner than this (3600 = not twice in an hour); a break that cannot be filled otherwise is filled anyway and named in the notes
Template editor3.53.0A table of blocks and fixed spots, coverage shown as you type
Traffic API3.49.0Logs, import, generate, profile, library, templates, release, load

Live insertion & ad breaks

FeatureSinceWhat it does
RTMP publishing point3.2.0rtmp://HOST/live/<key> per channel; rtmp_port 1935, -1 disables
Take live / Return3.2.0Cuts on the next keyframe mid-item (3.21.0); returns within 100 ms
Random stream keys3.48.0192-bit live_ keys, New key, encrypted at rest
Feed-loss auto-return3.52.0No frame for 5 s: back to the schedule, audit live.lost
One encoder per key3.52.0A second publisher is refused, or replaces a stalled one
Live inserts in the as-run log3.52.0A row labelled LIVE β€” RTMP from <address>
SCTE-35 passthrough1.0.0Splice data in the source is carried through
Scheduled ad breaks1.54.0at_ms/duration_ms per item, after the trim-in
Conformant splice_insert1.53.0Correct lengths and CRC, break_duration with auto_return; CUEI and per-PID counters (3.52.0)
HLS ad signalling1.55.0CUE-OUT, CUE-OUT-CONT, CUE-IN, DATERANGE SCTE35-OUT
Ad-break editor and patterns2.75.0πŸ“’ per row; every N minutes; apply to every item (2.77.0)
Break now3.44.0An immediate avail of any length while on air, audited; says "queued" when it must wait (3.53.0)
An avail inside an avail3.53.0The open avail returns first; every cue-out has its own return

Audio, captions, DVB & constant rate

FeatureSinceWhat it does
Silent audio synthesis1.4.0Video-only files get a silent 48 kHz stereo AAC track
Codec checking1.80.0HEVC/VP9/AV1 refused by name; unsupported audio replaced with silence
Audio track inventory2.94.0audio_tracks with language, channels, codec, rate
Multi-language audio3.32.0Each language its own stream with an ISO 639 label (HLS 3.33.0)
Stable channel audio set3.40.0Language slots fixed per run; empty slots carry the main track
Audio routed by language3.52.0Each slot takes its language's track, not the track in that position
Every language for an early receiver3.53.0A receiver connected during the start-up slate gets the new streams without reconnecting
HLS audio renditions3.54.0Each extra language an audio-only rendition in the master, so players offer an audio menu; its own segments through dead air too (3.55.0)
Closed captions2.44.0A/53 captions detected (CC badge) and carried through remux and local encodes
Captions declared in HLS3.54.0CLOSED-CAPTIONS (CEA-608 CC1) in the master when the rundown carries A/53 captions
Constant-bitrate transport2.3.0cbr_bps pads the SRT stream to a fixed rate
CBR conformance test2.93.0Five-minute measurement with a verdict per criterion
DVB SDT / TDT / TOT / NIT2.5.0Service name and provider, time tables, network table (2.6.0)
DVB programme identity3.52.0PAT/PMT carry the service's numbers; tables repeat while the programme airs; a new channel's service is numbered after it (3.53.0)

Graphics & branding

FeatureSinceWhat it does
Graphics mode2.45.0Normal or composited per channel; changeable only while stopped
L-shape compositing2.50.0A node frames the clean output with headline, ticker, colours and logo
HTML / URL graphics2.50.0Headless Chromium snapshots of your page, every snapshot_sec (default 300)
Fallback and recovery2.50.0FALLBACK to clean output; return at an item boundary; at start, on the first healthy check (3.53.0)
Live headline/ticker updates2.63.0Text edits reach air without rebuilding the session
Composite node pinning and placement2.59.0Pin a node, or the least-loaded Chromium-capable one
Graphics origin isolation3.47.0Sandboxed opaque origin; optional graphics_port
QR code overlay2.8.0Burned in at encode time, sized per rung; address validated on save (3.52.0), corner too (3.53.0)
Traffic bug layer3.49.0Rating, daypart logo and E/I cues on the generated graphics page
Traffic graphics on the built-in frame3.53.0The node is sent the cues on air as overlays, in the session and on each change
Uploaded logo in the L-shape3.53.0Used when logo_url is empty; served to the node at /graphics/{id}/logo
Demo watermark2.47.0Demo uploads capped at 720p with a watermark pill
Channel logos1.31.0PNG/JPEG per channel for apps

Encoding

FeatureSinceWhat it does
Encoding modes1.32.0Original files, single bitrate or an ABR ladder per channel
Remote GPU encode nodes1.32.0Least-loaded node with a free slot; content-addressed sources
Node management1.33.0Add, test, edit, disable, remove; tokens never returned
gpuff support and node roles3.0.0Capabilities over versions; encode / composite / both roles
This server's NVIDIA cards3.51.0Cards join the pool after an NVENC test; parked on faults
GPU decode and scale3.54.0Opt-in NVDEC and scale_cuda on the encoding card; the processor when the card cannot decode
Encode fallback policy1.36.0fail / local_gpu / local_cpu when no node is free
Encode All and Re-encode1.39.0Each distinct rundown file once; a waiting file is not queued twice (3.52.0)
Adaptive HLS variants1.64.0A master playlist over frame-aligned rungs; SRT and push carry the tallest (3.52.0); each rung measured (3.55.0)
ABR items in full3.54.0Breaks, Break now, every language on every rung, live takes, as-run rows; rungs keep airing between ABR items
Constant-bitrate encodes2.97.0cbr=1 single rendition for fixed-rate pipes
Encode queue1.93.0Survives restarts, fair across tenants, urgent work first, ETAs
Encode again3.9.0Requeues a failed or cancelled job with the same settings
Completion notices and alerts1.97.0Kept 12 h; alerts only when a person is needed

Delivery & feeds

FeatureSinceWhat it does
CDN base URL1.36.0Rewrites every viewer-facing URL
Per-account and per-channel CDN1.42.0Channel β†’ account β†’ global β†’ origin
Connect YouTube3.31.0OAuth once; creates the broadcast and sets the ingest
HLS proxy1.1.0CORS proxy with an SSRF guard and a read-only scoped token
App API and app keys1.31.0Read-only JSON lineup, now/next, thumbnails; hashed keys
XMLTV EPG1.0.0Per-channel (public) and per-account feeds; loop projection (2.46.0)
M3U playlist1.0.0Your channels with HLS or SRT URLs
Feed tokens3.52.0Non-expiring EPG/M3U credential, revoked by a password change
Read-only scoped tokens3.52.0URL tokens can only read; a leaked preview URL cannot delete
Stream viewer1.0.0/viewer browser monitor

Media & storage

FeatureSinceWhat it does
Upload and probe1.0.0MP4/M4V; async probe; metadata stored
Conform on ingest3.60.0MOV, MXF, TS, MKV accepted; HEVC, ProRes, MPEG-2, 10-bit or 4:2:2 video and AC-3, E-AC-3, MP3 or PCM audio converted to H.264/AAC MP4, every language and the captions kept
Loudness3.60.0Every file measured (BS.1770: integrated LUFS, true peak, range); with a target (βˆ’24 A/85, βˆ’23 R128) normalised at ingest; CSV report
Upload safeguards1.85.0Free-space reserve; plain-language rejection reasons
Preview from the Rundown2.58.0πŸ‘ plays a file or a rendition in the dock
Re-import and re-probe1.5.0Adopt files already on disk; re-probe broken ones
Watch folders and drop boxes1.38.0Polled folders import settled MP4s; label and append (3.19.0)
Collections3.19.0Labels, filter chips, bulk labelling, rename/merge
Tiered storage2.49.0Primary β†’ mirror β†’ S3, failover and failback, pre-warming
Disk quotas2.48.0Per-user quota on originals; 507 refusal
Doubled-name repair and media purge2.54.0Dry-run repair; delete a channel's copies then originals

Accounts & security

FeatureSinceWhat it does
Multi-tenant accounts and roles1.0.0Each user sees only their own channels, media, EPG and stats
User management1.7.0Create, edit, suspend, delete (files too)
Invite codes and registration control1.8.0Per-code limits, uses and expiry
Demo accounts2.47.0Watermarked, 720p, lifted by an admin
Suspension2.26.0Stops channels, blocks starts and uploads, deletes nothing
Session revocation1.68.0A deleted account, password or role change ends its tokens
Login and recovery limits1.2.0Per-IP and per-account windows; recovery off by default
Two-factor sign-in3.60.0Authenticator-app codes (TOTP) per account, recovery codes, replay refused, sessions signed out on change, admin reset
Per-install secrets1.78.0JWT secret and admin password generated on first run
Secrets at rest3.48.0AES-256-GCM keyring for passphrases, keys, node and YouTube tokens
Keyring off the host3.54.0From a command (KMS, vault), a descriptor, the environment or a systemd credential; fails closed
Content Security Policy2.0.1script-src 'self' on every page (3.51.0)
Audit trail2.31.0Who did what, from where; every action listed and checked against the code (3.54.0)
Every change audited3.55.0Channel edits (field names only), nodes, keys, invites, delivery settings, accounts, encodes, snapshots; a code check fails the build on an unaudited write

Operations

FeatureSinceWhat it does
As-run log1.10.0Tab, filters, CSV, API; per-item bytes (3.52.0), exact to the item (3.55.0)
Live stats and health chips1.0.0Stats tab, SSE, SRT and L-shape chips that follow their state (3.52.0)
System utilisation and capacity1.8.0CPU, memory, disk I/O, egress, headroom estimate
Auto-resume1.9.0Channels resume at their scheduled position after a restart
Prometheus metrics2.19.0Off until metrics_allow_from is set
Health check and watchdog3.38.0/healthz; systemd notify heartbeat (2.62.0)
Snapshots and restore2.29.0Every 6 h, newest 8, verified; restore with a pre-restore snapshot
Flight recorder2.59.0Ring of 30 s vitals; unclean shutdowns reported
Built-in HTTPS1.16.0Let's Encrypt on 443 with redirect from 80
Deployment guide3.39.0/guide/deploy: users, layout, unit, ports, backups; rewritten around the bundle (3.57.0)
Deploy bundle and installer3.57.0Binary, unit, install.sh and notes in one archive; a fresh install, a checked upgrade that rolls itself back, --rollback
Hardened systemd unit3.57.0Notify and watchdog, a dedicated user, the system read-only but the state directory, no new privileges, the configuration checked before every start
playout -check and -version3.57.0The configuration checked for production without changing anything; exits 1 on an error
Process metrics3.57.0Goroutines, heap, resident memory, open files, threads and uptime in /metrics; transport and HLS bytes apart
Prometheus alert rules3.58.0playoutgo-alerts.yml in the bundle: thirteen alerts, every metric they name checked against /metrics, with promtool rule tests
install.sh --tls3.58.0A fresh install with built-in HTTPS: the TLS settings, the capability drop-in, and /healthz on 127.0.0.1 for the installer's checks
ACME CA of your own3.59.0tls.acme_directory and tls.acme_ca_file: certificates from an internal step-ca or another ACME CA instead of Let's Encrypt; install.sh --tls-acme-directory and --tls-acme-ca
Crash-safe as-run3.58.0The airing row is stamped every 5 s; after a kill, a crash or a power cut it ends at its last stamp, not its start
Fuzzed parsers3.56.0Every input from outside β€” RTMP, AMF0, MP4, H.264, AAC, traffic logs, imports, settings, tokens, the keyring, the transport-stream paths β€” fuzzed; failing inputs kept and re-run by the suite
Combination tests3.56.0Features aired together to a real SRT receiver and checked as a stream monitor would: tables, continuity, clock, rate, picture and sound

Interface

FeatureSinceWhat it does
Web console and in-app manual1.0.0Embedded console; the Help tab embeds this manual
Tutorials and reference pages1.7.0Five tutorials, recipes, comparison, FAQ, glossary
Guided tour2.74.0A spotlight walkthrough offered once
Accessibility2.17.0Labels, real tabs, focus trap, AA contrast (3.47.0)
Console dialogs3.48.0Labelled dialogs replace every native prompt (3.51.0)
Floating preview dock2.53.0On-air preview over the Rundown while scrolling

Roadmap β€” What Is Left 3.60.0

The features not built are listed below, checked against the code in 3.60.0; 3.60.0 built three of them (conform on ingest, loudness, two-factor sign-in), and 3.63.0 two more (UDP/RTP output, alert webhooks). 3.55.0 closed the last documented limits. 3.56.0 was a hardening round: every parser fuzzed and features aired together, and what that found fixed (changelog, fuzzing and combinations). 3.57.0 is the production release: a deploy bundle with an installer that upgrades and rolls back, a hardened unit, a configuration check, process metrics, and an hour-long soak (deployment). 3.58.0 made the as-run log survive a crash, shipped alert rules, set up built-in HTTPS from the installer β€” and made upgrades on such hosts work at all β€” and added clock-change and weekday options to traffic for 3ABN to choose between. 3.59.0 ran the installer on a real systemd and built-in TLS against a real ACME server, and fixed what that found. What is left needs something only an operator can supply β€” hardware, an account, a node release, a partner's answer.

Needs something from the operator

Features not built 3.60.0

What commercial playout commonly has and PlayoutGo does not, checked against the code. None is scheduled; each is a round of its own.

Known limits, documented, not scheduled

None open. The four listed in 3.54.0 were closed in 3.55.0. One of them was stated wrongly: gap filler and the slate always reached every audio rendition (they air as samples, like an item); only dead air β€” a gap with neither filler nor a slate possible β€” named the variant's segment.

Architecture

One Go module (module playout, Go 1.23+), every non-test file in package main at the repository root β€” there is no src/ directory. Grouped by what the files do:

./
β”œβ”€β”€ main.go cli.go reqlimits.go health.go sdnotify.go supervise.go tlsserver.go acme359.go opsport.go exec_stderr.go # server, the command line (-check, -version, -healthz-url), routes, deadlines, /healthz, systemd watchdog, HTTPS, the ACME CA and its checks, /healthz and /metrics on loopback under TLS
β”œβ”€β”€ config.go config_check.go database.go dbperms.go models.go channeldto.go sqltime.go tsconn.go tsmigrate.go secretstore.go keysource.go backup.go restore.go mfa360.go # state: config, SQLite, secrets at rest and where the key comes from, snapshots, two-factor sign-in
β”œβ”€β”€ auth.go feedtoken.go livekey.go quota.go audit.go audit355.go webhook363.go # sessions, feed and stream keys, quotas, audit trail and what an edit changed
β”œβ”€β”€ handlers.go manual.go purge.go probe_progress.go conform360.go schedule_import.go schedulefill.go filler.go # REST API; conform on ingest and loudness
β”œβ”€β”€ playout.go deadair356.go itembreaks.go mp4reader.go tsmuxer.go pmtdeclare.go dvb.go cbr.go cbrtest.go audioroute.go captions.go slate.go abr.go hlswriter.go hls_master.go sps.go srtcaller.go srt_refusal_log.go rtmpin.go amf0.go rtmpout.go sdi362.go udp363.go udp363_sockopt_linux.go udp363_sockopt_other.go # the engine and its outputs
β”œβ”€β”€ epg.go app_api.go hlsproxy.go cdn_fallback.go metrics.go procmetrics.go flight.go sysinfo.go # feeds, app API, proxy, delivery settings, monitoring, the process's own figures
β”œβ”€β”€ encode.go encode_queue.go eta.go autonomy.go localgpu.go renditions.go watermark.go qr.go qr_overlay.go # encoding (nodes, queue, local GPUs, QR, demo watermark)
β”œβ”€β”€ storage.go s3.go prewarm.go watchfolders.go # tiered storage, watch folders
β”œβ”€β”€ graphics.go graphicsorigin.go graphicsssrf.go composite.go composite_bugs.go traffic_cues.go # L-shape graphics, traffic overlays and logo, and their isolated origin
β”œβ”€β”€ rpx.go traffic_db.go traffic_handlers.go traffic_integrity.go traffic_ops.go traffic_sched.go traffic361.go traffic362.go requirements3abn.go # traffic: RPX logs, library, templates, scheduler, 3ABN’s requirements traced to their tests
β”œβ”€β”€ youtube.go # YouTube account connection
β”œβ”€β”€ docs.go about.go pushguide.go deployguide.go # this manual, /about, /guide/push, /guide/deploy
β”œβ”€β”€ gpupool/ # node selection library (composite placement)
β”œβ”€β”€ static/ # embedded console: index.html, app.js, boot.js, ui-dispatch.js, traffic.js, viewer.js, epg-index.js, youtube-done.js, bundled video.js
β”œβ”€β”€ audit/ # browser and API proof scripts (not compiled)
β”œβ”€β”€ deploy/ # playoutgo.service, install.sh, INSTALL.md β€” the deploy bundle
└── release/ # ci.sh gates, make-release.sh (source archive and deploy bundle)

Data flow

When a channel starts: the PlayoutEngine reads MP4 samples via mp4reader.go (the encoded rendition when the channel has one, otherwise the original), paces them to wall-clock time, and feeds the HLS segmenter (browser playback), the SRT output (downstream ingest, muxed to MPEG-TS on the way out, with service tables and splice information added there) and the RTMP push, when one is set. Audio is routed per language (audio languages). A gap is covered by gap filler, then by a generated black-and-silence slate. SRT reconnection happens in a background goroutine so a dropped SRT client never interrupts HLS output. A live RTMP feed, once taken, replaces the file samples on every output until it is returned or stops.

Quick Start

Build and run

# from the repository root (Go 1.23+)
CGO_ENABLED=0 go build -o playout .
./playout

Opens on http://localhost:8700. The administrator is admin@playout.local with the random admin_password written to config.json on first run β€” sign in with it and change it. Install ffmpeg too if you want the gap slate, thumbnails, local encodes and the CBR test.

Log in and upload media

Use the Web UI at /. Upload MP4 files (H.264 video + AAC audio). The system probes each file on upload β€” duration, resolution, codec, and bitrate are recorded automatically.

Create a channel

Click New Channel. Set the name, choose output mode (hls, srt, or both), configure SRT parameters if needed, then save.

Build a playlist and start

Add media files to the channel playlist in the desired order. Click Start Channel. The HLS stream is immediately available at /hls/{channel-name}-{id}/index.m3u8.

Monitor

Use the Viewer at /viewer to watch any channel in the browser. The dashboard shows live bytes/packets, RTT (SRT), current file, and error history.

UI Tour & In-App Help v1.5

The entire documentation you are reading lives inside the application: open the πŸ“š Help tab in the top navigation β€” it opens with a one-click launcher bar for the tutorials, recipes, comparison, FAQ, glossary and testing guide. Every page also has a small ? button next to its title that jumps straight to the relevant manual section β€” Channels β†’ channel management, Rundown β†’ the scheduling manual, Media β†’ upload & probing, and so on. The β†— button in the header opens this manual in its own browser tab if you prefer a second window while operating.

A new account with no channels is offered a guided tour once (🧭) β€” a spotlight walkthrough of the pages; the 🧭 Guide tab starts it again at any time.

Five-minute tour for a new operator

  1. πŸ“Ί Channels β€” create a channel, choose output (HLS, SRT, or both), press β–Ά to start, ⏹ to stop. The card shows live status, the copy-ready player URLs, and shortcuts to the rundown and stats.
  2. 🎞 Media β€” drag-drop MP4s (H.264 + AAC). Files probe automatically: watch processing β†’ ready and the real resolution/fps/duration appear. A file marked error is not playable β€” re-encode it.
  3. πŸ“‹ Rundown β€” the heart of the system: order content, pin air times with πŸ”’ anchors, watch gaps/overruns, and read the NOW line. Full manual in the Rundown section.
  4. β–Ά HLS Player β€” quick in-browser preview of any HLS URL (served through the built-in CORS proxy).
  5. πŸ“‘ EPG β€” copy XMLTV and M3U links for Plex/Jellyfin/IPTV apps. The links carry a feed token, not your login token: it opens only /epg/ and /m3u, never expires, and stops working when you change your password.
  6. πŸ“Š Stats β€” live throughput, viewer counts, play history, and the channel error log.
  7. πŸ›‘ Admin (admins only) β€” user and system management; see the Admin Guide.

Configuration (config.json)

Config is loaded from config.json in the working directory (under systemd, WorkingDirectory=). If missing, it is created with defaults β€” including a random admin_password and jwt_secret β€” at mode 0600, because it holds secrets. A misspelled key is named in the log at startup, and a malformed file stops startup with the line and column. config.sample.json lists every key with its default. Relative paths resolve against the working directory (under systemd, WorkingDirectory=), so use absolute paths in production.

{
  "port": 8700,
  "admin_email": "admin@playout.local",
  "admin_password": "(random, generated on first run)",
  "jwt_secret": "(random, generated on first run)",
  "database_path": "playout.db",
  "uploads_dir": "uploads",
  "hls_dir": "/dev/shm/playout_hls",
  "advertised_base_url": "http://10.0.0.5:8700"
}
KeyDefaultDescription
port8700HTTP listen port for the console, API, HLS and feeds.
rtmp_port0 (= 1935)Where contribution encoders publish live inserts (Live insertion). -1 turns the publishing point off; the channel dialog then says so instead of showing a URL.
admin_emailadmin@playout.localThe administrator's login, used on first run only.
admin_passwordrandomGenerated (24 characters) and written here on first run; used only when no administrator exists, and what /api/auth/reset restores. Change the password in the interface β€” editing this later has no effect.
jwt_secretrandomGenerated (64 characters) on first run. Signs session tokens, feed tokens and scoped media tokens; changing it signs everyone out and voids every feed URL.
database_pathplayout.dbSQLite database file.
secrets_key_filesecrets.key beside the databaseKeyring that encrypts SRT passphrases, live-insert stream keys, encoding-node tokens and YouTube refresh tokens (AES-256-GCM, created 0600). Back it up separately from database snapshots — see Secrets at rest.
loudness_target_lufs0 (off)Normalise uploads to this integrated loudness: βˆ’24 for ATSC A/85 (the CALM Act reference), βˆ’23 for EBU R128. Files are measured either way (loudness, 3.60.0).
loudness_tolerance_lu0 (= 2)How far from the target a file may be before it is normalised (A/85: Β±2 dB).
loudness_true_peak_dbtp0 (= βˆ’2)The true-peak ceiling; a file over it is normalised.
conform_x264_presetveryfastThe x264 preset conform uses when an upload’s video must be re-encoded to H.264 (conform, 3.60.0).
secrets_key_commandnoneTake the keyring from a program β€” a key-management service, a secrets manager β€” instead of a file; config.json only (where the key comes from, 3.54.0).
uploads_diruploadsOriginals, in user_<id>/ folders.
hls_dir/dev/shm/playout_hlsHLS segments and the generated slate. Keep it on tmpfs.
encoded_direncodedRenditions collected from encodes.
backup_dir(beside the database)Where automatic database snapshots are kept (every 6 hours, newest 8).
mirror_dir""Second copy of every original on another disk, with automatic failover and failback (Tiered storage).
s3{}Tertiary tier: endpoint, region (empty signs as us-east-1), bucket, prefix, access_key, secret_key. Active only when endpoint, bucket and both keys are set.
failover_cache_dirmedia-cache beside the databaseWhere files restored from S3 land.
require_invite_codefalseRegistration needs an invite code (Invite codes).
max_upload_mb10240Largest single upload, in MB.
allowed_origins[]Browser origins allowed to call the API cross-origin, matched exactly. Empty means none β€” right for a server that serves only its own console.
metrics_allow_from[]Addresses or CIDR ranges allowed to scrape /metrics. Empty means the endpoint answers 404. The address checked is the direct peer, so behind a reverse proxy list the proxy.
watch_folders[]Folders polled for new media. Each entry: path, user_id, move, and optionally channel_id, collection, append_to_rundown (Watch folders). Adding them in Admin is usually better.
watch_interval_seconds0 (= 30)How often watch folders are polled; under 10 also means 30.
gpu_nodes[]Encode and composite nodes. Each entry: url, token, optional name.
encode_fallbackfailWhen no node can take an encode: fail, local_gpu or local_cpu.
local_gpusautoThis server's NVIDIA cards as encode capacity: auto, off, or card numbers such as "0,2" (Encoding on this server's GPUs).
local_gpu_slots0 (= 2)Encodes each local card runs at once, 1–8.
local_gpu_decodefalseLocal NVENC encodes also decode (NVDEC) and scale (scale_cuda) on the card (GPU decoding, 3.54.0).
cdn_url""Base players use for HLS instead of this origin (CDN delivery).
hls_proxy_allowed_hosts[]Optional allowlist for the HLS proxy. Empty means any public host; private, loopback and link-local addresses are always refused.
graphics_allowed_hosts[]Optional allowlist for graphics page and logo URLs a compositing node is told to fetch. Empty falls back to hls_proxy_allowed_hosts.
advertised_base_url"" (= http://127.0.0.1:port)How other machines reach this server: compositing nodes fetch the clean HLS and graphics from it, the publish URL for live insertion is built from its host, and YouTube's redirect uses it.
graphics_port0A spare port to serve /graphics/ on its own origin; the control origin then answers 404 for it (L-shape graphics).
graphics_base_url""Where nodes fetch graphics when that is not advertised_base_url with graphics_port substituted.
trust_proxy_headersfalseBelieve X-Forwarded-Proto from a reverse proxy that terminates TLS. It gates HSTS; leave it off unless a proxy really does.
tls{enabled:false}Built-in HTTPS: enabled, domains (required when enabled), email, cache_dir (certs), port (443), http_port (80), redirect_http (true), staging (false), acme_directory and acme_ca_file (an ACME CA of your own, 3.59.0). See HTTPS.
google_client_id""OAuth client for connecting YouTube accounts (Connecting YouTube).
google_client_secret""Its secret.
google_endpoint_base""Testing only: a stand-in for Google's endpoints. Leave empty.
allow_password_resetfalseTurns on POST /api/auth/reset. Off, the endpoint answers 404.
recovery_secret""The secret that endpoint requires: at least 24 characters and different from admin_password.
First-run behaviour: the administrator is created from config on first run only. Later restarts do not overwrite the password, so a change made in the interface persists. To recover a lost password: set allow_password_reset: true and a recovery_secret of at least 24 characters, restart, and POST /api/auth/reset with {"secret":"…"}. That restores admin_email / admin_password from config. Turn it off again afterwards.

Tutorial 1 Β· Your First Channel 10 min

Goal: take a fresh install to a channel playing in a browser. No prior playout experience assumed.

  1. Log in with the admin credentials from config.json (default admin@playout.local). Change the password from the πŸ”‘ button in the header.
  2. Upload media. Go to 🎞 Media and drag in two or three MP4s. They must be H.264 video + AAC audio (video-only is fine β€” PlayoutGo adds a silent track automatically). Watch the status go processing β†’ ready; the real resolution, fps and duration appear when probing finishes.
    If a file lands on error, it isn't H.264/AAC or the container is damaged. Re-encode:
    ffmpeg -i input.mkv -c:v libx264 -preset veryfast -g 48 -c:a aac -ac 2 -ar 48000 output.mp4
  3. Create a channel. πŸ“Ί Channels β†’ + New Channel. Name it demo, set Output mode to HLS (simplest β€” plays in any browser), leave the rest at defaults. Save.
  4. Fill the rundown. Open πŸ“‹ Rundown, pick demo in the channel selector, and click files in the right-hand Add Files panel to append them.
  5. Start it. Back on Channels, press β–Ά Start. Within ~10 seconds the card shows ● LIVE.
  6. Watch it. Click the β–Ά button on the card (opens the built-in player), or copy the HLS URL into VLC or Safari.

What just happened: the engine opened your first file, remuxed it to MPEG-TS in real time, and wrote 4-second keyframe-aligned segments to /dev/shm/playout_hls/demo-1/, updating index.m3u8 as it went. When the file ended it moved to the next item with a discontinuity marker and continuous timestamps β€” no re-encoding anywhere, which is why one modest server can run many channels.

Tutorial 2 Β· Build a Broadcast Day 15 min

Goal: understand anchors, flow, gaps and overruns β€” the heart of the Rundown.

Step 1 β€” flow everything from a start time

Add six items. In Schedule day from pick today at 18:00 and press ⏩ Set & flow. Item 1 becomes πŸ”’ anchored at 18:00; everything else shows a grey computed time that simply follows the previous item's end. Drag any row β€” every grey time recalculates instantly, the anchor never moves.

Step 2 β€” pin a hard start

Say item 4 is a news bulletin that must hit 19:00 exactly. Click its πŸ”“ to anchor it, then edit the yellow time to 19:00. Now one of two things appears:

This is the entire job of a traffic scheduler: make the gaps and overruns visible before air, not while viewers are watching.

Step 3 β€” repeat a block

Tick the checkboxes for items 1–3 (shift-click selects a range), then press ⧉ Duplicate in the blue bulk bar. Floating copies land at the end and immediately flow after the last item.

Step 4 β€” safety net

Press ↩ Undo. Every schedule and order change is snapshotted (10 levels). Try to drag or delete the row marked β–Ά playing β€” you'll be asked to confirm, because that item is on air right now.

Step 5 β€” survive a restart

Stop and start the channel mid-programme. It resumes inside the correct item at the correct offset: the engine walks anchors, chains floating durations after them, and compares against the wall clock. A 25-minute-old anchor followed by two 10-minute items resumes 5 minutes into the third.

Tutorial 3 Β· A 24/7 FAST Channel 20 min

Goal: a channel that runs unattended forever, in the shape a FAST/OTT operator actually needs.

  1. Create the channel with Output mode = Both (HLS for viewers, SRT for a downstream CDN or transcoder) and Loop playlist ticked.
  2. Load a deep rundown β€” several hours of content. With looping on, when the last item finishes the whole playlist resets to pending and starts again.
  3. Assign a gap filler (channel settings β†’ Gap filler content): a 10–30 s branded slate. Anything that would have been dead air becomes on-brand.
  4. Decide on anchors. For pure looping, use no anchors β€” content just cycles. For appointment programming, anchor only the few must-hit slots and let the rest flow.
    Anchors and looping interact: an anchor is a one-shot wall-clock event. When a looping playlist resets, anchors already in the past fire immediately in flow order rather than waiting for tomorrow. For a repeating daily grid, re-anchor per day (or leave the day unanchored and let it cycle).
  5. Publish. From πŸ“‘ EPG copy the M3U link into your IPTV app (it carries your feed token, which does not expire), and the XMLTV URL into Plex/Jellyfin/Emby for a programme guide.
  6. Monitor. πŸ“Š Stats shows live throughput and the error log. Install from the deploy bundle (sudo ./install.sh, see Deployment) so the server runs under systemd, restarts on reboot and is restarted if it ever wedges.

Tutorial 4 Β· Multi-Tenant Setup 10 min

Goal: give several people (clients, colleagues, departments) their own isolated channels on one server.

  1. As admin, open πŸ›‘ Admin and create a user with an email, a password of at least 8 characters, and role user.
  2. They log in and see only their own channels, media, rundowns, EPG feeds and stats. Admins see everything.
  3. Isolation is enforced server-side, not merely hidden in the UI: a user cannot read another tenant's files, add another tenant's media to their playlist, or point their gap filler at it β€” all return 403/400. (Both playlist and filler paths were hardened in v1.7.0; see the changelog.)
  4. Give each tenant a distinct SRT port, or share a port and give each channel a distinct stream ID β€” the listener routes by exact stream-ID match.
  5. Each tenant's /m3u?token=FEED_TOKEN and /epg/my?token=FEED_TOKEN (from their EPG tab, or GET /api/epg/feed-token) return only their channels, so the links are safe to hand out individually. A password change revokes them.

Tutorial 5 Β· Diagnose a Dead Stream 10 min

Goal: a repeatable path from "nothing is playing" to a root cause. Work top to bottom β€” each step rules out a layer.

Layer 1 β€” is the engine running?

β€Ί Channels tab: does the card show ● LIVE?
β€Ί Server log: look for "Playing: filename"
βœ— Status "error"      β†’ open Stats and read the channel error log
βœ— Nothing playing     β†’ the rundown may be empty, or every item failed to parse

Layer 2 β€” is content being produced?

β€Ί ls -la /dev/shm/playout_hls/<slug>-<id>/
βœ“ .ts files with timestamps advancing every few seconds = engine is fine
βœ— No files / stale files = the engine is stuck; check the log

Layer 3 β€” is it valid?

β€Ί curl -s http://SERVER:8700/hls/<slug>-<id>/index.m3u8
βœ“ #EXTM3U with #EXTINF lines and segment names
β€Ί Download one segment and probe it:
  curl -s .../seg00000042.ts -o /tmp/s.ts
  ffprobe -v error -show_entries stream=codec_name,channels,sample_rate -of csv=p=0 /tmp/s.ts
βœ“ h264 + aac,48000,2
βœ— "aac, 0 channels"  β†’ source has no audio and silent-track synthesis failed (pre-v1.4 bug)
  ffmpeg -v error -i /tmp/s.ts -f null -
βœ“ silent output = decodes cleanly
βœ— "non-existing PPS" = segment starts mid-GOP (pre-v1.6 bug)

Layer 4 β€” the player

β€Ί Works in VLC but not Safari?  Safari is far stricter: it needs every segment to start
  on a keyframe and a consistent audio layout. Test in a private window (Safari caches
  HLS aggressively) or with a cache-buster: index.m3u8?v=2
β€Ί Works in the browser but not VLC over SRT? SRT is single-consumer β€” disconnect the
  other client first, and use the exact URL form below.

Layer 5 β€” SRT specifics

β€Ί ffprobe -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=YOUR_ID&latency=200000"
βœ— ERROR:PEER          β†’ stream ID doesn't match. It must equal the channel's Stream ID
                         exactly. The URL parameter is streamid= (NOT srt_streamid=),
                         and latency is in MICROseconds (200 ms = 200000).
βœ— Connection refused  β†’ the channel isn't started, or output mode excludes SRT
βœ— Bad secret          β†’ passphrase mismatch (10–79 chars)
The server tells you the answer: when an SRT listener starts it logs a complete, paste-ready connect URL. Copy that rather than composing one by hand.

Recipes & Examples

Encode source files for reliable playout

# Standard 1080p conform β€” closed GOP every 2 s, AAC stereo 48 kHz
ffmpeg -i input.mov -c:v libx264 -preset medium -crf 20 -g 48 -keyint_min 48 \
       -sc_threshold 0 -pix_fmt yuv420p -c:a aac -b:a 128k -ac 2 -ar 48000 \
       -movflags +faststart output.mp4

-g 48 with -sc_threshold 0 gives predictable 2-second keyframes, which lets the segmenter cut cleanly at its 4-second target.

Make a branded slate for gap filler

# 15-second slate from a still image, silent stereo audio track
ffmpeg -loop 1 -i slate.png -f lavfi -i anullsrc=r=48000:cl=stereo -t 15 \
       -c:v libx264 -g 48 -pix_fmt yuv420p -c:a aac -ac 2 -shortest slate.mp4

Restream a channel to YouTube/Facebook

# Built in: paste the service's address and key into the channel's
# "Push to a streaming service" field β€” no relay is needed:
rtmp://a.rtmp.youtube.com/live2/YOUR-KEY

See Pushing to YouTube, or connect a YouTube account and let PlayoutGo create the broadcast.

Pull a channel into another server over SRT

ffmpeg -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=ch1&latency=200000" \
       -c copy -f mpegts "srt://DEST:9000?mode=caller&streamid=relay1"

Schedule a day from a script

TOKEN=$(curl -s -X POST http://SERVER:8700/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"..."}' | jq -r .token)

# anchor item 101 at 18:00 UTC, let the rest flow
curl -s -X POST http://SERVER:8700/api/playlist/1/schedule_bulk \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"items":[{"id":101,"scheduled_at":"2026-08-01T18:00:00Z"},
                {"id":102,"scheduled_at":""},
                {"id":103,"scheduled_at":""}]}'

Back up and restore

# Everything lives in the database plus the uploads tree
tar czf playout-backup-$(date +%F).tar.gz playout.db uploads/
# HLS segments are ephemeral β€” never need backing up

How PlayoutGo Compares

An honest positioning guide, so you can tell whether this is the right tool for a given job.

SystemShapeWhere PlayoutGo differs
ffmpeg + concat/cronDIY scriptsThe common starting point. PlayoutGo adds a real schedule model (anchors, gaps, overruns), a rundown UI, per-tenant isolation, EPG output and stats β€” and avoids the classic concat pitfalls (timestamp discontinuities, restart-from-zero, no mid-item resume).
OBS StudioLive production switcherOBS is for a human operating a live show, and it re-encodes. PlayoutGo is unattended file playout; the on-air path remuxes and any encoding happens ahead of time, so it scales to many channels per box β€” but it has no scenes, overlays or camera switching.
CasparCGBroadcast graphics/playoutCasparCG excels at SDI output and rich CG/overlay layers for on-air graphics. PlayoutGo is IP-only (HLS, SRT and RTMP push out; RTMP live feed in), far simpler to run, and includes its own scheduler; Caspar normally needs an external automation system driving it.
Flussonic / NimbleStreaming serversThose are delivery/transcode platforms with some playlist features. PlayoutGo is the scheduling source that feeds them: point its SRT output at your streaming server and let that handle ABR, DRM and CDN edge.
Commercial playout (WideOrbit, Amagi, Veset…)Full traffic + playout suitesThey bring traffic/billing integration, ad sales workflows, compliance logging, redundancy and support contracts. PlayoutGo covers the scheduling-and-playout core as a single self-hosted binary with no per-channel licence.
Cloud FAST platformsManaged SaaSManaged platforms handle scale and monetisation for you, at recurring per-channel cost, with your content in their cloud. PlayoutGo runs on your own hardware and keeps content and schedule in your control.

Choose PlayoutGo when

Choose something else when

Frequently Asked Questions

Does PlayoutGo re-encode my files?

Not on air: playout remuxes H.264/AAC into MPEG-TS in real time. Files are encoded only if you choose Single bitrate or ABR for a channel β€” ahead of time, on an encode node or this server's GPUs β€” and a demo account's uploads are always transcoded. CPU use per channel is small, which is why a modest server runs many channels β€” but it also means all files should already be H.264/AAC, and mixed resolutions/frame rates are passed through as-is with discontinuity markers rather than conformed.

How many channels can one server run?

Because playout does no encoding, the practical limits are disk I/O and network throughput rather than CPU. Measure on your own hardware with your own bitrates before committing to a number β€” the honest answer is that it depends on your content and storage far more than on PlayoutGo.

Why does my SRT client get rejected?

Almost always the stream ID. It must match the channel's Stream ID exactly; the URL parameter is streamid= (not srt_streamid=) and latency is in microseconds. Also remember SRT here is single-consumer: one client at a time per channel.

Why does VLC play my stream but Safari won't?

Safari enforces the HLS spec strictly: every segment must start on a keyframe and the audio layout must stay consistent. VLC tolerates violations of both. If Safari refuses a stream that VLC plays, probe an individual segment (Tutorial 5, Layer 3) β€” and remember to test in a private window, because Safari caches playlists aggressively.

What happens if the server restarts mid-programme?

On start the engine recomputes the on-air position from your anchors plus the durations of floating items, and seeks into the correct item at the correct offset. A channel with a schedule rejoins where it should be, not at the top of the playlist.

Can I use video files with no audio?

Yes. Video-only files automatically get a synthesized silent 48 kHz stereo AAC track, so a mixed playlist presents one stable audio layout β€” required for Safari and tvOS.

Where are the HLS segments stored, and do I need to back them up?

In hls_dir (default /dev/shm/playout_hls, a tmpfs). They're ephemeral and regenerate on start β€” back up only playout.db and uploads/.

Is it safe to expose this to the internet?

Serve it over HTTPS β€” the built-in Let's Encrypt support (HTTPS) or a TLS reverse proxy β€” sign in with the generated admin password and change it, and keep the JWT secret out of version control. API access is token-authenticated and per-user isolated; the HLS endpoints are intentionally public so players can reach them.

Can two channels share one SRT port?

Yes β€” they share a listener and are routed by stream ID, provided their passphrase, latency, buffer and max-bandwidth settings match. Mismatched settings on the same port are rejected with a clear error.

Glossary

AnchorA rundown item pinned to an exact wall-clock air time (πŸ”’). The engine waits β€” playing filler or padding β€” to hit it precisely.
Floating itemAn item with no stored time (πŸ”“); it airs immediately after the previous one and its displayed time recomputes whenever the rundown changes.
Gap / underrunUnfilled time before an anchor. Covered by gap filler if configured, otherwise the built-in black-and-silence slate. Null packets are used only if no slate can be generated (no ffmpeg).
OverrunContent running past an anchor's start time, so the anchored item begins late.
GOPGroup of Pictures β€” the span from one keyframe to the next. Segment boundaries and clean stream joins can only happen on keyframes.
Keyframe / IDRA frame decodable on its own, carrying SPS/PPS headers. A stream that starts anywhere else produces "non-existing PPS" errors.
DiscontinuityAn HLS tag telling the player that timing/encoding parameters change at that point β€” emitted at every file transition.
Null packetsPadding TS packets (PID 0x1FFF) that carry nothing: the padding of a constant-bitrate stream, and the last resort for a gap when no slate can be generated.
Stream IDThe SRT identifier a caller sends to select a channel. Must match exactly; empty or wrong IDs are rejected.
Caller / ListenerSRT roles. Listener = PlayoutGo waits for clients to connect (pull). Caller = PlayoutGo dials out to your ingest (push).
FASTFree Ad-supported Streaming TV β€” linear channels delivered over IP, the typical use case for this system.
As-runThe record of what actually aired and when (its own As-Run tab, with CSV export, and GET /api/asrun).

Channels

A Channel represents one live broadcast stream. Each channel has:

Channel statuses

StatusMeaning
stoppedChannel is idle, no output being generated
connectingChannel has been started; waiting for SRT connection or first frame
runningActively playing a media file and producing output
errorStopped due to an error (all playlist items failed, or unrecoverable error)

HLS URL format

Each channel gets a directory named {slugified-name}-{id}. For example, channel GB1 with ID 3 β†’ /hls/gb1-3/index.m3u8. Legacy ch{id} format is also recognised for backward compatibility.

Media Files

The engine airs MP4 with H.264 video and AAC audio. Since 3.60.0 uploads, watch folders and re-imports also take MOV, MXF, MPEG-TS (.ts, .m2ts, .mts), MKV and WebM, and anything that is not H.264/AAC in MP4 is conformed first.

Conform on ingest 3.60.0

Before a new file’s first probe, ffprobe reads its streams. A file that cannot air as it is shows conforming while ffmpeg converts it, one file at a time and at a lower priority than the channels on air:

The result is read back and checked before it replaces the original, atomically; the library card says what was done (βš™ Conformed: audio 1 AC-3 β†’ AAC), as does the file’s conform_note. A file is conformed once: probing it again does not convert it again. A file that is on air (or being encoded) is never replaced: it is marked with the reason, and βš™ Conform converts it once it is off air β€” the same button converts a file already in the library, for example after setting a loudness target. Subtitle and data streams are not carried, nor SCTE-35 in a transport-stream upload. Without ffprobe on the server files are judged by the probe alone, as before 3.60.0; without ffmpeg a file that needs converting is refused with the reason.

Loudness 3.60.0

Every file’s audio tracks are measured in the background (ITU-R BS.1770 as ffmpeg’s loudnorm measures it): integrated loudness in LUFS, true peak in dBTP and loudness range in LU. Files already in the library are measured after the upgrade, one at a time. The library card shows the first track’s figure (πŸ”‰ βˆ’23.8 LUFS; yellow when a target is set and a track is outside it), the tooltip every track; πŸ”‰ Measure measures again, and GET /api/media/loudness.csv is the whole library, one row per track.

With loudness_target_lufs set β€” βˆ’24 for ATSC A/85, the CALM Act’s reference, or βˆ’23 for EBU R128 β€” a new file whose track is further than loudness_tolerance_lu (2) from the target, or over loudness_true_peak_dbtp (βˆ’2), is normalised at ingest with loudnorm’s second pass: a linear gain where the ceiling allows, so the programme’s dynamics are kept. Files already in the library are not changed until βš™ Conform is pressed. Live inserts and the slate are not measured or normalised.

After upload, the server probes the file asynchronously and stores: duration, width, height, FPS, bitrate, video/audio codec, whether closed captions are present, and whether SCTE-35 markers exist.

Codec check: If a file has 0 readable samples (wrong codec), it is marked as status: error and the playlist engine marks it failed after one attempt. A message is logged and stored in the channel error history.

The Rundown β€” Playlist & Scheduling v1.4

As of v1.4.0 the separate Playlist and Schedule tabs are merged into a single Rundown β€” one ordered list per channel showing what plays and when, side by side with the media library for quick adds. This section is the operator manual for it.

Core concept: anchors and floating items

Every rundown item is in one of two states, toggled with the padlock in the ⏱ column:

This mirrors a broadcast traffic log: a few hard starts ("news at 18:00"), with everything between flowing by duration. Items placed before the first anchor have no defined air time and simply play in loop order.

Gap and overrun detection

Orientation aids

Operator tutorial: building a broadcast day

  1. Open Rundown, select the channel.
  2. Add content from the right-hand panel (search, click to append).
  3. Drag rows to order the day. All times reflow as you drag.
  4. Set the day start: pick a time in Schedule day from and press ⏩ Set & flow. This anchors item 1 and lets everything else float.
  5. Pin the hard starts: click πŸ”“ on any item that must air at an exact time, then adjust its yellow time inline. Watch for GAP/over indicators and correct them.
  6. Start the channel. On restart or crash the engine recomputes the correct item and offset inside it from the anchors + flow, so the channel rejoins the schedule mid-item.

Multi-select, bulk edits, undo

On-air safety

Removing, moving, or re-scheduling the item that is currently playing prompts for confirmation. The playing row is highlighted green with a β–Ά marker. The change runs inside that question: if the server refuses it, the refusal is shown in the dialog, which stays open to retry or cancel, and undo records only a change the server made (3.52.0 β€” the change used to be sent after the dialog closed, so a refusal for the one item where it matters most was a toast). Removing a selection that includes the on-air item asks once, naming it.

More rundown tools

Day & Week grids show the whole schedule (v2.47): anchored items draw solid at their air time; items that merely follow the flow β€” including a running loop projected from what is on air now β€” draw dimmed and dashed at their computed time. Dragging any item onto an hour pins it there. A stopped loop with no anchors has no fixed point to project from, and the grid says so instead of showing an empty week.

Schedule engine semantics

The engine walks the rundown in position order. If the next pending item is anchored in the future, it covers the wait with gap filler, then the built-in slate β€” it never skips ahead. A background scheduler checks every 10 seconds and auto-starts stopped channels when an anchored item comes up within 15 seconds.

Flow-aware restart (v1.4): on start/restart the engine computes the on-air position from anchors and the durations of floating items chained after them, then seeks into the correct item at the correct offset. Example: an anchor 25 minutes ago followed by two floating 10-minute items resumes 5 minutes into the third item.

Gap Filler β€” Covering Dead Air v1.27

A linear channel must always be transmitting something. When your rundown does not reach the next anchored item, PlayoutGo covers the hole with content you choose instead of the built-in black slate.

What happens during a gap, precisely

Bin-packing: why a pool beats a single clip

Configure several clips and PlayoutGo fits the best combination into each gap rather than looping one slate. The difference is large:

GapSingle 2:00 slatePool of 2:00 + 1:00 + 0:20 + 0:10
3:202:00 played, 1:20 of black2:00 + 1:00 + 0:20 β€” nothing left
0:500:50 of black (clip doesn't fit)0:20 + 0:20 + 0:10 β€” nothing left

The algorithm is greedy longest-fit: it repeatedly takes the longest clip that still fits in the time remaining. It is fast, predictable, and easy to read back from the log β€” which matters more for an operator than squeezing out a final second.

It also avoids playing the same clip twice in a row whenever an alternative fits. If only one clip fits the remaining time, it repeats rather than leaving black β€” repetition beats dead air.

Verified in production conditions: a 29 second gap with a pool of 8 s, 4 s and 2 s clips was covered as 8+8+4+4+2+2 = 28 s with 1 s of slate, and the anchored item still started on its exact second.

Configuring it

  1. Upload your filler clips like any other media (short promos, idents, sponsor tags, a branded slate).
  2. Open the channel's settings and select them under Gap filler content β€” ⌘-click or Ctrl-click for several.
  3. Save. The change takes effect at the next gap; no channel restart needed.
Clips need a known duration to be fitted against a deadline. Anything still probing, failed, or missing a duration is shown greyed out and cannot be selected β€” run Admin β†’ Re-probe Broken Files to recover those first.

Choosing good filler

How this compares with professional playout systems

An honest positioning of what PlayoutGo does and does not do here:

TechniquePlayoutGo
Filler/interstitial pool β€” fit promos, idents and PSAs into the holeβœ… Yes, with bin-packing across a multi-clip pool
Slate fallback β€” guarantee something legal always goes outβœ… Yes β€” a black slate is built in; put a branded one in the pool. Null packets are used only without ffmpeg
Gap visibility before airβœ… Yes β€” gaps and overlaps are shown in the Rundown with sizes
Elastic items β€” mark a programme as loopable so it absorbs slack❌ Not yet
Under/over compensation β€” trim earlier item tails so the anchor lands on time❌ Not yet β€” an item always plays to completion, so an overlap makes the anchor start late
Operator alarms on large gaps⚠️ Partial β€” gaps appear in the Rundown and the channel error log, but nothing pushes a notification

Troubleshooting

Gap shows "dead air" in the Rundown
  β†’ no filler configured for that channel; select clips in channel settings

Filler configured but black still airs
  β†’ the gap is shorter than your shortest clip (check the log line, which
    reports exactly what was played and how much was padded)
  β†’ or every clip is unusable: the channel error log names them

Log line to look for:
  [channel] Filler: covering 29s with promo8.mp4 Γ—2 + ident4.mp4 Γ—2 + tag2.mp4 Γ—2 (1s null-padded)

Generating & Importing Schedules v1.19

Two ways to fill a rundown without clicking each file: generate one randomly from your library, or import a schedule produced by another system.

🎲 Generate β€” randomised rundown

In the Rundown toolbar press 🎲 Generate and give either a count ("20") or a duration ("3h", "90m"). PlayoutGo fills the rundown from the channel owner's media library.

The dialog exposes every option below: fill by count or by hours, replace or append, the no-repeat guarantee, and a seed. The same options are available through the API:

POST /api/playlist/{channelID}/generate
{
  "count": 20,              // or "duration_minutes": 180
  "replace": true,          // false appends to the existing rundown
  "no_repeat": true,        // never place the same file twice in a row
  "seed": 12345             // optional: reproducible ordering
}

⬆ Import β€” CSV or XML

Press ⬆ Import and choose a file. The format is detected from the content, and you are asked whether to replace the rundown or append to it. Entries whose media cannot be found are reported individually rather than failing the whole import.

Matching: a schedule entry is matched against your uploaded filenames β€” exact first, then case-insensitively, then ignoring the extension, then as a substring. So news.mp4, News.MP4 and news all find the same asset.

Air times are optional. An entry with a start time becomes an πŸ”’ anchored item; entries without one simply flow after the previous item (see the Rundown manual).

CSV

A header row is recommended. The media column may be named file, filename, media, asset, title, clip, name, material or content; the time column start, start_time, start_datetime, air_time, scheduled_at, time or on_air. Case, spaces, hyphens and underscores are ignored.

file,start
news.mp4,2026-08-01T18:00:00Z
promo.mp4,
weather.mp4,2026-08-01T18:30:00Z

Without a header, the first column is the media reference and an optional second column the start time. Blank lines and lines beginning with # are ignored.

Accepted time formats: RFC-3339 (2026-08-01T18:00:00Z), 2026-08-01 18:00:00, 2026-08-01 18:00, 2026-08-01, DD/MM/YYYY HH:MM:SS, and a bare 18:00 meaning today at that time.

XML β€” three dialects accepted

The importer detects the dialect from the document's contents rather than its namespace, so exports that omit or alter namespaces still import.

1 Β· PlayoutGo native β€” the simplest option if you are generating the file yourself:

<?xml version="1.0"?>
<playlist>
  <item file="news.mp4" start="2026-08-01T18:00:00Z"/>
  <item file="promo.mp4"/>
</playlist>

<entry>, <event> and <clip> are accepted as synonyms for <item>, and the media reference may be an attribute (file, filename, src, media, asset, name, title) or a child element of the same name.

2 Β· SMPTE ST 2021 (BXF) β€” the broadcast industry standard for schedule interchange, used by traffic and automation systems such as WideOrbit, Imagine, Pebble and Amagi. PlayoutGo reads a subset: each <ScheduledEvent> contributes one rundown item, using the first available of EventTitle, AssetName, MaterialName, Name, Title, MaterialId or EventId as the media reference, and StartDateTime, StartTime or SmpteDateTime as the air time.

<BxfMessage xmlns="http://smpte-ra.org/schemas/2021/2008/BXF">
  <BxfData><Schedule>
    <ScheduledEvent>
      <EventData>
        <EventTitle>news</EventTitle>
        <StartDateTime>2026-08-01T18:00:00Z</StartDateTime>
      </EventData>
    </ScheduledEvent>
  </Schedule></BxfData>
</BxfMessage>
Scope of BXF support, stated plainly: this is a pragmatic subset β€” event titles/IDs and start times β€” not a complete ST 2021 implementation. Secondary events, as-run reconciliation, content transfer, macros, segmentation and rights metadata are parsed over and ignored. It is intended for taking a schedule out of a traffic system and into PlayoutGo, not for round-tripping a full BXF workflow.

3 Β· XMLTV β€” the widely used open EPG format. Each <programme> becomes an item, using <title> as the media reference and the start attribute (20260801180000 +0000) as the air time. Handy because PlayoutGo already publishes XMLTV, so a guide can round-trip.

<tv>
  <programme start="20260801180000 +0000" channel="c1">
    <title>news</title>
  </programme>
</tv>

API

# multipart upload (as the interface does)
curl -X POST "http://SERVER:8700/api/playlist/3/import?replace=true" \
  -H "Authorization: Bearer $TOKEN" -F "file=@schedule.csv"

# or post the document directly
curl -X POST "http://SERVER:8700/api/playlist/3/import" \
  -H "Authorization: Bearer $TOKEN" --data-binary @schedule.xml

The response reports the detected format, how many entries were parsed, added, anchored and skipped, plus a warnings list naming each unmatched entry and its line number.

Restart & Resilience v1.9

A 24/7 channel must survive a reboot, a crash or an upgrade without an operator pressing anything. PlayoutGo separates two ideas to make that work:

Shutdown stops the engines but deliberately preserves the desired state, so the next start brings back every channel you left on air. Channels queued for resume show an ⟳ auto-resume badge in the Channels list.

Where does it resume from?

Resume is driven by the schedule rather than saved playback state, so it behaves correctly whether the process was stopped cleanly, killed, or the machine rebooted.

Verified: with a channel anchored 25 s in the past and a 20 s clip, kill -9 followed by a restart resumed into item 2 at +12.5 s β€” exactly the scheduled position for the wall-clock time at which the process returned.

Automatic at the OS level

Run under systemd with Restart=always (see the 24/7 tutorial). Combined with channel resume, a reboot restores the entire playout unattended.

Measuring Channel Density v1.28

The capacity panel projects from what is currently running and is capped at 4Γ— the measured sample, because per-channel cost does not scale linearly forever. For a number you can quote to a customer, measure it with benchmark.sh below.

"How many channels will this box run?" has no useful generic answer β€” it depends on your bitrates, resolutions and storage far more than on PlayoutGo. The capacity panel gives a projection; benchmark.sh (shipped with the source) gives a measurement.

./benchmark.sh --password 'your-admin-password' \
               --file representative-clip.mp4 \
               --max 40 --step 4 --settle 60

It starts channels in steps, lets each step settle, then records CPU, load per core, I/O wait, memory, disk utilisation and egress β€” and, critically, whether every channel is still producing healthy HLS. Results go to benchmark-<timestamp>.csv; the benchmark channels are deleted automatically when it finishes or is interrupted.

Why it stops on output health, not a resource threshold

A host can look comfortable on CPU while segments start arriving late. The run therefore stops when any channel's HLS goes unhealthy, and reports the previous step as your practical density. Resource verdicts (LOAD_SATURATED, IO_BOUND) are recorded alongside so you can see which resource gave out first.

Reading the results

Sanity-check the starting point first. If the capacity panel reports high I/O wait or load per core above 1.0 before you start, fix that first β€” benchmarking a saturated host measures the saturation.

As-Run Logs v1.10

The as-run log is the record of what actually aired, as opposed to what was scheduled. Broadcasters need it for advertiser reconciliation, licensing reports, affiliate compliance and post-incident review. PlayoutGo writes one record per item automatically β€” there is nothing to enable.

Reading the log

Open the 🧾 As-Run tab. Filter by channel and date range (quick buttons for 1, 7 and 30 days), and the header summarises total items, total airtime in hours, bytes delivered, and how many entries had errors.

Started / EndedWall-clock times playback actually began and finished.
DurationMeasured on-air time β€” not the file's nominal length. A shortfall against the file's nominal length means the channel was stopped or the item failed mid-play β€” not that playout truncated it.
SentBytes this item sent: SRT bytes written plus the HLS variant's bytes, counted as they are muxed β€” so a row holds exactly its own item's bytes, though a segment spans two items (3.55.0; until then HLS bytes were counted when a segment closed, and the segment holding an item's last seconds counted with the next item). Audio rendition segments are not counted: they repeat a language the variant already carries. (Before 3.52.0 this was the channel's running total since it started, written every five seconds β€” so a short item could read 0.)
Statuscompleted aired in full Β· playing on air now Β· stopped the channel stopped mid-item β€” an operator’s Stop, or the server shutting down β€” ended at that moment (3.57.0) Β· skipped an operator ended the item Β· error failed Β· interrupted the process died mid-item β€” killed, crashed, the power cut β€” and the next start closed the row at its last heartbeat: the airing row is stamped every 5 s, so it records the airtime to within 5 s (3.58.0; before, it ended where it started and recorded none).

Exporting

⬇ Export CSV downloads exactly the rows currently filtered, with columns channel, file, started_at, ended_at, duration_sec, bytes_sent, errors, status, last_error. Timestamps are UTC RFC-3339 so spreadsheets and billing systems parse them unambiguously. The download is fetched with your auth header rather than a URL token, so your credentials never reach server access logs.

API

GET /api/asrun?channel_id=&from=&to=&format=csv|json&limit=
  from / to   RFC3339 or YYYY-MM-DD (a bare date for 'to' means end of that day)
  default     the last 24 hours
  format=csv  returns a downloadable CSV instead of JSON

# last week for one channel, as CSV
curl -H "Authorization: Bearer $TOKEN" \
  "http://SERVER:8700/api/asrun?channel_id=3&from=2026-07-20&to=2026-07-27&format=csv" \
  -o asrun.csv
Scoping: administrators see every channel; other users only ever see their own, and an explicit request for another tenant's channel is refused with 403.

Output Modes

output_mode: "hls"
HLS only. Segments written to hls_dir. No SRT connection attempted. Best for internet streaming, browser preview, and Plex/Emby/Jellyfin.
output_mode: "srt"
SRT output to an ingest server + HLS for monitoring. SRT reconnects automatically if the downstream drops. Best for broadcast contribution.
output_mode: "both"
Simultaneous SRT contribution and HLS output. Both run independently β€” SRT failure does not affect HLS. SRT reconnects in background.

Live Insertion β€” Cutting to an RTMP Feed 3.2.0

Every channel has its own publishing point. A contribution encoder (OBS, vMix, a hardware encoder, ffmpeg) publishes RTMP to it; the operator sees the feed arrive in the channel dialog and cuts the channel to it with β–Ά Take live, then back with ⏎ Return to playout. Nothing is transcoded: the feed's own picture and bitrate go to air, on HLS, SRT and the RTMP push.

The publish URL and the stream key

What is accepted

Ready, take and return

The dialog polls GET /api/channels/{id}/live every two seconds while it is open, and stops when it closes. The feed is ready once it is connected, has sent its parameter sets and a keyframe, and has delivered a frame in the last 3 s; otherwise why says what is missing (audio is not required).

GET  /api/channels/{id}/live          β†’ {publish_url, key, key_predictable, key_locked, connected, remote,
                                         width, height, has_audio, mbps, frames, keyframes,
                                         last_frame_ago_s, on_air, ready, why, listener_off}
POST /api/channels/{id}/live/take     β†’ 409 "the live feed is not ready to take β€” …" | "the channel is not on air"
POST /api/channels/{id}/live/return
POST /api/channels/{id}/live/rekey    β†’ 409 while on air

ABR channels (3.54.0): a take during an ABR item cuts in on its next keyframe, and the feed reaches every variant playlist, so a player on any rung sees it. Until 3.54.0 the take waited for the ABR item to end and the feed reached HLS through the main playlist only.

Scheduled Ad Breaks & SCTE-35 1.54.0

An item can carry ad breaks β€” points in it where an avail begins, and for how long. When one is reached the channel signals it: a SCTE-35 splice_insert on the SRT transport stream for a downstream splicer, and cue tags in the HLS playlist for server-side ad insertion. Break now signals one immediately.

Setting breaks

On the SRT transport stream

In HLS

An avail inside an avail 3.53.0

Break now during a scheduled break (or a scheduled break due while Break now's avail is open) does not nest: the open avail returns first β€” its return goes out on that frame β€” and the new one starts, so every cue-out has a return of its own. In HLS both land on the same segment, #EXT-X-CUE-IN written before the new #EXT-X-CUE-OUT. Until 3.53.0 the new avail overwrote the open one's end: the first cue-out never got its return and a splicer saw one avail of the wrong length.

ABR items (3.54.0): an ABR item's scheduled breaks, and Break now during one, go out like a single-rendition item's β€” on SRT, and as cue tags in every rung's playlist on the same segment, so an SSAI system reads the same avail whichever rung a player is on. Break now during gap filler, the slate or a live feed still waits for the next item, and says so (queued).

Audio Languages 3.32.0

A file with several audio tracks β€” English, Spanish, French β€” airs all of them, each as its own stream labelled with its language. There is no channel setting for this: the channel's set of languages is worked out from its rundown when it starts.

The channel's languages

Routing each item into the slots 3.52.0

  1. Each slot takes the item's track tagged with its language.
  2. The main slot falls back to the item's first track.
  3. Untagged tracks fill the remaining slots in order.
  4. A slot still empty carries a copy of the main slot, so a declared stream never goes silent.

Before 3.52.0 tracks were routed by position, so a file whose tracks were in a different order from the first item's aired French on the Spanish stream. A track in a language the channel does not carry is not aired (the log says which). Live feeds and the slate have one track, which feeds every slot.

On the outputs

ABR rungs (3.54.0) carry every language too, each rung's tracks routed by language like an item's; a rung encoded with the first track only feeds every slot from it. Until 3.54.0 the rungs carried the main language only, and the media playlist carried EXT-X-MEDIA lines, which RFC 8216 allows only in a master playlist β€” players that follow it showed no audio menu.

A receiver that connected early (3.53.0): one that connected during a start-up slate got a stream built before the first item fixed the languages β€” one audio stream β€” and the other languages were dropped for it until it reconnected. The transport stream is now rebuilt when the language set no longer matches it, with a new program-map version, so the receiver re-reads the map and gets every stream without reconnecting.

Closed Captions 2.44.0

DVB Service Information 2.5.0

The clock, 3.56.0. While the programme airs its video carries the PCR; in a gap, dead air carries its own. The service emitter’s clock now goes out only while a new receiver waits for a keyframe, when nothing else carries one. Before, it also went out in gaps beside dead air’s clock, on the same PID with a different value, and it wrapped every 318 seconds (a 33-bit mask on the 27 MHz count) instead of every 26.5 hours. On a constant-rate channel its value is derived from the bytes sent since it was synchronised: to the item when it starts, and to the programme when a receiver connects, so a receiver joining mid-programme gets a clock that meets the programme’s at the first keyframe.

A channel feeding a DVB modulator, multiplexer or IRD can carry the tables a DVB receiver reads to name the service and set its clock. They go on the SRT output only (HLS has no use for them). Choose Constant bitrate with DVB service tables in the channel dialog's broadcast setting, or send a dvb object on channel create or update; "dvb": null turns them off, and leaving it out of an update keeps the current settings. Changing it on a running channel restarts the channel (3.52.0).

FieldDefaultRule
service_idchannel id + 1 (since 3.53.0 also for a channel created with service information; it used to get 1)1–65535
transport_stream_id10–65535
original_network_id10–65535
service_namethe channel namerequired; trimmed to 200 bytes
provider_namePlayoutGotrimmed to 40 bytes
service_type1 (digital TV)1, 2, 10, 17, 22 or 25
country, offset_minutesblank, 0three letters; Β±840 minutes
network_id, network_nameβ€”set both to emit a NIT

What goes out

TablePIDEvery
SDT (actual)0x11500 ms β€” one service, its name and provider
TDT + TOT0x1425 s β€” UTC time, and the local offset for the country
NIT (actual)0x108 s β€” only when a network name is set

QR Codes on the Picture 2.8.0

A channel can carry a QR code β€” a link to a donation page, an app, a programme website β€” burned into its picture at encode time. Set QR code on screen and its corner in the channel dialog, which shows a scannable preview, then press Re-encode this channel.

Graphics & Branding β€” What Goes on the Picture

WhatHow it gets thereWhere
L-shape frame (headline, ticker, colours, logo, or your own HTML page)A composite node frames the clean output live; viewers fall back to clean output if the node failsL-shape graphics
QR codeBurned in at encode timeQR codes
Rating, daypart logo and E/I bugs from a traffic logA page channel's graphics page draws them from /graphics/{id}/cues.json; a built-in-frame channel's node is sent them as overlays (3.53.0)Traffic
"Playout from TULIX.COM" watermarkBurned into every upload of a demo account, at most 720pDemo accounts
Channel logo (uploaded PNG/JPEG)Shown by apps through the App API (logo_url), and in the L-shape when the graphics set no logo_url (3.53.0)App API, L-shape
Needs the node: PlayoutGo sends overlays and logo_url for the built-in frame; whether they are drawn is the node's. A node that does not draw them ignores the fields (they are additions to the contract), and the graphics then reach air only on channels whose graphics are a page. Verified against a recording stand-in node, not a real one.

L-Shape Graphics v2.50

A channel in composited graphics mode gets the classic FAST-channel L-shape: the programme in the top-left 75% of a 1080p frame, a sidebar and bottom bar carrying headline, a smoothly scrolling ticker, colours and logo. The work happens on a composite-capable gpunode (-role composite or both, gpunode β‰₯ 1.34): the node pulls this origin’s clean HLS, renders the frame, re-emits HLS β€” and this origin keeps serving viewers, fronting the node’s output while it is healthy and its own clean output when it is not. Delivery accounting, DRM and CDN configuration are untouched, because the origin never moves.

Graphics sources

The built-in frame (headline/ticker/colours/logo in the channel dialog) is drawn entirely by ffmpeg on the node β€” no browser anywhere, works on every node, and the ticker scrolls smoothly because the encoder renders it. Pasted HTML and an external URL switch the node to headless Chromium: the page is screenshotted at 1920Γ—1080 (leave the top-left 1440Γ—810 transparent β€” the programme goes there), external URLs are re-snapshotted on an interval (min 30 s, default 5 min) with hold-last-good-frame β€” a failed snapshot keeps the previous image, because a broken graphics vendor must never take the programme down. Content changes apply while on air; the compositor restarts with the new frame and the clean fallback covers the seconds of gap.

The fallback (and the patience)

The origin health-checks the node’s playlist every few seconds; a playlist that stops changing is as dead as a 404. When it goes stale, viewers get the clean programme immediately β€” mid-item, because the alternative is dead air β€” and the switch is loud: a red FALLBACK chip on the channel card, an audit entry, and playoutgo_channel_graphics_ok at 0. When the node recovers, composited output returns quietly, at the next item boundary β€” graphics never pop back mid-programme. The amber resuming at boundary chip shows the wait. At start there is nothing on air to protect: the session's first healthy check serves composited output at once (3.53.0 β€” it used to wait for the first item boundary, so a channel opening on a two-hour film had no graphics for two hours).

No composite node configured? A composited channel simply serves clean output and says so in the log and audit trail β€” the mode never breaks a channel.
GET /api/admin/flight
The flight recorder’s ring: up to an hour of 30-second process vitals (RSS, heap, goroutines, descriptors, disk headroom, running channels). After an unclean shutdown the previous run’s samples are served until the ring refills β€” the black box, read out over HTTP.
πŸ” Administrator only
POST /api/channels/{id}/media/purge
Disk hygiene for one channel’s rundown: {"encodes":true} deletes every encoded rendition (regenerable; originals stay); adding "originals":true then deletes the uploads too β€” copies always go first, and originals of files on any RUNNING channel are refused. Optional "file_ids":[…] narrows the set. Returns per-file results with bytes freed; audited. In the interface: the 🧹 buttons on the Rundown page.
POST /api/admin/media/fix-doubled-names
Repair original_name values that are one string repeated exactly twice (a download-template accident): {"dry_run":true} lists every candidate with its before/after without touching anything; {"dry_run":false} applies and audits. Long stems only β€” short doubles like “gogo” are names and are never touched.
πŸ” Administrator only
POST /api/admin/storage/config
Edit the storage tiers from the interface: {"mirror_dir", "s3":{…}, "failover_cache_dir"}. The mirror must be writable from here or the change is refused β€” a failover that cannot fail over is worse than none. Persisted to config.json, applied live, audited. Send the secret key as β€’β€’β€’ to keep the stored one.
πŸ” Administrator only

Tiered Media Storage & Failover v2.49

Original media can live on up to three tiers: the primary (the uploads directory), an optional local mirror on another disk or partition, and an optional S3 bucket (AWS, MinIO, Wasabi β€” anything speaking the S3 API; a minimal SigV4 client is built in, no SDK). Configure in config.json:

"mirror_dir": "/data1/playout-media",
"s3": {"endpoint": "https://s3.us-east-1.amazonaws.com", "region": "us-east-1",
       "bucket": "tulix-playout", "prefix": "media", "access_key": "…", "secret_key": "…"}

With neither configured, nothing changes β€” single-directory installations behave exactly as before.

Replication

Every ingested original (browser upload or watch folder) is copied to the other tiers immediately, and a reconciler sweep runs every five minutes with one direction-free rule: every configured tier ends up holding every file at the right size, copied from wherever a good copy lives. That single rule is initial seeding, ongoing mirroring, healing a damaged replica, and post-outage resync β€” four behaviours that would each have bugs if they were four mechanisms. Truth is size: cheap enough to check every file every sweep, and it catches the failures disks actually have (missing, truncated, half-copied). If bit-rot is your concern, put the mirror on ZFS. Replicas are copied via a temporary name and rename, so an interrupted copy can never be mistaken for a finished one. Deleting a file deletes it from every tier β€” a mirror still holding a deleted file would resurrect it at the next sweep.

Failover

A health monitor write-probes the primary and mirror every five seconds β€” a stat is not enough, because a dead NFS mount stats fine and a read-only remount stats fine; writing a byte tests what ingest and playback actually need. Two consecutive failures declare a tier down (one slow probe during a backup does not fail anything over); two consecutive successes bring it back. While the primary is down: reads resolve per-file down the chain β€” primary, mirror, the restore cache, then a synchronous S3 download β€” so playback continues without any global switch; ingest writes to the first healthy local tier, so uploads keep working through the outage. The failover is announced everywhere an operator looks: a red badge in the top bar, the storage card in Admin, an audit entry, and the log.

Failback β€” automatic, with resync

When the primary passes probes again, an audit entry records the return, a sweep is kicked immediately, and everything that landed on the mirror during the outage flows back to the primary β€” then ingest routes to it again. Failback is not an operation anyone performs; it is a state the reconciler converges on. The ⟳ Sweep now button in Admin exists for the day a new disk is plugged in and five minutes feels long.

GET /api/admin/storage
The tiers, their health, which one ingest routes to, and the last sweep’s result.
πŸ” Administrator only
POST /api/admin/storage
Run a reconciler sweep immediately. Returns what it copied, uploaded, and could not fix. Audited.
πŸ” Administrator only
What the S3 tier is and is not: it is a replica and a disaster tier β€” files missing from BOTH local tiers are restored from it on demand, with a download before playback (logged, timed). It is not a playout source: nothing streams from S3 directly, and ingest never routes to it. If both local tiers are gone, restores land in the failover cache beside the database.

Disk Quotas & Account Lifecycle v2.48

Three controls cover an account’s life on the platform: a disk quota while it is active, suspension while a dispute is open, and deletion when it is over. Together they answer the question a multi-tenant playout host eventually asks: whose bytes are these, and who said they could be here?

Disk quotas

The quota covers original files β€” the bytes a user chooses to store. Renditions the system encodes do not count: their size is a property of the encode profile, not of anything the user did. Zero means unlimited, and is the default. Set it per account in the Users panel (✏ Edit β†’ disk quota in GB) or via PUT /api/admin/users/{id} with disk_quota_bytes.

Enforcement happens at every path that adds bytes, before a byte is written: the browser upload is refused with HTTP 507 and a message carrying the numbers (file size, space used, the ceiling); the watch folder leaves the file in place unimported β€” and picks it up on a later poll if space is freed or the quota raised, logging its refusal at most every ten minutes. The re-import scan is not gated: it adopts files already on disk, so refusing it would not save a byte, only hide existing bytes from the accounting the quota reads. Users see their own usage as a bar above the upload area (GET /api/media/usage), so a refusal is never the first they hear of the ceiling. Deleting files frees allowance immediately β€” the accounting is the library’s own file_size, corrected by every probe, so it tracks the disk without walking it.

Suspension

Suspension (⏸ in the Users panel, or POST /api/admin/users/{id}/suspend) stops the account’s channels immediately, refuses every start while it lasts, and β€” since v2.48 β€” closes the upload gate too: browser uploads answer 403 with the suspension reason, and the watch folder stops importing. Nothing is deleted; the usual reason is a billing dispute, and a customer who then pays should not have lost their library over it. Restoring lifts everything at once.

Deletion

Deleting an account (DELETE /api/admin/users/{id}) is the full teardown, in order: every running channel is stopped; the account’s file paths are collected before the database cascade (afterwards there would be no way to find them); the rows go; then the files go β€” uploads and encoded renditions. The response reports freed_bytes, and anything that could not be removed is named in not_removed rather than silently left to fill the disk. There is no undo; the confirmation says so.

GET /api/media/usage
The calling account’s own storage: {"used_bytes", "quota_bytes", "suspended"}. quota_bytes of 0 means unlimited. This is what draws the bar above the upload area.
πŸ” Requires auth
Reading the Users panel: each account row shows originals + encoded Β· quota (percent). Originals come from the library’s accounting; encoded from one walk of the renditions tree per page load. Only originals count against the quota.

Demo Accounts v2.47

A demo account exists to be tried, not to run a channel for free. The deal is stated in one sentence on the sign-up form: everything a demo account uploads is converted to at most 720p with a “Playout from TULIX.COM” watermark burned into the bottom-right corner before it can air. Everything else β€” channels, scheduling, EPG, the full interface β€” works exactly as it does for a paying account, which is the point: the demo shows the product, and the watermark shows whose.

How enforcement works

Enforcement happens at the probe gate β€” the one step every file passes on its way to becoming playable, whether it arrived through the browser uploader, the watch folder, or the re-import scan. A file belonging to a demo account is never “ready” clean:

uploading β†’ processing β†’ watermarking β†’ ready   (demo account)
uploading β†’ processing β†’ ready                  (normal account)

While a file shows watermarking it cannot be added to a rundown or played. When the transcode finishes, the watermarked file replaces the original atomically β€” the clean copy is not kept β€” and the re-probe marks it ready with its new dimensions. If the transcode fails, the file is marked error rather than being allowed to air clean; the failure is in the log with ffmpeg’s own words.

What the watermark looks like

One filter, defined in one place, so it cannot drift: video scaled to at most 720 lines (a smaller source is watermarked at its own size β€” upscaling would spend bits making it worse), then bold text at h/30 (≈24 px on a 720 line) in white at 92% over a soft dark pill (35% black, 10 px padding) with a one-pixel shadow, anchored bottom-right with a 24/20 px margin, clear of TV overscan. The pill is what makes it read on any content β€” white text alone disappears on clouds, black on shadows.

scale=-2:'min(720,ih)',format=yuv420p,
drawtext=fontfile=…DejaVuSans-Bold.ttf:text='Playout from TULIX.COM':
  fontsize=h/30:fontcolor=white@0.92:box=1:boxcolor=black@0.35:boxborderw=10:
  shadowcolor=black@0.6:shadowx=1:shadowy=1:x=w-tw-24:y=h-th-20

Where the transcode runs β€” and why

Demo transcodes run locally on the playout host, on the CPU (libx264, veryfast, CRF 21, audio copied β€” re-encoded to AAC only if the copy is refused), bounded to two at a time. This is deliberate: demo traffic must never occupy a GPU encode slot that paying work is queued for, and a demo account must work on a system with no encode nodes configured at all. A demo upload therefore takes a little longer to become ready than a paying upload does. That is the correct trade.

Lifting demo status

An administrator lifts it from the Users panel (✦ Lift demo) or the API. Lifting affects future uploads only: files already watermarked keep the watermark, because it is burned into the content β€” there is no clean copy to go back to. Re-uploading the originals is the path to clean copies. The same endpoint can also apply demo status to an existing account, which watermarks what it uploads from then on and leaves its existing library untouched. Both directions are audited.

POST /api/admin/users/{id}/demo
Set or lift demo status: {"demo": true|false}. Returns the updated user. Audited as user.demo.applied / user.demo.lifted.
πŸ” Administrator only
One caveat worth knowing: the watermarked flag lives on the database row, not in the file. If a watermarked file is deleted from the library and later re-imported by the uploads scan under a demo account, it is a new row and will be watermarked again β€” a second pill over the first. Harmless, but re-importing demo content is not a path anyone needs.

Authentication

API calls need an account token (a JWT, 72 hours) in the Authorization header. The exceptions are /api/auth/login, /api/auth/register, /api/auth/reset, /api/youtube/callback and the App API (/api/app/*, application key). The feeds /epg, /epg/my and /m3u take a feed token; /epg/{id}, /hls/, /hls-clean/ and /graphics/ are public; /metrics is IP-allowlisted. Obtain an account token via login.

POST /api/auth/login
{"email": "admin@playout.local", "password": "<admin_password from config.json>"}

β†’ {"token": "eyJ...", "user": {...}}

Pass the token in the Authorization header:

Authorization: Bearer eyJ...
Tokens in URLs. REST calls take the token in the Authorization header only. A few routes also accept ?token=, because what fetches them cannot set a header: /api/stream/stats and /api/stream/stats-poll (EventSource), /api/files/{id}/… (a <video> previewing a file) and /api/hls-proxy (a player). Put a scoped token there, never the account token: it lasts six hours and can only read β€” any other method is refused with 403 (3.52.0). An account token still works in those URLs but is logged as a warning. Subscription feeds use a feed token instead (below).

The three tokens

TokenWhere it goesLifetimeOpens
account (JWT)Authorization header72 h; /api/auth/refresh renews; a password or role change ends itevery API route the account may use
scoped (p…)?token= of a player, preview or EventSource URL6 hGET/HEAD on /api/hls-proxy, /api/files/, /api/stream/stats*
feed (f…)?token= of an XMLTV or M3U subscriptionno expiry; changing the account's password revokes it/epg, /epg/my, /epg/{id}, /m3u β€” nothing else
GET /api/hls-proxy/token
A scoped token for URLs a player cannot add a header to. Returns {"token","expires_at"}.
πŸ” Requires auth
GET /api/epg/feed-token
The caller's feed token, for /epg/my?token=… and /m3u?token=… (3.52.0). Returns {"token","scope","note"}. The EPG tab builds its links with it. Before 3.52.0 those links carried the account token, so they opened every API route and stopped working after 72 hours.
πŸ” Requires auth
POST /api/auth/register
Create a new user account. Returns JWT token.
Body: {"email", "password", "invite_code"?, "demo"?}. Setting "demo": true creates a demo account: every file it uploads is transcoded to at most 720p with a “Playout from TULIX.COM” watermark before it can air. The choice is one-way from the API β€” only an administrator can lift it afterwards. The returned user object carries is_demo.
POST /api/auth/login
Authenticate and receive a JWT token (72-hour expiry). Twenty consecutive failures from one address block it for 15 minutes; a rolling 15-minute window also refuses an address after 50 failures and an account after 10, and a successful login does not clear that window. A suspended account can still sign in (suspension stops its channels; it does not lock it out).
GET /api/auth/2fa
The account’s two-factor sign-in: enabled, and recovery_codes_left (3.60.0). POST /api/auth/2fa/setup {password} returns the secret, the otpauth_uri and a QR image; /api/auth/2fa/enable {code} turns it on and returns ten recovery codes and a new token; /api/auth/2fa/disable and /api/auth/2fa/recovery take {password, code}. An administrator resets an account’s with DELETE /api/admin/users/{id}/2fa. Sign-in takes the code as otp; without it an account with two-factor answers 401 with otp_required: true.
πŸ” Requires auth
POST /api/auth/change-password
Change own password. Requires current_password and new_password. Changes persist across restarts.
πŸ” Requires auth
POST /api/auth/reset
Emergency admin reset. Off by default: it answers 404 unless allow_password_reset is true and a recovery_secret of at least 24 characters (different from admin_password) is set. Body: {"secret": "<recovery_secret>"}. A wrong secret is 403; after 5 attempts from one address in 15 minutes, 429. Restores the administrator's email and password from config.json.
⚠️ No token required β€” protected by recovery_secret only. Turn it off again after use.
GET /api/auth/me
Returns the authenticated user's profile.
πŸ” Requires auth
POST /api/auth/refresh
Issue a fresh 72-hour JWT without re-entering credentials. Call this before the current token expires to stay logged in. Requires a currently valid token.
πŸ” Requires auth

Public Endpoints (no token)

These are served without authentication, deliberately, because the systems that fetch them cannot hold a credential: /hls/ and /hls-clean/ (players and compositing nodes), /epg/{id} (one channel's XMLTV, for media servers), /graphics/{id}.html and /graphics/{id}/cues.json (compositors), /docs, /about, /viewer, /guide/push, /guide/deploy, /healthz and /api/youtube/callback. The ones with something to say are below.

GET /graphics/{id}.html
A channel’s branding rendered as a page β€” headline, ticker, logo and the three colours, drawn on a transparent background so everything unpainted is programme. A compositor fetches this with a headless browser and snapshots it; a browser carries no Authorization header, which is why it sits here beside the clean programme it decorates rather than behind the API. Served no-store: a headline correction that a proxy holds for five minutes is a correction that did not happen.

Origin isolation (audit C-01). A channel may carry custom HTML instead of the built-in branding, and that markup is served verbatim because arbitrary full-page markup cannot be sanitized into safety. Containment is the control instead: every response here carries a CSP sandbox directive without allow-same-origin, which assigns the document a unique opaque origin. In an opaque origin localStorage access throws, cookies are empty and a fetch to /api/… is cross-origin and refused — so a script stored as graphics cannot read the control UI's session token even though the page is served from the same port. X-Graphics-Isolation on the response says which defence is in force.

For structural isolation rather than policy, set graphics_port in config.json. The page then lives on that origin alone — which serves nothing else — and the control origin answers 404 for /graphics/, naming the port it moved to. PlayoutGo tells the node the right URL either way, so turning it on does not break compositing. Add graphics_base_url when nodes reach that origin by another name.

Previewing custom graphics: open it in a sandboxed <iframe> rather than navigating to it top-level. frame-ancestors permits 'self' for exactly this reason: an embedded opaque-origin frame is a safer way to look at tenant markup than giving it a window of its own.

GET /docs
This manual. Also embedded in the Help tab of the console.
GET /about
A one-page overview of the system for somebody who has not used it. Entirely self-contained, so the page can be saved and sent to a person who has no server to run it on.

Connecting a YouTube Account

An operator can connect their YouTube account once and never copy a stream key again. PlayoutGo creates the broadcast and reads the ingest address itself.

PlayoutGo never asks for a Google password, and could not use one. Google blocks programmatic password sign-in for consumer accounts, so it would fail on every real account β€” and a Google password unlocks Gmail, Drive and Ads, not just the one channel an operator meant to grant. The authorisation happens on Google’s own pages; PlayoutGo receives a token limited to YouTube, which the account holder can revoke at any time from their Google account without changing anything else.

Setting it up, once, per deployment

  1. In the Google Cloud console: a project, the YouTube Data API v3 enabled, and an OAuth client of type Web application.
  2. Add the redirect URI the status endpoint reports β€” it is <advertised_base_url>/api/youtube/callback. Google rejects anything not listed there, so it must match exactly.
  3. Put the client id and secret in config.json as google_client_id and google_client_secret.
GET /api/youtube/status
Whether a Google client is configured, whether an account is connected, which YouTube channel it is, and the exact redirect URI to register. When nothing is configured it says so rather than sending the operator to a failure at Google.
πŸ” Requires auth
GET /api/youtube/connect
Returns the URL to send the operator’s browser to. It requests access_type=offline and prompt=consent β€” without both, Google returns no refresh token on a repeat authorisation and the connection silently lasts one hour.
πŸ” Requires auth
GET /api/youtube/callback
Where Google returns the operator. Public, because a browser redirect carries no session β€” it is protected by the one-time code and a state value this server issued, which expires in ten minutes and is accepted once.
POST /api/channels/{id}/youtube
Creates a broadcast and a stream on the connected account, binds them, and puts the resulting ingest address on this channel. Body takes title, description and privacy; privacy defaults to unlisted because a broadcast created by mistake should not be announced to a channel’s subscribers.
πŸ” Requires auth
curl -s -X POST $BASE/api/channels/7/youtube -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{"title":"Tulix Live","privacy":"unlisted"}'
β†’ {"broadcast_id":"…","stream_id":"…","privacy":"unlisted",
   "watch_url":"https://youtu.be/…","push_target":"rtmp://a.rtmp.youtube.com/live2/ab…op",
   "message":"The broadcast is ready. Start the channel and it will go live automatically."}
POST /api/youtube/disconnect
Forgets the account here. The reply explains that revoking access entirely is done from the operator’s own Google account page β€” this endpoint cannot do that for them.
πŸ” Requires auth

How the token is held

The refresh token is encrypted with AES-256-GCM under the server’s keyring (secrets_key_file, see Secrets at rest), which is not in the database β€” so a copy of the database alone yields nothing. The short-lived access token is held in memory and refreshed a minute before it expires, because a token that lapses between the check and the call fails a live operation for no reason. Neither token is ever returned by any endpoint or written to a log.

Pushing a Channel to YouTube

A channel can publish itself to any service that gives you an RTMP address and a stream key β€” YouTube, Facebook, Twitch. It sends the same frames as SRT β€” the original, the single rendition, or an ABR channel's tallest rung (until 3.52.0 a fully encoded ABR item sent the push nothing) β€” so there is only ever one quality to reason about. Only the main audio language is pushed.

Setting it up

  1. In the channel dialog, paste the address and key into Push to a streaming service: rtmp://a.rtmp.youtube.com/live2/xxxx-xxxx-xxxx-xxxx. It is checked as you type β€” an address with no key is the commonest mistake and is caught before saving.
  2. Press “Use YouTube’s recommended quality” to set the ladder from their own live guidance: 1080p at 4500 kbps, 720p at 2500, 480p at 1000, AAC 128, and a two-second GOP. Getting one rung wrong means the stream is accepted and then transcoded badly, which looks like a fault in the picture rather than a setting.
  3. Start the channel. The push connects, and retries by itself with a widening delay if the service drops it.
GET /api/channels/{id}/push
How the push is doing: connected, frames sent, dropped, rate, retries, and the reason when it is not running. The stream key is never returned β€” the target is reported with the key elided, and the same applies to every log line.
curl -s $BASE/api/channels/7/push -H "Authorization: Bearer $TOK"
β†’ {"enabled":true,"target":"rtmp://a.rtmp.youtube.com/live2/ab…op",
   "connected":true,"seconds_up":412,"video_frames":10300,"audio_frames":17800,
   "dropped":0,"mbps":4.6,"retries":0}

One soundtrack. RTMP carries a single audio stream, so a channel with several languages pushes the main one and counts the rest β€” alt_audio_dropped in the push status, with a line saying why. Sending them all would put four soundtracks into one stream, which decodes as noise.

What to expect. The push never slows the channel: if the service cannot keep up, frames are dropped and counted rather than queued β€” a live push that buffers falls further behind every second and never recovers. Frames sent before the connection is established are dropped too, which is why a small dropped count at start-up is normal. Stopping the channel closes the connection, so the service stops showing a stream with nothing behind it.

SDI Output (DeckLink) 3.62.0

A channel can play out of an SDI port on a Blackmagic DeckLink card in this server β€” to a router, a transmitter, a master-control switcher or a monitor β€” alongside SRT, HLS and the RTMP push. The output takes the same frames as everything else: they are muxed into a transport stream and piped to ffmpeg, which decodes them, scales and pads them to the SDI format (aspect kept, black bars), sets its frame rate and field order, converts to 4:2:2 8-bit (uyvy422), embeds the audio as 48 kHz 16-bit PCM, and writes to the card with ffmpeg’s decklink output device. The card’s clock paces the port.

What the server needs

Setting it up

  1. As an administrator, open the channel dialog: SDI output (Blackmagic DeckLink). The DeckLink output box suggests the cards ffmpeg sees (as ffmpeg -sinks decklink names them: DeckLink Duo (1)).
  2. Choose the video format: 1080i59.94 (North America), 1080i50, 1080p29.97, 1080p30, 1080p25, 1080p59.94 and 1080p50 (3G-SDI), 720p59.94, 720p50, 525i59.94 (SD NTSC, 720Γ—486, bottom field first), 625i50 (SD PAL). Interlaced HD is top field first.
  3. Choose the embedded audio: 2, 8 or 16 channels. The channel’s main audio is on channels 1 and 2; the others are silent.
  4. Set SDI On and press Apply SDI settings. It takes effect at once: on air, the port starts (or restarts with the new settings); off air, it starts with the channel.
GET /api/channels/{id}/sdi
The channel’s SDI settings (config: enabled, device, format, audio_channels), the formats (formats), whether the channel is on air, and how the output is doing (status: running, seconds_up, video_frames, audio_frames, dropped, alt_audio_dropped, restarts, error β€” ffmpeg’s own last words β€” why, and decklink_supported).
πŸ” Requires auth + ownership
PUT /api/channels/{id}/sdi
{"enabled","device","format","audio_channels"}. Refused (400) when the format is not one of the list, the audio is not 2, 8 or 16 channels, the device name holds anything but letters, digits, spaces and ( ) . _ + / -, or SDI is turned on with no device. Applied at once when the channel is on air. Audited.
πŸ” Administrators only β€” the output drives a card in this server
GET /api/sdi/devices
decklink_supported, the DeckLink outputs ffmpeg sees (devices), the formats, and why when there is nothing to choose.
πŸ” Administrators only
curl -s -X PUT $BASE/api/channels/7/sdi -H "Authorization: Bearer $TOK" \
  -d '{"enabled":true,"device":"DeckLink Duo (1)","format":"1080i59.94","audio_channels":8}'

What to expect. The output never slows the channel: if ffmpeg falls behind, frames are dropped and counted, as the RTMP push does. It starts on a keyframe. If ffmpeg exits β€” no card, the card busy, the driver missing β€” the status gives ffmpeg’s own message, and it is started again after 1, 2, 4 … up to 30 seconds. Stopping the channel stops the port. A channel’s second and further languages are not embedded (counted as alt_audio_dropped). Captions are not carried in the SDI ancillary data.

UDP / RTP Output 3.63.0

A channel can send its transport stream over UDP β€” to a multicast group (239.1.1.1:5000) or a unicast address β€” the way cable head-ends, IRDs, multiplexers and IPTV networks take it. It is the same stream the SRT output carries: one muxer feeds every transport output, so the DVB service tables, SCTE-35 splices and constant-rate padding reach the multicast as they reach SRT. It runs whenever the channel is on air, whatever its output mode.

Setting it up

  1. As an administrator, open the channel dialog: UDP / RTP output.
  2. Destination: IP:port β€” an IP literal, not a name, so a feed never moves because a DNS answer did (239.1.1.1:5000, [ff3e::1234]:5000, 10.0.0.5:1234).
  3. Encapsulation: UDP, seven 188-byte packets to a datagram (1316 bytes); or RTP, the same with a 12-byte RFC 3551 header β€” payload type 33 (MP2T), a sequence number, a 90 kHz timestamp and one SSRC per run. A datagram is always seven packets while the stream flows; packets that have waited a quarter of a second for the rest β€” a channel with no constant rate gone still β€” are sent short, and counted (short_datagrams).
  4. Interface: the network interface multicast leaves by (eth1); blank lets the routing table choose. TTL: multicast hops, 1–255 (default 16). DSCP: the IP header’s traffic class, 0–63 (34 is AF41, 46 is EF).
  5. Pacing: -1 spreads the datagrams evenly at the channel’s constant bitrate (the default, and what equipment with small input buffers needs); a number paces at that many kb/s; 0 sends each frame’s packets at once. Paced, a queue holds two seconds; a stream faster than the pacing rate drops datagrams and counts them.
  6. Set it On and press Apply UDP settings. On air, it starts at once.
GET /api/channels/{id}/udp
The settings (config: enabled, address, interface, ttl, dscp, rtp, pace_kbps), whether the channel is on air, and how the output is doing (status: running, multicast, datagrams, bytes, mbps, dropped, short_datagrams, send_errors, paced_kbps, error, why).
πŸ” Requires auth + ownership
PUT /api/channels/{id}/udp
The settings above. Refused (400) for a name instead of an IP, a port outside 1–65535, the broadcast or unspecified address, TTL outside 1–255, DSCP outside 0–63, or On without a destination. Applied at once on air. Audited.
πŸ” Administrators only β€” it sends traffic onto this server’s networks
curl -s -X PUT $BASE/api/channels/7/udp -H "Authorization: Bearer $TOK" \
  -d '{"enabled":true,"address":"239.1.1.1:5000","interface":"eth1","ttl":8,"dscp":34,"rtp":true,"pace_kbps":-1}'

With SRT as well. When the UDP output runs, an SRT receiver connecting no longer restarts the stream: the multicast carries on with no continuity break, and the SRT receiver is given only padding and service information until the next keyframe, then the current PAT and program map, then the stream. Without a UDP output, SRT works as it always did.

Watching it. playoutgo_channel_udp_bytes_total, playoutgo_channel_udp_datagrams_total, playoutgo_channel_udp_dropped_total and playoutgo_channel_udp_send_errors_total in /metrics; the bundle’s alert rules fire on refused and dropped datagrams; a refused output is an output.down webhook event. Datagrams the network accepts can still be lost beyond the server: multicast routing (IGMP, PIM) is the network’s.

Alert Webhooks 3.63.0

The server tells your systems when something goes wrong on air, instead of waiting to be scraped. Each event is an HTTP POST of JSON to every webhook that wants it, signed with that webhook’s secret, queued in the database and retried, so an endpoint that is down for a while gets what it missed. Administrators set them up under Admin β–Έ Alert Webhooks; at most 20.

Events

EventWhen
channel.downA channel’s engine stopped unexpectedly (it restarts itself), or gave up after five restarts in five minutes.
channel.upA channel reported down is on air again.
channel.errorA line in a channel’s error log; the same text is sent once in ten minutes.
output.downAn output stopped delivering: the SRT receiver could not be reached, the UDP output’s datagrams are refused, SDI’s ffmpeg exited. extra.output is srt, udp or sdi.
output.upThat output is delivering again.
media.unplayableA file was found unplayable and marked for attention.
dead_airA gap aired as dead air β€” no filler and no slate; once in ten minutes per channel.
testThe Send test button.

A down/up pair is sent once each way: a channel that keeps failing is one channel.down, then one channel.up when it is back. A webhook can take every event or a list, and every channel or a list.

What is sent

POST /your/endpoint
Content-Type: application/json
X-PlayoutGo-Event: output.down
X-PlayoutGo-Delivery: 42
X-PlayoutGo-Timestamp: 1792008000
X-PlayoutGo-Signature: sha256=6f1c…

{"id":"evt_9b2e…","event":"output.down","at":"2026-10-14T21:20:00Z","server":"https://plt.example.com",
 "version":"3.64.0","channel":{"id":7,"name":"ENG"},"summary":"udp output down",
 "detail":"datagrams to 239.1.1.1:5000 are refused: network is unreachable","extra":{"output":"udp"}}

Checking the signature. Compute HMAC-SHA256 of <X-PlayoutGo-Timestamp>.<body> with the webhook’s secret and compare it, in constant time, with the hex after sha256=; refuse a timestamp more than a few minutes old. The secret (whsec_…) is shown once, when the webhook is added or given a new one, and stored sealed.

Delivery

GET /api/admin/webhooks
Every webhook (never its secret) with its last ten deliveries, and the events.
πŸ” Administrators only
POST /api/admin/webhooks
{"url","description","events","channels","enabled"}. The answer carries the secret, once. Audited.
πŸ” Administrators only
PUT /api/admin/webhooks/{id} Β· DELETE /api/admin/webhooks/{id}
Change a webhook (its secret is kept), or remove it with its deliveries. Audited.
πŸ” Administrators only
POST /api/admin/webhooks/{id}/test Β· /rotate Β· GET …/deliveries
test sends a test event at once and answers with the result (retried later if it failed); rotate gives the webhook a new secret, returned once; deliveries lists the last 100. Test and rotate are audited.
πŸ” Administrators only

Watching a Running Server

GET /healthz
For whatever puts this instance in and out of rotation. 200 means it can serve; 503 means take it out. Unauthenticated on purpose β€” a check that needs a credential is one somebody forgets to rotate, and it reveals nothing a port scan does not.
curl -s http://playout:8937/healthz
β†’ {"status":"ok","version":"3.38.0","uptime_seconds":412}

Deliberately shallow. It confirms the database is reachable and nothing more. A check that queries hard becomes the thing that finishes off a server already under strain, and one that reports on every channel would fail the whole instance because a single channel is stopped β€” which is a normal condition, not an unhealthy one. Use /metrics to alarm on channels.

GET /metrics
Prometheus text format. Off unless allowlisted: set metrics_allow_from in config.json to the addresses or ranges your scraper comes from. Until then it answers 404 and says nothing about why, so a scan learns nothing about what is or is not enabled.
"metrics_allow_from": ["10.0.0.0/8", "127.0.0.1"]

The pair worth alarming on is playoutgo_channel_up and playoutgo_channel_delivering. A channel can be up and not delivering β€” marked on air while writing no segments β€” and that gap is the failure an operator most wants to hear about, because nothing else reports it.

The process itself 3.57.0

playoutgo_process_goroutines, playoutgo_process_heap_bytes, playoutgo_process_resident_bytes, playoutgo_process_open_fds, playoutgo_process_threads and playoutgo_process_uptime_seconds. Alert on resident memory approaching the memory limit, open files approaching the unit’s LimitNOFILE, and an uptime that resets (a restart). Over an hour with five channels on air they held flat: 41 goroutines throughout, 18–24 open files, 9 threads, 24–30 MB resident (26.6 MB on average) β€” 3.57.0; from 3.58.0 each channel adds one goroutine, the as-run heartbeat, so 46 with five, flat over the 3.58.0 soak.

Bytes (3.57.0): playoutgo_channel_bytes_sent_total is the transport stream alone (SRT), as its description says, and playoutgo_channel_hls_bytes_total is the HLS variant’s segments. Until 3.57.0 the first was the engine’s total, HLS included: a constant-rate channel read about 15% above its configured rate, and an HLS-only channel appeared to send on a transport output it does not have.

Alert rules 3.58.0

The deploy bundle holds playoutgo-alerts.yml, Prometheus rules for the alerts above: the server down; a channel on air and not delivering; a restart; resident memory above 80% of the default memory limit; open files above 80% of LimitNOFILE; the disk under 10% and 3%; encoding nodes down and the queue stalled; a constant-rate channel more than 2% off its rate; late or backwards DVB clock references; graphics falling back. Copy it to the Prometheus host and list it under rule_files; scrape the server as job playoutgo. A test checks that every metric the rules name is one /metrics exports, and, where promtool is installed, runs promtool check rules and the rule tests in deploy/playoutgo-alerts.test.yml, which feed each alert the series it should fire on. Adjust the memory and file thresholds if you change GOMEMLIMIT or LimitNOFILE.

Capacity, Measured

Eight channels on air at once, each looping a 1280×720 25 fps file to HLS: 197 MB resident and 10.3% of one core, with all eight writing fresh segments. That is roughly 17 MB and 1.3% of a core per channel over about 40 MB of baseline.

ChannelsMemoryCPU
24~0.4 GB~0.3 core
50~0.9 GB~0.7 core
100~1.7 GB~1.3 cores

Read those as a floor rather than a promise. The measurement looped one small file on every channel, so the page cache is working less hard than it would with a hundred distinct programmes, and there is no encoding in the figure β€” that is what the nodes are for. Graphics compositing, CBR pacing and an RTMP push each add work this did not include. What it does establish is that the per-channel cost is small and flat: eight channels behaved no differently from one, and memory grew linearly rather than in steps.

Playout is I/O and clock bound, not processor bound β€” it remuxes rather than transcodes, so the disk feeding the files is the thing to size. Each channel also holds its own segment directory, continuously written; a hundred channels at a six-segment window of 1080p is real disk.

And it plays

A standard HLS client consumed 40 seconds of a live channel over HTTP, across a rundown of two different files so it had to cross an item boundary mid-stream: 998 video packets over exactly 40.0 seconds, no decoder complaints, and no gap over 200 ms. The second file’s extra Spanish track appeared mid-stream and the client picked it up.

Zero gaps is the number that matters. A stream that decodes but stutters at every segment boundary passes every structural check and is unwatchable.

Constant Bitrate β€” Two Different Things

“CBR” means two separate mechanisms here, and confusing them is how a channel ends up over its pipe.

1 Β· The channel’s transport rate β€” done here, not on a node

Set cbr_bps on a channel and the engine paces its own SRT output, padding each interval with null packets so the total never varies. This is what a modulator, an ASI card or a multiplexer expects. It runs in PlayoutGo; no encode node is involved.

Exact, not approximately right (3.57.0). What is left of a packet each interval, and the fractions of a bit and of a byte, are owed to the next one, so over any minute the stream is the configured rate to within one packet. Before, each 20 ms interval dropped half a packet on average: a 3 Mb/s channel left at about 2.965 Mb/s.

Padding can only ever add. If the programme itself runs above the target the pacer cannot bring it down β€” that needs re-encoding β€” and the stream leaves at the content’s rate. Measured: a 4 Mb/s channel playing a 6.5 Mb/s file transmits at 6.75 Mb/s with 2% padding. The channel now says so in the log when an item is above its target, naming both rates.

The rate starts with the receiver (3.56.0). The pacer used to begin its interval when the engine started, or when the last receiver left, and paid that idle time as one burst of padding on the next connect β€” up to a second’s worth on top of the stream, about 1.7 times the rate in the first second, into the buffer of exactly the equipment a constant rate exists to serve. It now restarts when a receiver connects; measured, the first second carries the rate or less.

2 Β· Encoding a file at a constant rate β€” done by the node

Choose Constant bitrate as a channel’s source quality and each encode carries cbr=1 to the node, which holds the encoder to the target and pads the undershoot. That is gpuff’s work, not ours.

The two belong together. Encode the files at or below the rate, then let the transport pacer hold the pipe exactly. Either alone leaves a gap: encoding without pacing gives a stream that dips between pictures, pacing without encoding gives one that overruns.

What PlayoutGo Sends an Encode Node

Two questions come up often enough to answer here: how the QR code reaches the picture, and how an L-shape does. Neither needs an endpoint of its own on the node.

The QR code β€” a generic image overlay, not a “QR API”

PlayoutGo renders the code itself and sends it as an ordinary PNG. The node never learns it is a QR code, and does not need to: it composites an image at a position and a scale. That is deliberate β€” a node that had to know about QR codes would need a new release every time the thing being burned in changed.

POST /v1/jobs        (multipart)
  ladder            [{"label":"1080p","height":1080,"video_k":5000,"audio_k":128}, …]
  format            mp4
  segment_seconds   6                # the profile's; the YouTube preset uses 4
  gop_seconds       2
  align             true
  cbr               1                # only for a constant-bitrate profile
  audio_hz          48000            # the source rate, when probed, so the node need not guess
  owner             playoutgo
  overlay           <overlay.png>    # the rendered code, as a file part
  overlay_pos       main_w-overlay_w-(main_h*0.03):main_h-overlay_h-(main_h*0.03)   # bottom-right; other corners follow qr_corner
  overlay_scale     0.1100           # four decimals; only when above 0
  file              <source.mp4>     # or sha, when the node already has it

The scale is a fraction of each rendition's picture height, whatever size the PNG is: 0.11 is about 119 px on 1080p and 79 px on 720p. It rises to 64 px Γ· the shortest rung's height when that is larger (capped at 0.25), so the smallest rung still carries a scannable code. Local encodes on this server apply it the same way. Nothing in the request tells the node which audio tracks to keep or to carry captions; that is up to the node (audio languages, captions).

Verified against gpuff 1.8.3: a white test overlay on a black source encodes with the corner reading luma 235 β€” the image is on the picture.

The L-shape β€” a composite session, not an encode job

Graphics do not travel with an encode. A composited channel opens a session on a node that takes the channel’s clean output, squeezes it into a frame and publishes the result.

POST /v1/composite
{
  "id":          "ch7",
  "channel":     "News 24/7",
  "source_url":  "<advertised_base_url>/hls-clean/news-24-7-7/index.m3u8",
  "layout":      "lshape",
  "headline":    "BREAKING",
  "crawl":       "…scrolling text…",
  "bar_background": "#101820",
  "bar_accent":     "#2ea0ff",
  "graphics":    {"headline":"BREAKING","ticker":"…","bg_color":"","text_color":"","accent_color":"","logo_url":"", …}
}

That is a channel on the built-in frame: the two colour fields carry its colours (or the defaults shown) and the node draws the bar. When an operator supplied HTML or a URL instead, the colour fields are left out and graphics becomes {"type":"html","url":"…/graphics/7.html","refresh_seconds":300, …} β€” the page, and how often to re-capture it (snapshot_sec, default 300, at least 30). A page outranks the node’s own drawn bar, which is why it is sent only when set. Until 3.52.0 the colour fields were never sent at all (they sat under the page branch) and the refresh default was 5 seconds.

Verified against gpuff 1.8.3: bottom bar luma 39, right column 34, programme 125 β€” the furniture is on the picture, and the session echoes layout, headline, crawl, bar_background and bar_accent back so a caller can see what was accepted.

Text changes go to POST /v1/composite/{id}/update with {headline, ticker} and reach air within a frame without restarting the session. Anything structural β€” colours, logo, layout β€” rebuilds it, which a viewer sees.

Added in 3.53.0, for the built-in frame:

  "logo_url":  "<logo_url, or <graphics origin>/graphics/7/logo?v=…>",   # also in graphics.logo_url
  "overlays":  [{"code":"TVG","url":"https://cdn.example/tvg.png","corner":"top-left","width_pct":6,"margin_pct":3}]

POST /v1/composite/ch7/update
{"headline":"…","ticker":"…","overlays":[…]}     # on every change of the traffic graphics; [] clears them

overlays are the traffic graphics on air (rating, daypart logo, E/I label): an image, a corner, a width as a percentage of the frame's and a margin from its edges. Paths on this server are made absolute with advertised_base_url. A node that draws neither field ignores them; drawing them is a node change (roadmap).

Inserting an Ad Avail Now

Scheduled breaks are set on a rundown item, in the πŸ“’ dialog. This is the other case: a channel is on air and an operator needs an avail now.

On the Rundown page, with the channel airing: πŸ“’ Break now, and how many seconds.

POST /api/channels/{id}/scte35
Signals an avail on the next frame. Body takes seconds or duration_ms; thirty seconds if neither is given. Works on any item, including one with no scheduled breaks.
πŸ” Requires auth
curl -s -X POST $BASE/api/channels/7/scte35 -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{"seconds":45}'
β†’ {"inserted":true,"duration_ms":45000,
   "message":"A 45 second avail is going out now β€” in the transport stream and as
              CUE-OUT in the playlist."}

Queued (3.53.0): splice signals go out from a file item β€” since 3.54.0 an ABR item too. During gap filler, the slate or a live feed β€” or in the instant between items β€” the break waits for the next item, and the answer says so with 202: {"inserted":false,"queued":true,"duration_ms":…,"message":"Queued: the 45 second avail cannot go out yet β€” the slate is airing …"}. The console shows it as a notice to read, not a success toast. Pressing Break now again replaces a queued break. It is audited as scte35.queued. Until 3.53.0 the answer was "going out now" in every case. On a file item whose SRT receiver has not yet been sent the program map that declares the splice stream, the answer is also queued β€” it goes out within a second.

An avail asked for while another is open closes the open one first (its return), then starts β€” see an avail inside an avail.

The picture does not change. An avail tells whatever is downstream that it may insert; the programme keeps playing. Operators expect a break button to cut to black, so the dialog says what actually happens rather than leaving it to be discovered on air.

It goes out on both paths, the same code as a scheduled break: a splice_insert on the transport stream’s SCTE-35 PID, declared in the program map, and #EXT-X-CUE-OUT with #EXT-X-DATERANGE in the playlist. A channel that is not on air is refused with a reason β€” a break signalled into nothing is worse than a refusal, because an operator would believe it went out.

Every insertion is written to the audit trail with its length and who asked for it.

Collections API

Files are grouped by label, not by folder. A clip airs on more than one channel more often than not β€” an ident belongs to every channel a brand owns β€” and a folder tree forces one home, so the same file gets copied into three places and edited in one. A label leaves the file where it is, and moving a file between collections moves no bytes.

GET /api/collections
The labels in use with a count each, most-used first β€” what an operator actually has rather than what they might have. An admin sees every label; anyone else sees their own.
πŸ” Requires auth
curl -s $BASE/api/collections -H "Authorization: Bearer $TOK"
β†’ {"collections":[{"name":"News","files":42},{"name":"Idents","files":8}]}
POST /api/collections/assign
Label several files at once. Importing two hundred clips and labelling them one at a time is not a workflow anybody would use, so the bulk form is the one that matters. An empty collection removes the label.
πŸ” Requires auth
curl -s -X POST $BASE/api/collections/assign -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' \
     -d '{"file_ids":[12,13,14],"collection":"News"}'
β†’ {"updated":3,"collection":"News"}
POST /api/collections/rename
Rename a label everywhere it is used. Renaming to a label that already exists merges the two; renaming to empty removes the label from every file in it. Renaming a collection must not mean re-labelling two hundred files by hand.
πŸ” Requires auth
curl -s -X POST $BASE/api/collections/rename -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{"from":"Idents","to":"News"}'
β†’ {"updated":8,"from":"Idents","to":"News",
   "message":"renamed \"Idents\" to \"News\" across 8 file(s)"}
PUT /api/files/{id}/collection
One file's label. Same rules; the bulk endpoint above is usually what you want.
πŸ” Requires auth

A label may not contain control characters or text-direction overrides: they travel from here into XMLTV, where a NUL makes the whole feed unparseable and an override can make one label impersonate another on screen. Sixty characters maximum.

Drop boxes β€” importing straight into a channel

A watch folder can be pointed at a channel. Anything landing there is imported as that user’s media, labelled with the folder’s collection, and β€” with append_to_rundown β€” added to that channel’s rundown. One directory per channel is the simplest possible interface for an editor with an export button and no interest in this system.

curl -s -X POST $BASE/api/admin/watch -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{
       "path":"/srv/dropbox/news","move":true,
       "channel_id":7,"collection":"News","append_to_rundown":true}'

What to expect: a file is added to the rundown only after it probes as playable. Adding first and discovering the file is broken second is how a channel ends up with a gap on air. A file that fails to probe stays in the library, marked, and is not scheduled.

API Β· Worked Examples

Every call below is real: the request as you would send it, and the answer as the server gives it. Anything marked πŸ” needs Authorization: Bearer <token>. Set BASE and TOK once and the rest can be pasted straight into a terminal.

BASE=https://plt.bozztv.com
TOK=$(curl -s -X POST $BASE/api/auth/login \
       -H 'Content-Type: application/json' \
       -d '{"email":"you@example.com","password":"…"}' | jq -r .token)

1 Β· Put a file on air from nothing

Upload, make a channel, add the file, start it. Four calls.

# Upload. The response includes the id everything else refers to.
curl -s -X POST $BASE/api/files -H "Authorization: Bearer $TOK" \
     -F 'file=@promo.mp4'
β†’ {"id":12,"original_name":"promo.mp4","status":"processing"}

# Probing takes a moment. Poll until status is "ready".
curl -s $BASE/api/files -H "Authorization: Bearer $TOK" | jq '.[0] | {id,status,duration_ms,width,height}'
β†’ {"id":12,"status":"ready","duration_ms":30000,"width":1920,"height":1080}

# A channel. output_mode is "hls", "srt" or "both".
curl -s -X POST $BASE/api/channels -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' \
     -d '{"name":"Promo Loop","output_mode":"hls","loop_playlist":true}'
β†’ {"id":7,"name":"Promo Loop","status":"stopped","hls_url":"…/hls/promo-loop-7/index.m3u8"}

# Add the file to its rundown, then go to air.
curl -s -X POST $BASE/api/playlist/7 -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{"file_id":12,"position":0}'
curl -s -X POST $BASE/api/channels/7/start -H "Authorization: Bearer $TOK"
β†’ {"status":"started"}   # "already_running" if it was on air; the playlist begins within a second or two

What to expect: the first HLS segment appears a few seconds after start. Until then the playlist exists but is empty, which is normal β€” a player that connects immediately will wait rather than fail.

2 Β· Cut to a live feed and back

Point an encoder at the channel's publishing address, check it is arriving, then take it.

# Where to publish, and whether anything is there yet.
curl -s $BASE/api/channels/7/live -H "Authorization: Bearer $TOK"
β†’ {"channel_id":7,"publish_url":"rtmp://plt.bozztv.com/live/live_3f9c…","key":"live_3f9c…",
   "key_predictable":false,"connected":false,"ready":false,"why":"nothing is publishing to this point"}

# Channels created before 3.48.0 still accept the guessable key ch<id> and report
# "key_predictable":true. Issue a random one (refused while the feed is on air),
# then put the new publish_url in the encoder:
curl -s -X POST $BASE/api/channels/7/live/rekey -H "Authorization: Bearer $TOK"
β†’ {"publish_url":"rtmp://plt.bozztv.com/live/live_3f9c…","key":"live_3f9c…","key_predictable":false, …}

# From the truck, OBS, or anything that speaks RTMP:
ffmpeg -re -i SOURCE -c:v libx264 -preset veryfast -tune zerolatency -g 50 \
       -c:a aac -f flv rtmp://plt.bozztv.com/live/live_3f9c…

# Now it is arriving β€” size and rate read from the encoder itself.
β†’ {"connected":true,"ready":true,"width":1920,"height":1080,
   "has_audio":true,"mbps":8.4,"frames":1402,"keyframes":57,"on_air":false}

# Take it to air. Refused with a reason if the feed is not fit.
curl -s -X POST $BASE/api/channels/7/live/take -H "Authorization: Bearer $TOK"
β†’ {"on_air":true, …}
β†’ 409 {"error":"the live feed is not ready to take β€” no keyframe yet"}

# Hand back to the schedule.
curl -s -X POST $BASE/api/channels/7/live/return -H "Authorization: Bearer $TOK"

What to expect: the take interrupts whatever is playing β€” it does not wait for the current item to end β€” and lands on the next keyframe of the incoming feed, so a long GOP delays it by exactly that long (-g 50 at 25 fps is at most two seconds). The return is immediate, and the schedule resumes from the next item. If the feed stops for five seconds the channel returns to the schedule by itself.

Measured on a channel playing a file at luma 125.6: after the take the output reads 78 / chroma 187 (the live feed), and after the return 125.6 again with segments flowing β€” in and out, both proven from the channel’s own HLS output rather than from a status field.

3 Β· Encode a rundown to an ABR ladder

# Set the channel's quality once; every encode for it uses this.
curl -s -X PUT $BASE/api/channels/7 -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{
       "encode_profile":{"mode":"abr","ladder":[
         {"label":"1080p","height":1080,"video_k":5000,"audio_k":128},
         {"label":"720p","height":720,"video_k":2800,"audio_k":128},
         {"label":"480p","height":480,"video_k":1400,"audio_k":96}]}}'

# Encode everything in the rundown that is not already encoded.
curl -s -X POST $BASE/api/channels/7/encode -H "Authorization: Bearer $TOK"
β†’ {"queued":14,"already_encoded":33,"profile":"ABR 1080p@5000k Β· 720p@2800k Β· 480p@1400k"}

# Watch it. Each job says which machine ran it and on what.
curl -s $BASE/api/encode/nodes -H "Authorization: Bearer $TOK" | jq '.jobs[0]'
β†’ {"id":3,"file_name":"ep12.mp4","state":"encoding","progress":44,
   "node_name":"gpu-1","encoder":"NVENC h264/hevc","gpu_used":true,
   "remote_id":"j1788298013544974007"}

# A failure names its cause; retry when the cause is dealt with.
β†’ {"state":"failed","message":"node reported failed: failed at 1080p: no space left on device"}
curl -s -X POST $BASE/api/encode/jobs/3/retry -H "Authorization: Bearer $TOK"
β†’ {"job_id":9,"message":"ep12.mp4 queued again as ABR 1080p@5000k Β· 720p@2800k Β· 480p@1400k"}

What to expect: queued counts only what was actually sent β€” anything already encoded for these exact settings is skipped, which is why the two numbers rarely add up to the size of the rundown. A partly-encoded file counts as unfinished and is sent again.

4 Β· Constant bitrate for a modulator, and proving it

curl -s -X PUT $BASE/api/channels/7 -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' \
     -d '{"output_mode":"srt","srt_host":"10.0.0.5","srt_port":9000,"cbr_bps":4000000}'

# Measure the real output for up to five minutes. It stops itself.
curl -s -X POST $BASE/api/channels/7/cbr-test -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{"on":true}'
sleep 60
curl -s $BASE/api/channels/7/cbr-test -H "Authorization: Bearer $TOK"
β†’ {"verdict":"conformant","mean_bps":3970000,"target_bps":4000000,
   "criteria":[
     {"name":"padding is being emitted to hold the rate","pass":true,
      "detail":"1.99 Mb/s of padding, 50% of the pipe (programme 1.98 Mb/s)"},
     {"name":"programme and padding together make the target","pass":true},
     {"name":"no transport continuity errors","pass":true}]}

What to expect: a verdict of inconclusive with β€œno bytes left the transport output” means no receiver is attached β€” an SRT listener transmits only while a client is connected. Attach one and run it again.

5 Β· L-shape graphics, and changing the headline on air

curl -s -X PUT $BASE/api/channels/7 -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' -d '{
       "graphics_mode":"composited",
       "graphics":{"headline":"BREAKING","ticker":"…scrolling text…",
                   "bg_color":"#101820","text_color":"#ffffff","accent_color":"#2ea0ff"}}'

# Correcting the text is a live update β€” the stream is not restarted.
curl -s -X PUT $BASE/api/channels/7 -H "Authorization: Bearer $TOK" \
     -H 'Content-Type: application/json' \
     -d '{"graphics":{"headline":"BREAKING β€” CORRECTED","ticker":"…"}}'

What to expect: changing only the headline or ticker reaches air within a frame with no discontinuity. Changing colours, the logo or the layout rebuilds the session, which a viewer sees. The channel needs a composite-capable node; without one it serves the clean programme and says so on the Channels page.

6 Β· The guide, for partners

curl -s $BASE/epg/7                                      # one channel, XMLTV, public
FEED=$(curl -s -H "Authorization: Bearer $TOK" $BASE/api/epg/feed-token | jq -r .token)
curl -s "$BASE/epg/my?token=$FEED"                       # all your channels, XMLTV
curl -s -H "X-API-Key: pgk_…" $BASE/api/app/channels/7   # now_playing and next_up, as JSON

What to expect: one channel's XMLTV (/epg/{id}) is public by design so a partner can poll it without a key; the all-channels feed needs a feed token. Titles come from a file's description when it has one and a cleaned-up filename when it does not β€” a viewer never sees S01E03_FINAL_v2.mp4.

Errors, and what they mean

StatusMeansWhat to do
400The request was understood and is wrong β€” a missing field, an impossible value.The body names the field. Fix it and resend.
401No token, or it has expired.Log in again, or call /api/auth/refresh before the 72 hours are up.
403Authenticated, but not yours.Channels belong to accounts; an operator cannot touch another tenant's.
404No such thing, or it has been cleared.For a job, it has already been removed from the list β€” start it again.
405Right path, wrong method.The response names the method that works and, where relevant, the endpoint you probably meant. (The encode-job routes answer 404 "unknown action" instead.)
409The state does not allow it β€” taking a live feed that is not arriving, changing the graphics mode of a channel on air.The body says which condition failed. (Starting a channel that is already running is not an error: 200 already_running.)
202Accepted but not done yet β€” every node was busy, so the work was queued.Nothing. It starts when a slot frees; the response gives its position.

No endpoint answers 200 for a request that changed nothing. If a call succeeds, it did something, and the body says what.

Channels API

GET /api/channels?summary=1
The fields the channel grid renders and nothing else β€” about a fifth of the full record. Intended for polling: an interface refreshing every few seconds does not need forty fields to draw six. Without the parameter the whole record is returned, unchanged, so anything reading the rest keeps working.
πŸ” Requires auth
POST /api/channels/{id}/reencode
Re-encode every file in this channel's rundown with the channel's current QR code. Set qr_url on the channel, call this, and the code appears on all of its video. Change the URL and call it again for a new code. Each unique file is queued once however many times it appears; files already encoding are skipped. Errors on a channel playing original files, which have nothing to burn a code into.
πŸ” Requires auth Β· gpunode 1.21.0 or later
GET /api/channels/{id}/qr.png?url=…
A preview of the code that would be burned in, so the URL can be checked by scanning it before an encode is spent on it.
πŸ” Requires auth
GET /api/channels
List channels. Admins see all; regular users see only their own. Includes live stats if channel is running.
πŸ” Requires auth
POST /api/channels
Create a channel. Required fields: name. Optional: description, output_mode, srt_mode, srt_host, srt_port, srt_stream_id, srt_passphrase, srt_latency, loop_playlist.
πŸ” Requires auth
GET /api/channels/{id}
Get a single channel with live stats. Secrets are never returned: srt_passphrase_set says whether encryption is configured, and locked_secrets lists any stored secret this server cannot decrypt (its keyring lacks the key it was sealed with). A channel whose passphrase is locked refuses to start rather than send in the clear; re-enter the passphrase, or clear it with clear_srt_passphrase.
πŸ” Requires auth + ownership
PUT /api/channels/{id}
Update channel settings. Only the owner or admin may update.
πŸ” Requires auth + ownership
DELETE /api/channels/{id}
Stop and delete a channel (cascades to playlist items and stats).
πŸ” Requires auth + ownership
POST /api/channels/{id}/start
Start the playout engine for this channel. Calculates schedule position if items have scheduled_at set.
πŸ” Requires auth + ownership
POST /api/channels/{id}/control
Manual control of a RUNNING channel β€” the three moves of a live operator: {"action":"take_next"} ends the airing item now and takes the next rundown item; {"action":"restart_item"} starts the airing item over from its first frame; {"action":"jump","item_id":N} goes straight to item N (in a looping rundown the playlist is repositioned so the run continues cleanly from there; the target must belong to this channel). The interrupted airing is finalised as skipped in the as-run log β€” an operator’s decision, never a failed. Refused with 409 when the channel is not on air. In the rundown page: the Take next and Restart item buttons live on the now-playing banner, and Alt-click on any grid item plays it now. Audited as channel.manual.
POST /api/channels/{id}/stop
Stop the playout engine. Status set to stopped. The channel's original start time is preserved.
πŸ” Requires auth + ownership
GET /api/channels/{id}/stats
Returns playout history (last 50 sessions), error log, and current live stats.
πŸ” Requires auth + ownership
GET /api/channels/{id}/diag
Diagnostic endpoint. Returns playlist item status, file existence check, HLS directory status, and recent errors. Useful for debugging.
πŸ” Requires auth + ownership

Files API

GET /api/files
List media files owned by the authenticated user (admin sees all).
πŸ” Requires auth
POST /api/files
Upload a media file. Use multipart form: field file (MP4/M4V), optional short_desc and long_desc. Max size configured by max_upload_mb. File is probed asynchronously β€” status transitions from processing to ready or error.
πŸ” Requires auth
GET /api/files/{id}
Get file metadata including codec, duration, resolution, and probe status. 3.60.0 adds loudness (per audio track: track, language, lufs, true_peak, lra, and in_spec when a target is set) and conform_note; the list carries them too. A file being converted has status conforming.
πŸ” Requires auth + ownership
POST /api/files/{id}/conform
Convert the file to H.264/AAC MP4, and to the loudness target if one is set (conform, 3.60.0). Answers 202 with conforming, or with why there is nothing to do; 409 while the file is on air. Audited as media.conform.
πŸ” Requires auth + ownership
POST /api/files/{id}/loudness
Measure the file’s loudness again, in the background (202).
πŸ” Requires auth + ownership
GET /api/media/loudness.csv
Every file’s loudness, one row per audio track: file_id, file, track, language, integrated_lufs, true_peak_dbtp, lra_lu, in_spec. An administrator’s covers every account.
πŸ” Requires auth
PUT /api/files/{id}
Update short_desc and long_desc.
πŸ” Requires auth + ownership
DELETE /api/files/{id}
Delete the file record and the uploaded file from disk.
πŸ” Requires auth + ownership
GET /api/files/{id}/stream
Serve the raw MP4 file for browser preview. Supports HTTP Range requests.
πŸ” Requires auth + ownership

Playlist API

GET /api/playlist/{channelID}
Get the full playlist for a channel, ordered by position. Includes joined file metadata (name, duration, codec, etc.).
πŸ” Requires auth + ownership
POST /api/playlist/{channelID}
Add a media file to the playlist. Body: {"file_id": 5, "position": 2, "scheduled_at": "2025-01-15T20:00:00Z"}. Position defaults to end of list. All subsequent items are shifted down to maintain unique positions.
πŸ” Requires auth + ownership
PUT /api/playlist/{channelID}/item/{itemID}
Update a playlist item's scheduled_at and/or position. Pass "scheduled_at": "null" (string) to clear the schedule. Invalid RFC-3339 now returns 400 instead of being silently ignored (v1.4). Position updates reorder the playlist consistently.
πŸ” Requires auth + ownership
POST /api/playlist/{channelID}/schedule_bulk v1.4
Atomically set/clear many items' air times in one transaction. Body: {"items":[{"id":12,"scheduled_at":"2026-07-26T18:00:00Z"},{"id":13,"scheduled_at":""}]} β€” empty string clears (item becomes floating). Any invalid item ID or timestamp rolls back everything (400/500, no partial writes). Returns the full updated playlist. This powers the Rundown's Set & flow, Clear-all, bulk-clear and Undo.
πŸ” Requires auth + ownership
DELETE /api/playlist/{channelID}/item/{itemID}
Remove a single item from the playlist.
πŸ” Requires auth + ownership
POST /api/playlist/{channelID}/reorder
Reorder items. Body: {"item_ids": [3, 1, 4, 2]}. Assigns positions 0, 1, 2, 3 … Any items not included are appended at the end in their current order.
πŸ” Requires auth + ownership
POST /api/playlist/{channelID}/shuffle
Randomly shuffle the playlist order.
πŸ” Requires auth + ownership
POST /api/playlist/{channelID}/clear
Remove all items from the playlist.
πŸ” Requires auth + ownership
POST /api/playlist/{channelID}/random
Clear the playlist and add N randomly selected files from the user's library. Body: {"count": 20}.
πŸ” Requires auth + ownership
POST /api/playlist/item/{id}/trim
Set an item's in and out points without re-encoding: {"trim_in_ms":2000,"trim_out_ms":8000} (see Trim points).
πŸ” Requires auth + ownership
POST /api/playlist/item/{id}/breaks
Set an item's ad breaks: {"ad_breaks":[{"at_ms":600000,"duration_ms":120000}]}; an empty list clears them. Answers with what will actually be signalled: ad_breaks, requested, effective, item_duration_ms (see Ad breaks). Only POST is served under /api/playlist/item/; to move, re-time or remove an entry use /api/playlist/{channelID}/item/{itemID}.
πŸ” Requires auth + ownership

Schedule API

GET /api/schedule/{channelID}?from=RFC3339&to=RFC3339
Returns playlist items that have a scheduled_at within the given time window. Defaults to today through 7 days from now.
πŸ” Requires auth + ownership

Stats API

GET /api/stats/{channelID}
Full stats for a specific channel: session history (last 100), error log (last 50), and current live stats.
πŸ” Requires auth + ownership
GET /api/stream/stats
Server-Sent Events stream (text/event-stream). Emits live stats for all of the authenticated user's running channels every 2 seconds. Admins receive all channels. EventSource cannot set headers, so pass the scoped token from /api/hls-proxy/token as ?token=.
πŸ” Requires auth (token via Authorization header or ?token= query)
GET /api/stream/stats-poll
Polling alternative to SSE. Returns the same filtered live stats as JSON.
πŸ” Requires auth (token via Authorization header or ?token= query)

EPG / XMLTV API

These endpoints generate XMLTV-compatible EPG data for use with Plex, Emby, Jellyfin, Kodi, and other IPTV managers. Per-channel feeds support unauthenticated access so media players can poll without a token.

GET /epg/{channelID}
XMLTV EPG for a single channel. Public access allowed for media player compatibility. If a token is provided and the channel belongs to another user, returns 403.
GET /epg/my?token=FEED_TOKEN
XMLTV for all of the caller's channels (admin sees all). 401 without a feed token (from /api/epg/feed-token) or an account token. /epg with a token is an index page linking every feed.
GET /m3u
M3U playlist of your channels (admins: all). Requires ?token=FEED_TOKEN and answers 401 without it. Channels in hls or both mode get their HLS URL; listener-mode SRT channels get an SRT URL; caller-mode SRT-only channels appear as a comment. Use in VLC, Kodi, Jellyfin, etc. Includes tvg-id, tvg-name, and catchup-source attributes.

Admin API

GET /api/admin/audit
Who changed what, and when. Optional ?limit= (200 default, 2000 maximum), ?user= and ?action= (one action name from the table below). The address recorded is the direct peer, not a forwarded header: a trail recording what the subject claimed about themselves looks authoritative and is not. Retained ninety days.

Every action the trail records. Each entry has a time, the account that acted (empty when the server acted by itself), the direct peer address, a subject and a detail. The test TestEveryAuditedActionIsDocumented keeps this table and the code in step: an action that is recorded but not listed here, or listed here but recorded nowhere, fails the build.

ActionWritten whenSubject
On air
channel.startA channel was started from the console or the API.channel
channel.stopA channel was stopped.channel
channel.deleteA channel was deleted.channel
channel.createA channel was created (3.55.0).channel
channel.udpA channel’s UDP/RTP output was set: on, with its destination, TTL, DSCP and pacing, or off (3.63.0).channel
webhook.createAn alert webhook was added: its URL (without the query) and events (3.63.0).server
webhook.updateAn alert webhook was changed or turned on or off.server
webhook.deleteAn alert webhook was removed.server
webhook.rotateAn alert webhook was given a new signing secret.server
webhook.testA test event was sent to an alert webhook.server
channel.sdiA channel’s SDI output was set: on, with the DeckLink output, format and audio channels, or off (3.62.0).channel
channel.updateA channel’s settings were saved. The detail names the fields that changed — changed: description, srt_passphrase, or saved with no change — never their values, since a channel holds its SRT passphrase and stream key. It compares the channel as stored before and after β€” the encode profile, graphics and DVB service information by what they mean β€” so the console sending its whole form, or a script sending back the channel it read, names only what the save changed (3.55.0).channel
channel.logoA logo was uploaded for the channel (3.55.0).channel
channel.manualManual control: take next, jump to an item, or restart the airing item. The detail says which.channel
channel.live.rekeyThe channel's RTMP stream key was replaced (New key). The old key no longer publishes.channel
live.takenA live feed was taken to air in place of the schedule.channel
live.returnedAn operator handed the channel back to its schedule.channel
live.lostThe feed sent nothing for 5 s and the channel returned to its schedule by itself.channel
scte35.insertBreak now went out at once, with its length.channel
scte35.queuedBreak now had to wait. The detail says why (filler, the slate, a live feed, between items).channel
youtube.goliveA channel's YouTube broadcast was started.channel
Rundown
schedule.item_addedAn item was added to a rundown.channel
schedule.item_removedAn item was removed from a rundown.channel
schedule.reorderedA rundown was reordered.channel
schedule.item_trimmedAn item's trim in or trim out was changed.channel
schedule.ad_breaks_setAn item's ad breaks were set or cleared.item
schedule.fill_randomA rundown was filled at random from the library.channel
schedule.generatedA rundown was generated from the library (Generate), with how many items and whether it replaced the rundown (3.55.0).channel
schedule.importedA schedule file (CSV, BXF, XMLTV) was imported, with what was added, anchored and skipped (3.55.0).channel
schedule.repeatedA day was repeated onto other days (3.55.0).channel
schedule.shuffledA rundown was shuffled (3.55.0).channel
schedule.clearedA rundown was cleared, with how many items it held (3.55.0).channel
schedule.item_updatedAn item’s air time or position was changed (3.55.0).channel
schedule.bulk_timedMany items were given or cleared an air time at once (3.55.0).channel
Traffic
traffic.importA traffic log was imported. The detail gives its source, air date and event count.channel
traffic.generateA day was generated from a template, with the number of problems found.channel
traffic.learnA profile, library or template was learned from a log.channel
traffic.profileA traffic profile was saved.channel
traffic.templateA template was saved.channel
traffic.template.deleteA template was deleted.channel
traffic.library.programA program was saved to the traffic library.channel
traffic.library.interstitialAn interstitial was saved to the traffic library.channel
traffic.library.deleteA library entry was deleted.channel
traffic.assignA house ID was assigned to a media file.channel
traffic.releaseA log was released.channel
traffic.release.acknowledgedA log was released despite integrity errors. The detail carries the reason given.channel
traffic.loadA released log was loaded into the rundown, with what it replaced.channel
traffic.deleteA log was deleted.channel
Media and encoding
media.deleteA media file was deleted.file
media.purgeEncodes or originals were purged from a channel's media, with the space freed.channel
media.collectionFiles were moved into a collection or out of one.file, or a file count
media.collection.renameA collection was renamed.collection
media.fix_doubled_namesDoubled original file names were repaired.media library
media.updateA file’s descriptions, which the guide shows, were edited (3.55.0).file
media.reimportFiles already on disk were imported into the library, with the counts (3.55.0).uploads
media.conformβš™ Conform was asked for a file; the detail says whether it is converting (3.60.0).file
media.reprobeFiles were marked for a fresh probe (3.55.0).media library
encode.allEncode All was pressed. The detail gives the queued, already encoded and not ready counts, and the profile.channel
encode.retryA failed encode was queued again.file
encode.startOne file was sent to encode, or queued (3.55.0).file
encode.cancelA running encode was cancelled (3.55.0).file
encode.dequeueA queued encode was removed from the queue (3.55.0).file
encode.reencodeA channel’s files were queued to re-encode, for example with a new QR code (3.55.0).channel
node.addAn encoding node was added (3.55.0).node URL
node.updateAn encoding node was edited or switched on or off; the detail says when its token was replaced, never the token (3.55.0).node URL
node.removeAn encoding node was removed (3.55.0).node
rendition.repairedOne copy of a rendition failed its checksum and was replaced from the verified copy.rendition
rendition.corrupt_bothBoth copies of a rendition failed their checksum. Neither was copied over the other.rendition
watch.createdA watch folder was added.path
watch.removedA watch folder was removed.folder
watch.pausedA watch folder was paused.folder
watch.resumedA watch folder was resumed.folder
watch.appendedA file dropped into a watch folder was imported and added to its channel's rundown.file
Storage and backups
storage.configThe storage tiers (mirror, S3, failover cache) were changed.tiers
storage.sweepA storage sweep was run by hand.manual
storage.failoverThe primary media directory stopped answering write probes. Reads and ingest continue from the next tier.primary
storage.failbackThe primary storage came back.primary
storage.mirror_downThe mirror stopped answering write probes.mirror
storage.prewarmedAn item airing soon was staged from S3 into the failover cache.file
backup.verifiedA scheduled snapshot passed its full integrity check and was kept.snapshot
backup.verify_failedA scheduled snapshot failed its integrity check.nightly
backup.downloadedA snapshot was downloaded.snapshot
backup.createdA snapshot was taken by hand, and whether it verified (3.55.0).snapshot
backup.restore.beginA restore started.snapshot
backup.restore.refusedA restore was refused because another restore owns the database.snapshot
backup.restore.failedA restore failed. The detail carries the error.snapshot
backup.restore.doneA restore finished. The detail says what was restored.snapshot
Graphics
graphics.compositedComposited output is being served: from the first healthy check, or again at an item boundary after a fallback.channel
graphics.fallbackThe composite node's output went stale, so viewers get the clean programme.channel
graphics.no_nodeNo node can composite this channel's graphics, so clean output is served.channel
graphics.live_updateA headline or ticker was updated on air.channel
Accounts and settings
user.signup.demoA demo account signed up. Its uploads are watermarked.e-mail
user.signupAn account signed up, openly or with an invite code — never the code (3.55.0).e-mail
user.createAn administrator created an account (3.55.0).e-mail
user.updateAn account’s e-mail, role or channel limit was changed, with the old and new values (3.55.0).e-mail
user.deleteAn account was deleted, with its channels and files (3.55.0).e-mail
auth.2fa.setupAn account started enrolling an authenticator app (3.60.0).e-mail
auth.2fa.enableTwo-factor sign-in was turned on; the account’s other sessions end (3.60.0).e-mail
auth.2fa.disableTwo-factor sign-in was turned off by the account (3.60.0).e-mail
auth.2fa.recovery_codesNew recovery codes were made; the old ones stop working (3.60.0).e-mail
auth.2fa.recovery_usedA recovery code was spent, at sign-in or to change two-factor; the detail says how many are left (3.60.0).e-mail
user.2fa.resetAn administrator removed an account’s two-factor sign-in (3.60.0).e-mail
user.password_changedAn account changed its own password; its other sessions end (3.55.0).e-mail
auth.resetThe administrator’s credentials were reset with the recovery secret, the one way in without a session (3.55.0).e-mail
invite.createAn invite code was created, with its limits — never the code (3.55.0).invite
invite.updateAn invite code was revoked or re-enabled (3.55.0).invite
invite.deleteAn invite code was deleted (3.55.0).invite
appkey.createAn application key was created; the detail carries its prefix, never the key (3.55.0).name
appkey.revokeAn application key was revoked (3.55.0).key
user.quotaA user's storage quota was set.e-mail
user.suspendAn account was suspended, with the reason.e-mail
user.restoreA suspended account was restored.e-mail
user.demo.appliedAn account was made a demo account.e-mail
user.demo.liftedAn account stopped being a demo account.e-mail
youtube.connectedA YouTube account was connected.account
youtube.disconnectedThe YouTube account was disconnected.—
settings.local_gpusWhich of this server's cards encode was changed. The detail gives the old value.new value
settings.local_gpu_decodeGPU decoding and scaling for local encodes was turned on or off (3.54.0).on or off
settings.local_gpu_slotsEncodes per card was changed, with the old value (3.55.0).new value
settings.cdn_urlThe server’s CDN base was changed, with the old one (3.55.0).new base
settings.encode_fallbackThe encode fallback policy was changed, with the old one (3.55.0).new policy
settings.require_invite_codeSignup was opened or closed to accounts without an invite code (3.55.0).on or off
settings.user_cdnAn account’s own CDN base was set (3.55.0).e-mail

Every change is recorded 3.55.0

Since 3.55.0 every request that changes state is in the trail, and a second test, TestEveryStateChangeIsAudited, reads the code to keep it so: a handler, or one branch of its switch, that writes to the database, config.json, the encode queue, the engines or a file must record an entry, or be listed in the test with the reason it does not. Listed today: uploads (the file records its owner and time, and deleting one is audited), and the QR-code preview and the app grid’s thumbnail, which only render images. Sign-ins and session renewals change no setting and are not recorded.

πŸ” Administrator only
GET /api/admin/backups
What database snapshots exist and how old the newest is. The newest is opened and read to confirm it is restorable, and reported as verified with what it holds. A warning appears when the newest is older than expected β€” a backup system that has silently stopped looks exactly like one that is working.
πŸ” Administrator only
POST /api/admin/backups
Take a snapshot now, before a risky change. Written from inside the database connection rather than copied, verified before it is kept, and discarded if it cannot be read.
πŸ” Administrator only
POST /api/admin/backups/restore
Restore the database from a snapshot: {"name":"playout-…​.db","confirm":"RESTORE"}. The snapshot is verified before anything is touched, the current state is snapshotted first (…​-prerestore.db, so a restore is itself restorable), every channel is stopped, journal debris is removed, the current file is set aside, and channels the restored database marked as running are resumed. Every failure path rolls back to the previous database. The confirmation string is deliberate friction β€” this endpoint replaces the entire database, and a misclick must not be enough.
πŸ” Administrator only
GET /api/admin/backups/download
Download a snapshot: ?name=playout-…​.db. Names are validated by shape, not by enumeration, so nothing else in the directory can be fetched. An off-box copy is the difference between backups and backups that survive the box. Audited.
πŸ” Administrator only
POST /api/admin/users/{id}/suspend
Body: {"suspended": true, "reason": "..."}. Stops the account's channels immediately and refuses to start them again, with the reason shown to whoever tries. Nothing is deleted β€” lifting the suspension brings the account back as it was. Enforced in the start path, so it holds across a restart. An administrator cannot suspend themselves or another administrator.
πŸ” Administrator only
GET /api/admin/settings
Whether registration requires an invite code. GET returns require_invite_code and how many usable codes exist; PUT {"require_invite_code": true} closes registration. Turning it on with no usable code is refused β€” that would leave nobody able to sign up and no way to invite them. Registration is open by default, which is right for an installation on a private network and wrong for one reachable from outside it.
πŸ” Administrator only
GET /api/admin/sysinfo
Host figures: disk, memory, load, and the encoded and upload directory sizes.
πŸ” Administrator only
GET /api/admin/delivery
The runtime delivery and encoding settings: CDN base, encode_fallback, the local encoder and NVIDIA cards, watch folders and encoded_dir. POST changes cdn_url, encode_fallback, local_gpus, local_gpu_slots and local_gpu_decode (3.54.0) without a restart.
πŸ” Administrator only
GET /api/admin/watch
Watch folders. POST to add one, DELETE /api/admin/watch/{id} to remove it. Media dropped into a watch folder is imported and probed automatically.
πŸ” Administrator only
GET /api/admin/invites
Outstanding invitations. POST to issue one, DELETE /api/admin/invites/{id} to revoke it.
πŸ” Administrator only
GET /api/admin/probe-status
Progress of media probing, for files still being examined after upload.
πŸ” Administrator only
POST /api/admin/usercdn
Sets one account's CDN base, so each customer's playback URLs carry their own domain: {"user_id":7,"cdn_url":"https://cdn.example.com"}; an empty value clears it. There is no GET β€” the value is on the user record.
πŸ” Administrator only

All admin endpoints require both authentication and the admin role.

GET /api/admin/users
List all user accounts.
πŸ” Admin only
PUT /api/admin/users/{id}
Update a user's email, role (required: admin or user), max_channels limit and disk_quota_bytes (0 = unlimited for both). There is no password field, and you cannot demote yourself.
πŸ” Admin only
DELETE /api/admin/users/{id}
Stops the user's channels and deletes the account, its rows, and its files on disk (uploads/user_<id>/ and its renditions). Returns freed_bytes and anything not_removed. You cannot delete your own account.
πŸ” Admin only
GET /api/admin/channels
List all channels across all users with owner info.
πŸ” Admin only
GET /api/admin/stats
System-wide stats: total_users, total_channels, running, total_files.
πŸ” Admin only
POST /api/admin/reimport
Register MP4/M4V files that are on disk under uploads but missing from the database. The owner comes from the user_<id> folder, otherwise the caller; new records are probed automatically. (Admin β†’ Scan & Re-import Missing Files.)
πŸ” Admin only
POST /api/admin/fixstatus
Re-probe media marked error, stuck in processing, or ready with no duration. Progress: GET /api/admin/probe-status. 409 if a re-probe is already running. (Admin β†’ Re-probe Broken Files.)
πŸ” Admin only

SRT Output

SRT (Secure Reliable Transport) provides low-latency, resilient TS delivery over UDP. PlayoutGo supports both caller and listener modes.

Caller mode (default)

PlayoutGo dials out to your ingest server. Configure your ingest to listen on the specified host/port.

# Nimble Streamer β€” listen on port 9000, stream ID "ch1"
srt://0.0.0.0:9000?streamid=ch1&mode=listener

# VLC β€” listen on all interfaces
vlc srt://:9000

Listener mode

PlayoutGo listens on the specified port. Your ingest server or VLC calls in.

# VLC calls in to PlayoutGo listener
vlc srt://your-server:9000?mode=caller&transtype=live&streamid=ch1&latency=200000

# Flussonic ingest
srt://your-server:9000?mode=caller&transtype=live&streamid=ch1

Connecting a player (listener mode) v1.4

The listener routes by exact stream-ID match. Clients with an empty or wrong stream ID are rejected (REJ_PEER) so a stray puller can never grab a channel's output slot. Use this exact form β€” the URL parameter is streamid= and latency is in microseconds:

ffprobe -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=YOUR_STREAM_ID&latency=200000"
vlc "srt://SERVER:9000?mode=caller&transtype=live&streamid=YOUR_STREAM_ID&latency=200000"
Single consumer: each channel serves one SRT client at a time. A second client is rejected until the first disconnects. Use HLS for multiple simultaneous viewers.
Clean join: a newly connected client receives nothing until the next keyframe, so the stream always starts on an IDR with SPS/PPS β€” expect up to one GOP of delay before first frame, and no "non-existing PPS" decoder errors.
Log hint: when a listener starts, the server logs the full ready-to-paste connect URL.

SRT parameters

ParameterDefaultDescription
srt_modecallercaller (dial out) or listener (accept connections)
srt_hostlocalhostTarget host (caller mode) or bind address (listener mode)
srt_port9000UDP port number
srt_stream_idSRT stream ID for multiplexed ingest servers
srt_passphraseAES encryption passphrase (10–79 chars)
srt_latency200Target latency in milliseconds (ARQ buffer)
srt_max_bw-1Maximum bandwidth in bytes/sec (-1 = unlimited)

HLS Output

HLS segments are written to hls_dir/{channel-slug}-{id}/. The playlist is available at:

http://your-server:8700/hls/{channel-slug}-{id}/index.m3u8

Segments are keyframe-aligned MPEG-TS files: a segment closes on the first keyframe at or after 4 seconds β€” there is no hard cap: with sparse keyframes a segment simply runs long, and the log warns once after 30 s with no keyframe β€” so every segment begins with an IDR carrying SPS/PPS and is independently decodable β€” a requirement for Safari's native HLS. The rolling window keeps the last 6 segments; an atomic rename ensures players always see a consistent playlist.

The master playlist 3.54.0

A channel's directory holds:

main.m3u8The media playlist: video, every language muxed in (the main one first), the cue tags. Always written.
index.m3u8A master playlist when the channel has more than one language or carries captions; otherwise the same media playlist as main.m3u8, exactly as before 3.54.0. On an ABR channel, the variant set's master.
audioN.m3u8, aN-seg….tsExtra language N as an audio-only rendition, cut at the same instants, with the same sequence numbers and discontinuities as the variant, so a player can switch language at any boundary.
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",LANGUAGE="eng",DEFAULT=YES,AUTOSELECT=YES
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="EspaΓ±ol",LANGUAGE="spa",DEFAULT=NO,AUTOSELECT=YES,URI="audio1.m3u8"
#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",NAME="CC1",INSTREAM-ID="CC1",DEFAULT=YES,AUTOSELECT=YES
#EXT-X-STREAM-INF:BANDWIDTH=474512,RESOLUTION=1280x720,CODECS="avc1.64001f,mp4a.40.2",AUDIO="aud",CLOSED-CAPTIONS="cc"
main.m3u8

What the master states, measured 3.55.0

Every EXT-X-STREAM-INF line states what its variant carries now, read from the segments still in the playlist window, so a player choosing a variant chooses on the truth:

ABR channels. Each rung's line is measured from that rung's own segments the same way, and the master is rewritten when a figure changes. Until a rung has written a segment its line carries the encode profile's figures (height Γ— 16:9, the profile's bitrates, avc1.640028), so the master is complete from the start. With extra languages, every rung's BANDWIDTH adds the top rung's largest audio rendition. Until 3.55.0 the ABR master stated the profile's figures for the whole run.

Consistent audio for mixed content v1.4

Video-only files (no audio track) automatically receive a synthesized silent 48 kHz stereo AAC-LC track covering the video duration. Files with real audio pass through untouched. Result: the HLS variant presents one stable audio layout across a mixed playlist β€” without this, strict players (Safari, tvOS) reject the whole stream on seeing an audio track with 0 channels. Discontinuity tags are emitted at every file boundary.

Safari testing tip: Safari caches HLS aggressively. After server-side changes, verify in a private window or with a cache-buster (index.m3u8?v=2), or open the URL in QuickTime Player (File β†’ Open Location).
Performance tip: Set hls_dir to /dev/shm/playout_hls (tmpfs). This eliminates disk I/O for segment writes and keeps latency low. Segments are ephemeral β€” they are recreated on restart.

XMLTV / Plex Integration

Add your channels to Plex DVR, Emby, or Jellyfin using the XMLTV feed:

  1. In Plex: Settings β†’ Live TV & DVR β†’ Add EPG Source β†’ enter http://your-server:8700/epg/{channelID}
  2. In Emby/Jellyfin: Dashboard β†’ Live TV β†’ Add β†’ XMLTV β†’ enter the same URL
  3. Programme data comes from the playlist items' short_desc / long_desc and scheduled_at fields

Trim Points v1.38

Play only part of a file without re-encoding it. Useful for topping and tailing a recording, dropping a slate, or cutting a programme down to a slot.

In the Rundown, each row has a β‹― button. Set an in point (seconds from the start of the file) and an out point; either can be zero to mean "from the beginning" or "to the end". A trimmed row shows its shortened length with a βœ‚ marker, and its original length on hover.

POST /api/playlist/item/{id}/trim
{ "trim_in_ms": 2000, "trim_out_ms": 8000 }     β†’ plays 6 seconds
The schedule reflows automatically. Air times, the EPG, the App API and the day's total all use the trimmed length. This matters more than it sounds: if any one of them used the file's full length instead, every item after a trimmed one would drift.

What is refused

How it works

The in point is a seek, which costs nothing. The out point stops the item once it has played its trimmed length, measured from its own first frame. Frame-accurate to the sample; there is no re-encoding, so a trim is instant however long the file is.

Trim points survive a day repeat β€” a copy that dropped them would quietly change what airs.

Repeating Days v1.38

Most channels run the same shape every weekday, with a different weekend. Rebuilding that by hand is the most repetitive job in the interface, so πŸ” Repeat Day in the Rundown copies one day onto others.

Pick the day to copy, then say where it goes:

6the next six days
weekdays 4the next four weekdays, skipping the weekend
2026-08-12,2026-08-13exactly those dates

Anchored times shift by whole days, so a programme at 18:00 stays at 18:00. Order and trim points are preserved. You are then asked whether to replace whatever is already on the target days or add alongside it.

POST /api/playlist/{channelID}/repeat
{ "source_date": "2026-08-10",
  "target_days": ["2026-08-11","2026-08-12"],
  "replace": true, "keep_times": true }
A day can only be repeated if its items have air times. A pure loop rundown has no days to copy, so the operation is refused rather than guessing.

Capped at 62 days per operation β€” enough for two months, low enough that one click cannot generate thousands of rows by accident.

Watch Folders v1.38

Content usually arrives by rsync, SFTP or a mounted share rather than through a browser. A watch folder imports whatever lands in it, so an operator can drop files and find them in the media list.

Add one in Admin β†’ Delivery & Encoding β†’ Watch Folders. The path is checked when you add it, so a typo or an unmounted share is reported immediately rather than silently doing nothing forever. Folders can be paused and resumed, and a new folder starts being watched within one poll β€” no restart.

The panel shows each folder's state, how many files it has imported, and when it was last scanned, so you can tell at a glance whether it is alive.

Folders declared in config.json as watch_folders continue to work, so existing installations are unaffected; they cannot be paused from the interface.

pathDirectory to watch. Must be readable at startup or it is skipped with a log line.
user_idWhich account owns the imported media.
movetrue removes the source after import β€” recommended, since a folder that keeps its files grows without limit and every poll has to re-check them.

Why polling, not inotify

Watch folders are very often NFS or CIFS mounts, where inotify silently does not fire for changes made by another host β€” the folder appears to do nothing and there is no error to find. A poll always works. Thirty seconds is the default; anything under ten is ignored.

Half-written files

A file is imported only once its size has stopped changing between two polls. Without that, a file still being uploaded is imported half-written, probes as broken, and needs manual cleanup. This means a file appears roughly one poll interval after it finishes copying, which is the intended trade.

Only .mp4 files are considered. Dotfiles are ignored, since many tools write .name.mp4 while uploading. A name already in the library is skipped, so a folder left in place is not re-imported on every poll.

Encoding & Quality Modes v1.32

PlayoutGo remuxes rather than transcodes β€” that is why one modest server runs dozens of channels at a fraction of a percent of CPU each. Encoding is therefore something you do to the library, once, offline. All three modes below keep the playout path itself free of any encoder.

The three modes

ModeWhat it doesChoose it when
Original files
default
Nothing. Files play exactly as uploaded.Your content is already H.264/AAC and reasonably consistent. No GPU, no extra storage, lowest cost per channel.
Single bitrateConforms every file to one rendition β€” height, video and audio bitrate all adjustable.Your library is a mix of resolutions and frame rates. Conforming once removes the discontinuity at every item transition and lets players keep one decoder configuration.
ABR ladderSeveral renditions from one master, frame-aligned. Defaults to 1080p/720p/480p; add, remove or retune any rung.You are feeding a packager or players that switch quality. Costs roughly 1.5Γ— the storage of 1080p alone.
Encoding is optional and off by default. Adding it does not make playout more expensive β€” the encode happens once, ahead of time: on an encode node, on this server's NVIDIA cards, or on its CPU if encode_fallback allows. What it costs is storage and a GPU node.

Setting a mode

Open a channel's settings and pick under Source quality. Single-bitrate exposes height and bitrates; ABR shows the ladder with a row per rendition plus the segment geometry.

Segment and keyframe seconds default to 6 and 2. The segment length must be a whole multiple of the keyframe interval, or segments cannot start on a keyframe and players cannot switch between renditions cleanly β€” the interface corrects an impossible combination rather than accepting it.

Encoding nodes

gpunode 1.1.0 or newer is required. Earlier builds return HLS whatever is asked for, and the playout engine reads MP4 β€” the encode would succeed and produce something unplayable. An older node is refused before any work is sent, and shown in red in the panel with its version.

Encoding runs on one or more gpunode servers and, since 3.51.0, on this server's own NVIDIA cards (see below). Add nodes in Admin β†’ Encoding Nodes β†’ + Add Node: URL, a name, and the token gpunode was started with. The node is tested before it is saved, so a wrong URL or token is reported immediately rather than at the first encode β€” and if the node answers but has no usable GPU, you are told that too.

Each node can be tested, edited, disabled or removed from the same panel. Disabling is the safe way to take a node out of rotation: work already running on it finishes, and nothing new is sent.

The token is stored server-side and never sent back to the browser. Editing a node with the token field left blank keeps the stored one.

Nodes declared in config.json as gpu_nodes still work, so an existing install keeps running after an upgrade; new ones are easier to manage in the interface.

Admin β†’ Encoding Nodes shows each node's encoder (NVENC or CPU), slots in use, free disk and availability, plus recent encode jobs with live progress. Node health is polled concurrently, so one unreachable node does not stall the page.

Frame alignment. PlayoutGo asks gpunode for keyframes on exact time boundaries, so every rendition places them at identical presentation times. That is what makes an ABR ladder switchable; a fixed frame-count GOP only aligns at some frame rates.

Encoding on this server's own NVIDIA cards v3.51

A playout server with NVIDIA cards of its own can encode on them. Each card becomes an encode node in the same pool as the remote nodes, named local://gpu/0, local://gpu/1 and so on. It is chosen by the same rule β€” the least-loaded node with a free slot takes the job β€” and it produces the same output a node does: the same MP4 layout, keyframes at the same times, captions, the channel's QR code and, for a constant-bitrate channel, a constant rate. A file encoded on a card and one encoded on a node are interchangeable.

What counts as a working card. PlayoutGo asks nvidia-smi which cards exist, then runs a one-frame NVENC test encode on each card through this server's ffmpeg. Only a card whose test succeeded takes work: a card that is listed but whose driver is too old for this ffmpeg, or which is out of encoder memory, fails only when an encode starts, and would fail every job sent to it. The list is read again every minute. A card whose test failed is tested again after ten minutes, so a driver fixed in the meantime is noticed without a restart.

Sessions held by other programs count. NVENC sessions opened by other processes on the same card β€” a compositor, a monitoring tool, another encoder β€” count against that card's slots. A consumer card refuses sessions past its driver limit rather than queueing them, so a card with three foreign sessions and three slots takes nothing.

A card that fails mid-encode. When an encode fails in a way that points at the device rather than the source β€” the CUDA library cannot be loaded, the encoder cannot open a session, the device has gone β€” the card is parked for five minutes and the file goes back to the queue, where another card or node takes it. The attempt that ended is listed as sent back to the queue, with the reason. A failure caused by the source (a corrupt file) fails the job as it would on a node. An encode running on a card when the server stops is sent back to the queue when it starts again.

Settings

In Admin β†’ 🌐 Delivery & Encoding β†’ This server's NVIDIA GPUs, or in config.json. A value saved in Admin takes effect at once and wins over the file.

KeyValues
local_gpus"auto" (default) β€” every card that passes its test Β· "off" β€” none Β· card numbers such as "0,2" β€” only those. Anything else is refused at start-up and in Admin, with the reason.
local_gpu_slotsEncodes each card runs at once: default 2, at most 8. NVENC throughput per card rises little past two or three sessions, and unless local_gpu_decode is on, decoding and scaling run on this server's processor, which the channels on air share β€” keep it low on a busy server.
local_gpu_decodefalse (default) or true: decode and scale on the card as well (3.54.0, below). In Admin: Decode and scale on the card.

Decoding and scaling on the card 3.54.0

By default a local encode decodes and scales on this server's processor and only encodes on the card. With local_gpu_decode on, the card does all three β€” -hwaccel cuda -hwaccel_output_format cuda -hwaccel_device N, then scale_cuda=-2:H:format=nv12 β€” so frames never leave its memory and the processor the channels on air share does neither.

Admin lists every card with its model, memory and driver, and its state: ready, not in the list, the test encode's own error, parked (until when, and why), and any sessions held by another process. Until the first detection has finished it says it is still looking rather than that there are none. In Admin β†’ Encoding Nodes the cards are marked this server; they have no Test, Edit or Remove, since there is nothing to configure per card, and a job running on one says which card.

Not measured on real cards in this release. The detection, the pool, the parking and the arguments ffmpeg is given are tested with stand-in nvidia-smi and ffmpeg programs, and the encodes themselves with ffmpeg's software encoder. The NVENC arguments (-c:v h264_nvenc -gpu N -preset p5, and for CBR -rc cbr with -maxrate = -bufsize = the target and -strict_gop 1) have not been run on an NVIDIA card by this build's tests. The ffmpeg in the test environment (6.1) has no NVENC filler-data option, so a constant rate on a card is the encoder's own rate control; where this server's ffmpeg lists such an option it is used, and Admin shows which.

Running an encode

The usual workflow is per channel: set the channel's Source quality, build its rundown, then press πŸŽ› Encode All in the Rundown. Every distinct file is encoded with that channel's settings β€” a file used ten times is encoded once β€” and files already encoded for those exact settings are skipped, so pressing it again after adding content only does the new work.

For one-off jobs, each ready file on the Media tab also has a πŸŽ› Encode button.

The same file in two channels

A file can be in one channel at 720p 2000k and another at 720p 2800k. Those are different renditions and both are kept: renditions are identified by height and bitrate, and each channel plays the one matching its own settings. Originals are never modified or replaced β€” encoding only ever adds files alongside them.

If a channel asks for a rendition that does not exist, the nearest height is used, and if there is none at all the original plays. Enabling a mode can never take a channel off air.

Original files are always kept

On the Media tab, each ready file has a πŸŽ› Encode button. Choose which channel's settings to use β€” so what is encoded matches what will air β€” or take a default 720p single-bitrate encode. Progress appears in Admin β†’ Encoding Nodes, where a running job can be cancelled and a finished one cleared from the list.

API

GET    /api/encode/nodes             node health, defaults and recent jobs
GET    /api/encode/nodelist          managed nodes (never includes tokens)
POST   /api/encode/nodelist          add, edit, or test  { "test": true }
DELETE /api/encode/nodelist/{id}     remove a node
POST   /api/encode/start             { "file_id": 12, "channel_id": 3 }
POST   /api/encode/jobs/{id}/cancel  stop a running encode
POST   /api/encode/jobs/{id}/retry   encode this file again with the same settings
DELETE /api/encode/jobs/{id}         clear a finished job

POST   /api/channels/{id}/live/take    cut the RTMP feed to air
POST   /api/channels/{id}/live/return  hand back to the schedule
POST   /api/channels/{id}/live/rekey   issue a new random stream key (refused on air)
GET    /api/channels/{id}/live         is a feed arriving, and is it fit to take
GET    /graphics/{id}.html             a channel's branding, for a compositor to render
                                       (sandboxed opaque origin; moves to graphics_port when set)

When no encoding node is available

Nodes go down, fill up, or get drained for maintenance. What should happen then is a policy decision, so it is one you make rather than one PlayoutGo guesses. Set encode_fallback in config.json:

SettingBehaviour
fail
default
Stop and notify. Nothing is encoded on the playout host, so an encoding problem can never affect channels on air. The failure is recorded as an alert in Admin β†’ Encoding Nodes.
local_gpuEncode here, but only if this server has a working GPU encoder. If it does not, stop β€” rather than quietly dropping to software.
local_cpuEncode here on the CPU. Correct for a small library or an idle host; on a busy playout server a software encode competes directly with channels on air.

Admin β†’ Encoding Nodes states which policy is active, what this server could actually do (GPU, CPU, or nothing if ffmpeg is absent), and warns when a fallback would land on the CPU. Recent alerts are listed there too, so a node that disappeared overnight is visible in the morning rather than only in the log.

A local encode produces exactly what a node produces β€” the same MP4 layout, the same time-forced keyframes β€” so renditions are interchangeable and nothing downstream can tell which machine made them.

Adaptive delivery v1.64

An ABR channel serves a real variant set. Every rung of the ladder is segmented in parallel, and the channel's playlist is a master listing them, so a player picks a rung and changes it as its bandwidth does.

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-STREAM-INF:BANDWIDTH=2928000,RESOLUTION=1280x720,CODECS="avc1.640028,mp4a.40.2"
720p-1/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1496000,RESOLUTION=852x480,...
480p-1/index.m3u8

The rungs are frame-aligned by construction β€” every rendition has keyframes forced at the same instants β€” so the segmenters cut together and a player can switch at any boundary without a gap.

The whole ladder must be encoded first. A master listing a rung with no segments makes a player stall when it switches to it, so an incomplete ladder is refused and the channel airs a single rendition until every rung exists. Run Encode All on the rundown, then start the channel.

To check a channel, fetch its playlist: #EXT-X-STREAM-INF lines mean variants; only #EXTINF means one bitrate.

Since 3.55.0 each rung's RESOLUTION, CODECS and BANDWIDTH are measured from its own segments, the profile's figures standing in until it has one (what the master states).

ABR items in full 3.54.0

Until 3.54.0 an item airing as an ABR ladder was a lesser item: no breaks, no languages but the main one, no live take until it ended, no as-run row, and rungs that went silent between ABR items. Now:

What actually goes to air

Once a file has been encoded, the channel plays the rendition rather than the original. Selection is deliberately forgiving:

Renditions are requested from the node as MP4, not HLS: the playout engine opens files and does its own segmenting, so a playlist and a pile of fragments would be no use to it. Deleting a media file removes its renditions too.

What this does not do

CDN Delivery v1.36

The playout host writes HLS segments; in production it should not also be serving viewers. Set a CDN base and every viewer-facing URL is rewritten to point at it β€” the channel list, the M3U playlist, the XMLTV guide and the App API all move together, which is the point: editing links by hand gets some of them wrong.

Set it in Admin β†’ Delivery & Encoding. It takes effect immediately β€” no file to edit, no restart. A per-channel override lives in the channel's own settings, for a channel on a different edge or a customer's own CDN.

Admin β†’ Delivery & Encoding β†’ CDN base URL
    https://cdn.bozztv.com

It can also be set in config.json as cdn_url; the interface setting wins if both are present.

Which CDN delivers a channel

Resolved most-specific-first, so a platform with many customers sets each one once:

ChannelIts own CDN field, in the channel dialog. Wins over everything.
AccountAdmin β†’ Users β†’ 🌐 CDN. Every channel that account owns is delivered from here. This is the level a reseller platform wants: set it once per customer rather than on every channel.
GlobalAdmin β†’ Delivery & Encoding.
Nothing setServed from this origin.

A path prefix is fine β€” https://183.bozztv.com/utopia produces https://183.bozztv.com/utopia/hls/<channel>/index.m3u8 β€” since many CDNs put an origin under a path.

Or per channel, in its settings, which overrides the global value. A channel on a different edge or a customer's own CDN needs no separate deployment.

https://cdn.bozztv.com/hls/<channel-slug>/index.m3u8

Give only the base. PlayoutGo appends the path itself, so a value containing /hls/, ending in .m3u8, or carrying a query string is refused rather than producing links that are subtly wrong.

Only delivery URLs are rewritten. The control API stays on the origin: a CDN in front of an authenticated API either caches responses it must not cache, or needs rules that are easy to get wrong. Point the CDN's origin at this server, and cache /hls/ only. The origin sends Cache-Control: no-cache, no-store on every playlist and segment, so a CDN that honours origin headers caches nothing: add an edge rule that caches .ts segments for about 60 s and index.m3u8 for 1–2 s.

With no CDN configured, URLs fall back to the origin the request arrived on, so nothing needs changing for a single-server install.

App API β€” Building Client Applications v1.31

A read-only JSON API for client applications: Roku, Fire TV, tvOS, Android TV, mobile apps, web grids, partner integrations. One call returns everything an app needs to draw a channel grid β€” names, descriptions, stream URLs, live thumbnails, now-playing and schedule.

1 Β· Get a key

Client apps cannot use the interface's login token: it expires after 72 hours and a television app has no way to re-authenticate a person. Instead, create an app key under πŸ“‘ EPG β†’ App API Keys. Keys are long-lived, read-only, scoped to your channels, and individually revocable.

Key format:  pgk_1a2b3c…       (32 bytes of entropy, hex-encoded)

Pass it either way β€” use the header in production so keys stay out of logs and browser history:

GET /api/app/channels?key=pgk_…
GET /api/app/channels          with header  X-API-Key: pgk_…
Revoking is immediate. If a key leaks, revoke it and issue a new one; only that application breaks. Issue one key per app so you can revoke precisely.

2 Β· The channel list

GET /api/app/channels?key=pgk_…
GET /api/app/channels?key=pgk_…&live=true      # only channels currently on air
{
  "generated_at": "2026-07-29T13:59:18Z",
  "count": 1,
  "channels": [
    {
      "id": 1,
      "name": "App Demo",
      "description": "Demo channel for apps",
      "status": "running",
      "live": true,

      "stream_hls": "https://utopia.bozztv.com/hls/app-demo-1/index.m3u8",
      "stream_srt": "srt://utopia.bozztv.com:9400?mode=caller&transtype=live&streamid=appdemo&latency=200000",

      "thumbnail_url": "https://utopia.bozztv.com/api/app/channels/1/thumbnail.jpg?t=…&e=1767225600",
      "logo_url":      "https://utopia.bozztv.com/api/app/channels/1/logo?t=…&e=1767225600",
      "epg_url":       "https://utopia.bozztv.com/api/app/channels/1/epg?key=pgk_…",
      "epg_xmltv_url": "https://utopia.bozztv.com/epg/1",

      "now_playing": { "title": "Morning Show", "duration_ms": 1800000, "progress_pct": 42.5, "elapsed_ms": 765000 },
      "next_up":     { "title": "Weather", "starts_at": "2026-08-01T18:30:00Z", "ends_at": "2026-08-01T18:40:00Z", "duration_ms": 600000 },

      "width": 1920, "height": 1080, "fps": 25
    }
  ]
}
FieldUse it for
stream_hlsWhat you hand to the player. Absent if the channel is SRT-only.
stream_srtOnly for listener-mode SRT channels, and only useful to SRT-capable clients. Remember SRT here is single-consumer β€” one client at a time. Consumer apps should use HLS.
liveWhether the channel is on air right now. Grey out or hide the tile when false.
progress_pctDraw a progress bar on the now-playing tile.
width/height/fpsOf the item currently airing β€” useful for choosing a player profile.

Scoping: a key sees its owner's channels; an admin's key sees all of them. Responses are sent private, no-store (varying on the key), so no proxy serves one tenant's list to another. Poll every 10 s. thumbnail_url and logo_url carry a six-hour token for that one resource (?t=…&e=…), not your key; after it expires the URL answers 401 "this link has expired β€” reload the channel list". logo_url is present only when a logo has been uploaded.

3 Β· One channel

GET /api/app/channels/{id}?key=pgk_…

Same object as above for a single channel. Requesting a channel you do not own returns 403.

4 Β· Schedule (JSON EPG)

GET /api/app/channels/{id}/epg?key=pgk_…&limit=20
{
  "channel_id": 1,
  "channel_name": "App Demo",
  "count": 3,
  "programmes": [
    { "title": "Morning Show", "starts_at": "2026-08-01T18:00:00Z", "ends_at": "2026-08-01T18:30:00Z", "duration_ms": 1800000 },
    { "title": "Weather",      "starts_at": "2026-08-01T18:30:00Z", "ends_at": "2026-08-01T18:40:00Z", "duration_ms": 600000 }
  ]
}

Times follow the same flow model the operator sees in the Rundown: anchored items pin the clock, floating items chain after by duration β€” so your app shows exactly what the schedule shows. Items with no known duration have no starts_at; treat those as "unscheduled" rather than guessing.

XMLTV remains available at /epg/{id} for Plex, Jellyfin, Emby and IPTV clients that expect it. Use the JSON form for your own apps β€” it is far less work to parse on a TV device.

5 Β· Thumbnails and logos

GET /api/app/channels/{id}/thumbnail.jpg      # X-API-Key header, or the ?t=…&e=… URL from the list
GET /api/app/channels/{id}/logo

6 Β· Worked example: a channel grid

const KEY  = 'pgk_…';
const BASE = 'https://utopia.bozztv.com';

async function loadGrid() {
  const res = await fetch(BASE + '/api/app/channels?live=true', {
    headers: { 'X-API-Key': KEY }
  });
  const { channels } = await res.json();

  return channels.map(ch => ({
    id:        ch.id,
    title:     ch.name,
    subtitle:  ch.now_playing ? ch.now_playing.title : 'Off air',
    image:     ch.thumbnail_url,     // carries a 6-hour token, not the key; reload the list to refresh it
    playUrl:   ch.stream_hls,
    progress:  ch.now_playing?.progress_pct ?? 0
  }));
}

// Refresh every 10s β€” matches the server's cache window.
setInterval(loadGrid, 10000);

7 Β· Platform notes

8 Β· Errors and etiquette

401Missing or invalid key β€” it may have been revoked. Do not retry in a loop.
403The key does not own that channel.
404Channel or thumbnail unavailable. The body explains which.
Polling10 s for the channel list, 30 s for EPG. Responses are not cacheable; thumbnails are regenerated server-side at most once per 10 s per channel. Faster gains nothing and only adds load.
Security: the API is read-only β€” an app key cannot start channels, change schedules, upload media or read other tenants' data. Stream URLs themselves are unauthenticated (players need them that way); thumbnail and logo URLs carry a six-hour token derived from your key, and only epg_url carries the key itself, so treat channel availability as public and use the key to control who can enumerate and describe your channels.

M3U Playlists

v1.5: M3U and EPG pages now emit the canonical HLS path /hls/<slug>-<id>/index.m3u8 β€” identical to what the UI shows. (Bare-slug URLs still resolve for backward compatibility.)

The M3U feed at /m3u?token=FEED_TOKEN lists your channels (admins: all) with their HLS or SRT URLs. Copy it from the EPG tab, which fills in your feed token. Import in VLC, Kodi, or any IPTV app:

vlc "http://your-server:8700/m3u?token=FEED_TOKEN"

HLS Proxy

The CORS proxy at /api/hls-proxy?url=... fetches external HLS streams through the server, solving browser CORS restrictions. It rewrites all segment and key URLs to also go through the proxy. It requires authentication β€” a header, or ?token= with the six-hour scoped token, which is carried into every rewritten URL β€” and fetches only ordinary public unicast addresses (or the hosts in hls_proxy_allowed_hosts).

Security: The proxy blocks all RFC-1918, loopback, and link-local addresses to prevent SSRF attacks. It will not fetch localhost, 10.x.x.x, 192.168.x.x, or 172.16.x.x resources. Configure hls_proxy_allowed_hosts in config.json to restrict which external hosts may be proxied.

Admin Guide v1.5

Everything on this page requires an admin account. The πŸ›‘ Admin tab is hidden for regular users, and every /api/admin/* endpoint returns 403 for non-admin tokens. The first admin is created from admin_email / admin_password in config.json on first start β€” change that password immediately in production.

User management

System monitoring & capacity

The Admin tab now leads with a System Utilisation panel: CPU, memory, disk I/O, per-volume disk usage, aggregate channel output, a capacity estimate of how many more channels the host can carry, and automatically generated tuning advice.

Account creation

Two routes: create an account directly (Users β†’ + New User), or issue an invite code that lets someone self-register with a preset channel limit. Tick Require an invite code to sign up to close open registration.

Maintenance operations

Operational practices

System Utilisation & Capacity Planning v1.8

The πŸ›‘ Admin β†’ System Utilisation panel shows what the host is actually doing, estimates how many more channels it can carry, and turns those measurements into specific tuning advice. Tick live to refresh every 5 seconds.

What each metric means

CPUHost-wide utilisation sampled between calls, plus load average. Playout is remux-only, so CPU should stay modest; sustained high CPU usually means very high-bitrate sources or another process competing.
MemoryUsed vs total, based on MemAvailable (so page cache is not counted as used). Swap pressure is called out separately because swapping causes stream stalls.
Disk I/ORead/write throughput, IOPS and utilisation (the share of wall-clock time the busiest device had a request in flight). Utilisation near 100% means storage is the bottleneck even if CPU looks idle.
Channel outputAggregate egress across running engines, measured from bytes actually sent β€” the honest figure for capacity planning.
NetworkHost-wide interface throughput (excluding loopback and container bridges).
DisksUsage for the uploads volume, the HLS directory and the application directory. The HLS entry is flagged when it is correctly on tmpfs.

How the capacity estimate works

The green panel answers "how many more channels can this box run?" It computes headroom three independent ways and reports the tightest one, along with which resource is the limiting factor:

Ceilings are deliberately below 100% so bursts, GC and the operating system still have room. When channels are running, per-channel cost is measured from your actual content and the estimate is marked confident. With nothing running it falls back to conservative assumptions and says so β€” start a few representative channels and re-check for a figure you can plan against.

The estimate does not know your uplink. For viewer-facing HLS, network egress is usually the real ceiling long before CPU is: 30 channels at 5 Mb/s is 150 Mb/s of origin traffic before a single viewer multiplier. Check the Channel Output figure against your provisioned bandwidth.

Tuning suggestions

Below the metrics, PlayoutGo lists advisories generated from the current snapshot β€” colour-coded by severity, each with a concrete action. Typical guidance:

Invite Codes & Signup Control v1.8

Invite codes let you hand out accounts without opening registration to anyone who can reach the server β€” and each code carries the channel limit for the accounts created with it.

Creating a code

πŸ›‘ Admin β†’ Invite Codes β†’ + New Code, then answer three questions:

Codes look like NVMY-WNND-SXTF β€” generated with a cryptographic random source and an alphabet that omits easily-confused characters (no O/0, no I/1). The new code is copied to your clipboard automatically.

Requiring codes for signup

Tick "Require an invite code to sign up" to close open registration. New users then must supply a valid code on the signup form. The toggle is persisted to config.json (require_invite_code) so it survives restarts.

You cannot enable this while no usable code exists β€” that would leave no route to a new account at all. Create a code first.

Lifecycle

Changing a user's limit later

The code sets the limit at signup only. To change it afterwards, use Admin β†’ Users β†’ ✏ Edit and set Max channels directly (0 = unlimited).

HTTPS with Automatic Certificates v1.16

PlayoutGo can serve HTTPS on port 443 with certificates obtained and renewed automatically from Let's Encrypt β€” no reverse proxy, no certbot, no cron job. Enable it in config.json:

{
  "tls": {
    "enabled": true,
    "domains": ["playout.example.com"],
    "email": "ops@example.com",
    "cache_dir": "certs",
    "port": 443,
    "http_port": 80,
    "redirect_http": true,
    "staging": false
  }
}
domainsRequired. Certificates are only issued for these hostnames. Without an allowlist anyone who points DNS at your server could trigger issuance and exhaust your rate limits, so startup refuses an empty list.
cache_dirWhere certificates and the ACME account key are stored. Must persist across restarts β€” a wiped cache means re-issuing on every boot and hitting Let's Encrypt limits. Created mode 0700; PlayoutGo tightens it automatically if it finds it group/world-readable.
redirect_httpBinds port 80 to answer ACME HTTP-01 challenges and 301-redirect everything else to HTTPS. Leave this on: HTTP-01 validation always arrives on port 80.
stagingUses Let's Encrypt's staging CA β€” certificates are untrusted by browsers but limits are far higher. Prove your DNS and firewall with this first, then switch it off.

Requirements

/healthz and /metrics on the loopback interface 3.58.0

With built-in TLS the server is on the TLS ports, and config.json’s port serves /healthz and /metrics β€” those two and nothing else β€” over plain HTTP on 127.0.0.1 only. The installer checks the server there after every start, and a Prometheus or a health agent on the host can use it; /metrics keeps its metrics_allow_from list. The console and the API stay on HTTPS. Until 3.58.0 port answered nothing with TLS on, so an upgrade with install.sh on such a host failed its health check and rolled itself back. Keep port apart from tls.port and tls.http_port; playout -healthz-url prints where the server answers.

An ACME CA of your own 3.59.0

A host with no public DNS name cannot get a Let’s Encrypt certificate. If your network runs its own ACME CA β€” step-ca, a corporate PKI β€” set tls.acme_directory to its directory URL (https:// only), and tls.acme_ca_file to a PEM bundle when the CA’s own HTTPS is not publicly trusted (the system’s roots stay trusted). The certificates are what that CA issues: browsers trust them only if they trust its root. staging and acme_directory cannot both be set. The server logs which CA it uses (certificates from ACME directory …); playout -check names a bundle that cannot be read or holds no certificate. install.sh --tls … --tls-acme-directory URL --tls-acme-ca FILE writes both, the bundle copied to /var/lib/playoutgo/acme-ca.pem.

"tls": {
  "enabled": true, "domains": ["playout.corp.example"],
  "acme_directory": "https://ca.corp.example:9000/acme/acme/directory",
  "acme_ca_file": "/var/lib/playoutgo/acme-ca.pem"
}

First run

β€Ί Set staging:true, start the service, and watch the log:
    πŸ”’ HTTPS on :443 for playout.example.com
    β†ͺ️  HTTP :80 answers ACME challenges and redirects to HTTPS
    πŸ“ Certificate cache: certs (must persist across restarts)
β€Ί Visit https://playout.example.com β€” the browser will warn (staging CA), which
  proves issuance worked end to end.
β€Ί Set staging:false, delete the cache directory once, restart.
βœ“ A trusted certificate is issued on the first request and renewed automatically.
Behind an existing proxy? Leave tls.enabled false and keep terminating TLS at nginx/HAProxy as before β€” nothing changes. TLS is off by default, so upgrading never causes an unexpected attempt to bind 443.

Secrets at Rest 3.48.0

SRT passphrases, live-insert stream keys, encoding-node bearer tokens and YouTube refresh tokens are stored encrypted (AES-256-GCM, each bound to its column so a value cannot be moved into another one). Application keys and passwords are stored only as hashes. A copy of playout.db — a snapshot, a backup, a stolen disk image — reveals none of the four recoverable secrets without the keyring.

Where the key comes from 3.54.0

A key file beside the database keeps the key out of database copies, but not off the host: whoever can read the data disk β€” a stolen drive, a host-level backup β€” has both. These sources keep the key elsewhere. The first one configured is used:

SourceHow
secrets_key_commandIn config.json: an argument list, run without a shell, whose standard output is the keyring β€” your key-management service's CLI, a secrets manager, a wrapper. Run once per start, with a 30 s limit; its error output goes into the message when it fails, its standard output never does.
PLAYOUTGO_SECRETS_KEY_FDA file descriptor number (0, or 3 and above) to read the keyring from, once, to its end β€” a pipe from a supervisor, or ExecStart=… 3</run/keys/playoutgo. Closed after reading.
PLAYOUTGO_SECRETS_KEYThe keyring in the environment, key lines separated by newlines or commas. Removed from the environment as soon as it is read, so no ffmpeg or other child inherits it. /proc/<pid>/environ keeps the value the process started with, so prefer a descriptor or a command.
systemd credential playoutgo-secrets-key$CREDENTIALS_DIRECTORY/playoutgo-secrets-key, from LoadCredential= or LoadCredentialEncrypted=. Sealed to this machine's TPM with systemd-creds encrypt --with-key=tpm2, the key is useless off this hardware.
the filesecrets_key_file, or secrets.key beside the database β€” as before, and the default.
# config.json β€” the key from a KMS-backed wrapper
"secrets_key_command": ["/usr/local/bin/playoutgo-key", "--env", "prod"]

# or a TPM-sealed systemd credential
systemd-creds encrypt --with-key=tpm2 --name=playoutgo-secrets-key secrets.key /etc/credstore.encrypted/playoutgo-secrets-key
# in the unit
LoadCredentialEncrypted=playoutgo-secrets-key:/etc/credstore.encrypted/playoutgo-secrets-key

SRT encryption is enforced in both directions

A channel with an SRT passphrase in listener mode accepts only callers that bring the same passphrase. A caller without one is refused (REJ_UNSECURE, which libsrt reports as “Password required or unexpected”) and a wrong one is refused (REJ_BADSECRET, “Incorrect passphrase”). A channel with no passphrase refuses a caller that asks for encryption, so a receiver can never believe a stream is protected when it is not. Refusals are logged: the first at once, then one summary per five seconds, printed when the five seconds are up even if nothing else arrives. Point receivers at the listener with the passphrase:

ffmpeg -i "srt://PLAYOUT_HOST:9000?mode=caller&streamid=STREAMID&passphrase=THE-PASSPHRASE" …

Security Hardening

Two-factor sign-in 3.60.0

Each account can add a second factor: a six-digit code from an authenticator app (RFC 6238 β€” Google Authenticator, 1Password, Authy and the rest). Turn it on with πŸ” in the header: your password, then scan the QR code (drawn by the server; the secret goes nowhere else) and enter a code. Ten recovery codes are shown once β€” each signs in once without the app; only their hashes are kept. From then on sign-in asks for the code after the password.

Two-factor is per account and optional; nothing yet requires it of administrators (see the roadmap).

The content policy (the console since 3.50.0, every page since 3.51.0)

The console β€” the page an operator's session token lives on β€” is served with script-src 'self' and nothing else: no 'unsafe-inline', no 'unsafe-eval', no other origin. An injected <script>, an onerror= or onclick= attribute, or a javascript: link does not run, even if some future escaping mistake let one onto the page. The browser refuses it and reports the violation.

That required the console to contain no inline code of its own. Its script lives in files served by this binary (/boot.js, /ui-dispatch.js, /app.js, /traffic.js). Its buttons and fields declare what they do in data-on-click, data-on-change and similar attributes, such as data-on-click="stopCh(12)". ui-dispatch.js parses those attributes and never evaluates them. Its grammar allows a single call to a function on a fixed list, whose arguments may be literals, the element (this, this.value, this.checked, this.files, this.textContent) or the event. It also allows event.stopPropagation() and return false. Anything else is refused and runs nothing.

Since 3.51.0 a value that varies per row β€” an id, a name β€” is never written into the handler itself. It goes into a data- attribute of the same element and is read by name: data-on-click="stopCh($num('v-ch-id'))" data-v-ch-id="12". $num reads a number, $str a string and $bool true or false; an attribute that is missing or does not parse refuses the handler instead of passing a guess. The handler text is then the same on every row, and nothing the server sends ever becomes part of it.

Since 3.51.0 every page this binary serves has the same policy: the viewer, the EPG index, the YouTube return page, the manual, the about page and the guides. Their script is in files too (/viewer.js, /epg-index.js, /youtube-done.js), and the viewer's player is served by this binary rather than fetched from a CDN, which the policy forbids. That closes audit U-11. The one exception is the graphics page a compositor renders (/graphics/{id}.html): it runs in a sandbox with an opaque origin and has its own policy, so even script that ran there could not reach a session. Every page refuses framing by other sites. Only the manual may be framed, and only by this origin, because the console's Help tab shows it.

For anyone changing the console: do not write onclick= or an inline <script>. The browser silently ignores them and the control does nothing. Write data-on-click="yourFunction(args)" instead. Declare yourFunction as a top-level function in app.js and add its name to HANDLERS in ui-dispatch.js. Put a per-row value in a data- attribute and read it with $num, $str or $bool, never ${…} inside the handler. Ask with confirmDialog(), askDialog() or formDialog() and tell with noticeDialog(); never confirm(), prompt() or alert(). Give the dialog a submit that runs the change, so a refusal is shown in the dialog. The tests refuse inline code, an interpolated handler, a native dialog, and a handler that is unlisted, listed but unused, or not a top-level function.

v1.4 security behaviors

Changes in this release (all bugs from security audit addressed):
FindingSeverityFix
HLS path traversal via /hls//etc/passwdπŸ”΄ Criticalfilepath.Clean + HasPrefix check ensures resolved path stays inside hls_dir
SSRF via /api/hls-proxyπŸ”΄ CriticalResolves hostname to IP; blocks all RFC-1918/loopback/link-local ranges; optional allowlist
Proxy URL encoding breaks signed segments🟑 Highurl.Values.Encode() used instead of string concatenation
Any user reads all channels' live stats🟑 HighStats endpoints now filter to caller's own channels (admin sees all)
Playlist position silently ignored on PUT🟠 MediumUpdatePlaylistItemPosition() implemented and wired to the handler
Duplicate positions on AddToPlaylist🟠 MediumExisting items at β‰₯ position are shifted up in the same transaction
Reorder leaves stale positions🟠 MediumOmitted items are appended at end; all items always get a valid position
Wrong item played with future schedule🟠 MediumEngine stops at a future-scheduled item; no unscheduled item may jump past it
started_at overwritten on stop/error🟠 Mediumstarted_at is only set on "running"/"connecting" transitions
Unauthenticated admin reset (brute-force)🟑 HighOff by default (404 unless enabled); 5 attempts per IP per 15 minutes; a recovery secret separate from the admin password
Password changes lost on restart🟑 HighEnsureAdmin() no longer overwrites existing admin credentials
Config file world-readable (0644)🟑 HighSaveConfig() now writes with 0600 (owner read/write only)
JWTs in query strings leak to logs🟠 MediumAPI middleware removed ?token= support; kept only for SSE, file preview and the HLS proxy, which take the six-hour read-only scoped token
Data race on e.output (SRT goroutine)πŸ”΄ CriticaloutputMu RWMutex added; all accesses use getOutput()/clearOutput() helpers
Login endpoint brute-forceable🟑 High20 consecutive failures per IP block it for 15 minutes; a rolling 15-minute window also refuses an IP after 50 failures and an account after 10
Arbitrary role string accepted in admin user update🟑 Highrole validated to exactly "admin" or "user"
Admin can self-demote via API🟠 MediumSelf-demotion rejected with clear error message
JWT in M3U link URL (EPG page)🟠 MediumThe M3U link carries a feed token (EPG/M3U only) instead of the account token; /m3u still requires a token
JWT in video src attribute (file preview popup)🟠 MediumPreview streams in a <video> with the six-hour read-only scoped token, never the account token
output_mode / srt_mode accept arbitrary strings🟒 LowValidated against allowed enum values on create and update
SRT passphrase length not validated🟒 LowEnforced 10–79 char requirement per SRT protocol spec
multiplayout: any file type uploadable🟑 HighExtension whitelist (MP4/M4V) + MaxBytesReader before ParseMultipartForm
multiplayout: login brute-forceable🟑 HighPer-IP failure counter; 20 consecutive failures = block
multiplayout: undefined variable n in ensureSRTDialπŸ”΄ CriticalPre-existing build error fixed; network var correctly stays "srt"
MP4 parser OOM via crafted sample countπŸ”΄ CriticalCapped at 10M samples (≫ 90 hours of video) in three box parsers
SRT connection leak on cancel/timeout🟑 HighDrain goroutine closes any stale connection before returning
Scheduler goroutine leaked on server shutdown🟠 MediumContext-aware Run(); cancelled by server shutdown context
XSS in stats grid (current_file unescaped)🟠 MediumX() escaper applied; file names can contain HTML characters
XSS in admin user table (role unescaped)🟠 MediumX() applied in badge and meta; editUser uses data-* attributes
Missing 405 on GET-only endpoints🟒 LowMethod guard added to five read-only handlers
HLS tmp file not cleaned on rename failure🟒 Lowos.Remove(tmp) called on both WriteFile and Rename error paths
writePES infinite loop β†’ OOM crashπŸ”΄ CriticalTS stuffing recalculated payload-first; remaining always advances β‰₯1 byte per iteration
SRT rejection log floods 100+ lines/second🟠 MediumBatched: one log line per 5s with rejection count
Playout panic kills entire serverπŸ”΄ Criticalrecover() in goroutine; one channel crash no longer kills the process
HLS takes 4s minimum before first segment🟠 MediumA stub playlist exists from the start; the first segment closes on the first keyframe at or after 4 s, like every other
SRT client connecting mid-stream misses PAT/PMT🟠 MediumPSI injected immediately on SRT connect via srtNewConn atomic flag

Production checklist

Deployment (systemd) 3.60.0

Each release has a deploy bundle, playoutgo-<version>-linux-amd64.tar.gz: the binary, the systemd unit playoutgo.service, the installer install.sh, INSTALL.md, config.sample.json, VERSION and, since 3.58.0, the Prometheus alert rules playoutgo-alerts.yml (alert rules). release/make-release.sh builds it beside the source archive, from the same tree, with a SHA256SUMS file.

tar xzf playoutgo-3.64.0-linux-amd64.tar.gz && cd playoutgo-3.64.0-linux-amd64
sudo ./install.sh --url https://playout.example.com     # fresh install
sudo ./install.sh --tls playout.example.com             # fresh install with built-in HTTPS (3.58.0)
sudo ./install.sh                                       # on an installed host: upgrade
sudo ./install.sh --rollback                            # the previous binary back
WhereWhat
/opt/playoutgo/playoutThe binary; playout.prev beside it after an upgrade.
/var/lib/playoutgo/Everything the server writes, owned by the playout system user: config.json (the working directory), the database, secrets.key, uploads/, encoded/, backups/.
/dev/shm/playout_hlsHLS segments, on tmpfs.
/etc/systemd/system/playoutgo.serviceThe unit β€” the installer’s; do not edit it.
/etc/systemd/system/playoutgo.service.d/Drop-ins: local settings for the unit (sudo systemctl edit playoutgo). Upgrades never touch them.

The installer

playout -check and -version

playout -check reads config.json in the working directory and reports, without creating, writing or changing anything: an ERROR for what the server would fail on or an attacker could use β€” an unparsable or invalid file, a directory the service cannot write, an unreadable keyring, a published secret β€” and a WARN for what an operator should decide: relative paths, plain HTTP (neither tls nor trust_proxy_headers), no advertised_base_url, the reset endpoint on, /metrics open to everyone, allowed_origins of *, a database without its keyring, missing ffmpeg, ports below 1024 without the capability, files readable by others. It exits 1 on an error. The unit runs it before every start (ExecStartPre), so a mistake appears by name in journalctl -u playoutgo instead of as a restart loop.

cd /var/lib/playoutgo && sudo -u playout /opt/playoutgo/playout -check
WARN  advertised_base_url is empty: links given to other machines … will use whatever address a request arrived on
PlayoutGo 3.57.0: 0 error(s), 1 warning(s)

playout -version prints the version and touches nothing. playout -healthz-url (3.58.0) prints where the configured server answers /healthz on this host β€” port, which with built-in TLS is the loopback listener β€” and changes nothing either. Without arguments the binary serves, as it always has.

The unit

[Service]
Type=notify                      # READY once serving; a heartbeat only while HTTP and the database answer
WatchdogSec=90                   #   β€” so a wedged process is restarted
User=playout
WorkingDirectory=/var/lib/playoutgo
StateDirectory=playoutgo
ExecStartPre=/opt/playoutgo/playout -check
ExecStart=/opt/playoutgo/playout
Restart=always
KillSignal=SIGTERM               # channels stop; airing items closed in the as-run log at the signal
TimeoutStopSec=30
LimitNOFILE=65536
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict             # everything read-only except the state directory and /dev
ProtectHome=yes
PrivateTmp=yes
…                                # kernel, clock, namespaces, address families restricted
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

Local settings go in a drop-in, never in the unit β€” the installer replaces the unit on an upgrade, and refuses to while it carries local changes. sudo systemctl edit playoutgo opens /etc/systemd/system/playoutgo.service.d/override.conf:

[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE      # built-in TLS on 443/80
ReadWritePaths=/data1/playout-media           # directories config.json names elsewhere
Environment=GOMEMLIMIT=6GiB                   # the memory limit (default 4 GiB)

Not set, on purpose: PrivateDevices and MemoryDenyWriteExecute β€” local NVENC encodes and GPU decoding need /dev/nvidia*, and the NVIDIA driver maps writable code. A directory config.json names outside /var/lib/playoutgo β€” mirror_dir, failover_cache_dir, a watch folder that moves files, an hls_dir off /dev/shm β€” must be added to ReadWritePaths= in the drop-in; the start-up check names any the service cannot write.

nginx reverse proxy (with TLS)

server {
    listen 443 ssl;
    server_name playout.example.com;

    ssl_certificate /etc/letsencrypt/live/playout.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/playout.example.com/privkey.pem;

    # Block emergency reset from public internet
    location /api/auth/reset {
        allow 10.0.0.0/8;
        deny all;
    }

    location / {
        proxy_pass http://127.0.0.1:8700;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        # Required for SSE
        proxy_buffering off;
        proxy_read_timeout 3600s;
    }
}

Troubleshooting

A page is blank, or dropdowns/buttons do nothing

This is almost always a JavaScript error aborting the page script. Open the browser console (Safari: Develop β†’ Show JavaScript Console; Chrome: βŒ₯⌘J) and look for "X is not defined". Then:

SRT does not connect but HLS works

Work through these in order β€” they cover almost every case:

  1. Include the stream ID. The listener matches it exactly; a URL with no streamid is rejected. Use the connect URL printed in the server log when the listener starts:
    ffprobe -i "srt://HOST:9000?mode=caller&transtype=live&streamid=YOUR_ID&latency=200000"
  2. Check the channel's Stats β†’ error log. Since v1.11 a failed listener bind is recorded there, e.g. "SRT output unavailable: SRT port 9000 already in use with different listener settings". Two channels may share a port only when passphrase, latency, buffer and max-bandwidth match β€” then they are routed by stream ID. Otherwise give them separate ports.
  3. Confirm something is listening: ss -lunp | grep 9000 on the server, and check the log for "[SRT] Listener on :9000". If it is absent, no channel currently owns that port.
  4. Remember SRT is single-consumer β€” one client per channel at a time. Disconnect VLC before testing with ffprobe.
  5. Firewall: SRT is UDP. Confirm the port is open for UDP, not just TCP.

The Help tab (or another tab) is not visible

Fixed in v1.6.1 β€” tabs used to overflow on narrow windows. On older builds, widen the browser window or scroll the tab strip horizontally.

Channel stuck in "connecting"

Stop and start the channel (⏹ then β–Ά, or POST /api/channels/{id}/stop then /start). In SRT caller mode "connecting" means the far end is not accepting β€” check it is listening. /api/admin/fixstatus re-probes media and does not touch channels.

HLS stream shows "Stream starting, please wait…"

The channel is running but no segments have been flushed yet. Wait about 4 seconds plus one GOP for the first segment, then reload. If it persists, check the channel error log via GET /api/channels/{id}/diag.

SRT connection fails immediately

In caller mode, verify the downstream server is listening before starting the channel. In listener mode, verify the port is open in your firewall. Check the channel stats for the last SRT error message.

File stuck in "processing" status

The background probe goroutine may have failed. Use POST /api/admin/fixstatus (Admin β†’ Re-probe Broken Files) to re-trigger probing on stuck files; reimport only registers files the database is missing. Check server logs for MP4 parse errors β€” the file may be corrupt or use an unsupported codec (H.265, VP9, etc.).

All playlist items show "failed"

The engine marks items failed when it can't read samples. This usually means the files are HEVC/H.265 β€” only H.264/AAC MP4 is supported. Re-encode with: ffmpeg -i input.mp4 -c:v libx264 -c:a aac output.mp4

HLS proxy returns 403 "upstream host not permitted"

The proxy blocked the request because the target hostname resolves to a private IP. If you need to proxy an internal host, add it to hls_proxy_allowed_hosts in config.json.

Testing Guide β€” verify every feature v1.4

A step-by-step acceptance checklist. Run it after every upgrade. Replace SERVER, TOKEN, CH (channel id) and stream IDs with your values. Get a token via the login call in step 1.

1 Β· Authentication

β€Ί curl -s -X POST http://SERVER:8700/api/auth/login -H "Content-Type: application/json" \
    -d '{"email":"you@example.com","password":"..."}'
βœ“ Returns {"token":"..."} β€” export it:  TOKEN=eyJ...
β€Ί curl -s http://SERVER:8700/api/channels
βœ“ 401 without a token; 200 JSON array with -H "Authorization: Bearer $TOKEN"

2 Β· Media upload & probe

β€Ί Upload an MP4 in the Files tab (or POST /api/files)
βœ“ Status shows "processing", then "ready" with real widthΓ—height, fps, duration, bitrate
βœ“ Restart the server β†’ metadata persists (no re-probe of already-probed files)
β€Ί Upload a renamed non-MP4 (e.g. a .txt renamed .mp4)
βœ“ Status becomes "error", not "ready"

3 Β· Channel lifecycle

β€Ί Create a channel (output=both, listener, port 9000, stream id "test1"), press Start
βœ“ Status: connecting β†’ running; logs show HLS segmenter + SRT listener with a connect URL
β€Ί Press Stop during an auto-restart back-off (kill the engine by removing its media)
βœ“ Channel stays stopped β€” no zombie resurrection

4 Β· Rundown β€” anchors & flow

β€Ί Add 3 files; drag to reorder
βœ“ Grey floating times reflow instantly while dragging
β€Ί "Schedule day from" a time 1h ahead β†’ Set & flow
βœ“ Item 1 shows πŸ”’ bold yellow; items 2–3 show πŸ”“ grey computed times
β€Ί Drag item 3 above item 2
βœ“ Floating times swap; the anchor does not move
β€Ί Lock item 3 at a time 10 min AFTER item 2's flow end
βœ“ Amber "GAP β€” 10:00" row appears between them
β€Ί Edit that anchor to a time BEFORE item 2's flow end
βœ“ Red "+MM:SS over" badge appears on the anchor

5 Β· Rundown β€” bulk, undo, safety

β€Ί Shift-click to select a range β†’ bulk bar appears
βœ“ Move Top / Move Bottom / Clear times / Remove all work on the range
β€Ί Press ↩ Undo after each mutation
βœ“ Previous schedule/order returns exactly
β€Ί Try to remove the row marked β–Ά playing
βœ“ Confirmation prompt appears
β€Ί API atomicity:
  curl -s -X POST http://SERVER:8700/api/playlist/CH/schedule_bulk \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d '{"items":[{"id":REAL_ID,"scheduled_at":"2030-01-01T00:00:00Z"},{"id":999999,"scheduled_at":""}]}'
βœ“ Error response AND the real item's time is unchanged (rolled back)

6 Β· Schedule engine & restart resume

β€Ί Anchor item 1 at nowβˆ’25min with three 10-min items flowing after; restart the channel
βœ“ Log shows resume in item 3 at β‰ˆ5:00 offset (flow-aware restart)
β€Ί Anchor an item 1 min in the future on a stopped channel; wait
βœ“ Scheduler auto-starts the channel ~15 s before air

7 Β· SRT output

β€Ί With no other client connected:
  ffprobe -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=test1&latency=200000"
βœ“ Connects; shows your video (h264 …); NO "non-existing PPS" spam; brief keyframe wait is normal
β€Ί Same URL with streamid=WRONG (or none)
βœ“ Rejected with ERROR:PEER; server log shows throttled reject lines
β€Ί Connect VLC with the correct URL, then run ffprobe simultaneously
βœ“ ffprobe is rejected β€” single consumer per channel is enforced
β€Ί Wrong passphrase on an encrypted channel
βœ“ Rejected (bad secret) β€” this is the one legitimate password error

8 Β· HLS output

β€Ί curl -s http://SERVER:8700/hls/SLUG-ID/index.m3u8
βœ“ #EXTM3U, EXT-X-VERSION:3, segments with ~real durations, DISCONTINUITY at file changes
β€Ί Download any segment and probe it:
  ffprobe -v error -select_streams a -show_entries stream=channels,sample_rate SEG.ts
βœ“ channels=2, sample_rate=48000 β€” including for VIDEO-ONLY source files (silent track)
  ffprobe -v error -select_streams v -show_entries frame=key_frame -read_intervals %+#1 SEG.ts
βœ“ first frame of every segment is a keyframe (key_frame=1)
β€Ί Play the m3u8 in Safari (private window) and VLC
βœ“ Both play; audio-bearing files have sound, video-only files are silent

9 Β· EPG / M3U

β€Ί curl -s http://SERVER:8700/m3u            β†’ βœ“ 401 unauthorized
β€Ί FT=$(curl -s -H "Authorization: Bearer $TOKEN" http://SERVER:8700/api/epg/feed-token | jq -r .token)
β€Ί curl -s "http://SERVER:8700/m3u?token=$FT"
βœ“ M3U with only YOUR channels; SRT URLs use streamid= and transtype=live
β€Ί /epg/my?token=… returns XMLTV for your channels

10 Β· Security checks

β€Ί Create/update/list a channel with a passphrase set
βœ“ API responses contain srt_passphrase_set:true and NEVER the passphrase itself
βœ“ /api/stats/CH likewise
β€Ί PUT {"name":"renamed"} only
βœ“ SRT host/port/latency and loop flag survive (no zeroing)
β€Ί PUT {"clear_srt_passphrase":true}
βœ“ Encryption removed; unencrypted client now connects
β€Ί Channel name with quotes/<script> renders inert in UI and M3U

11 Β· Stats & admin

β€Ί Stats tab while a channel runs
βœ“ Live viewer/bitrate numbers update; history renders; no passphrase in the payload
β€Ί Admin tab (admin account): user list, create/edit/suspend user, per-user channels
βœ“ Non-admin token on /api/admin/* β†’ 403

12 Β· In-app Help v1.5

β€Ί Click the πŸ“š Help tab
βœ“ Full manual loads inside the interface (this document)
β€Ί On Channels / Media / Rundown / Player / EPG / Stats / Admin, click the ? next to the title
βœ“ Help opens scrolled to that page's manual section
β€Ί Click β†— in the header
βœ“ Manual opens in a separate browser tab
β€Ί Developers, before deploying:  go test ./...
βœ“ TestUIHasNoUndefinedFunctions and TestUICriticalElementsExist pass
  (these catch UI functions/IDs broken by refactoring β€” invisible to the compiler)

13 Β· Gap filler & duplicate v1.6

β€Ί Upload a short slate clip; set it as Gap filler in a channel's settings
β€Ί Rundown: one 6s item, then a second item anchored ~30s ahead β†’ GAP row names the filler
β€Ί Start the channel and watch the log
βœ“ Item 1 plays, then "Filler: covering 29s with … (1s null-padded)"
βœ“ Anchored item starts on its exact scheduled second
βœ“ Live stats show "⏸ Filler: name" during the gap; HLS keeps flowing with discontinuities
β€Ί Clear the filler (None) and repeat
βœ“ The gap airs the built-in black slate; segments decode as black with silence
β€Ί Select two rundown rows β†’ ⧉ Duplicate
βœ“ Floating copies appear at the end in order

14 Β· Multi-tenant isolation & admin users v1.7

β€Ί As admin: πŸ›‘ Admin β†’ + New User (email, password β‰₯8 chars, choose role)
βœ“ User is created and can log in
βœ“ Duplicate email β†’ "already exists"; short password β†’ rejected
β€Ί Log in as that user and try to use ANOTHER tenant's media:
  curl -X POST .../api/playlist/<your-channel> -d '{"file_id":<other-users-file>}'
βœ“ 403 "media file belongs to another user"
  curl -X PUT .../api/channels/<your-channel> -d '{"filler_file_id":<other-users-file>}'
βœ“ 400 "filler file belongs to another user"
  curl -X PUT .../api/channels/<your-channel> -d '{"filler_file_id":999999}'
βœ“ 400 "filler file not found"
β€Ί Own media still works normally in both places
βœ“ Added / set without error
β€Ί Developers:  go test ./...
βœ“ tenant_isolation_test.go and ui_static_test.go all pass

15 Β· System metrics & invite codes v1.8

β€Ί πŸ›‘ Admin β†’ System Utilisation
βœ“ CPU, memory, disk I/O and disk usage all show real figures (not "β€”")
βœ“ Capacity panel names a limiting factor (CPU / RAM / tmpfs) and a per-channel cost
βœ“ Tick "live" β†’ values refresh every 5 s; untick and leave the tab β†’ refresh stops
β€Ί Start several channels, refresh
βœ“ Capacity switches from "(estimated)" to measured, per-channel cost reflects your content
β€Ί Admin β†’ Invite Codes β†’ + New Code (e.g. 3 channels, 1 use, 30 days)
βœ“ Code appears, is copied to clipboard, shows "active"
β€Ί Sign out, register with that code
βœ“ Account created; the new user may create exactly 3 channels, then sees "channel limit reached"
β€Ί Try the same code again
βœ“ "already been used the maximum number of times"
β€Ί Revoke a code, then try it
βœ“ Rejected; Re-enable restores it
β€Ί Tick "Require an invite code to sign up" with no usable codes
βœ“ Refused β€” create a code first (prevents locking everyone out)
β€Ί With a code available, tick it, then register without a code
βœ“ "an invite code is required to sign up"
β€Ί Developers:  go test ./...
βœ“ invite_sysinfo_test.go covers redemption, expiry, revocation, concurrency and metric sanity

16 Β· Restart resume v1.9

β€Ί Start a channel that has a schedule; let it run a minute
β€Ί Kill the process hard:  kill -9 $(pgrep -x playout)    then start it again
βœ“ Log: "[resume] restoring N channel(s)…" then "back on air"
βœ“ Log: "Time-aware restart: seeking to item X (+NNNNms)"
βœ“ Playing what the SCHEDULE says should be on now β€” not from the top of the rundown
β€Ί Press ⏹ Stop, then restart the process
βœ“ Channel stays stopped (explicit stop clears the auto-resume intent)
βœ“ Stopped-but-marked channels show the ⟳ auto-resume badge
β€Ί Verify disk figures against the OS:  df -h
βœ“ System Health percentages match df (measured against usable space)
βœ“ Each volume listed once even when uploads and the app share a filesystem

17 Β· As-Run log v1.10

β€Ί Run a channel for a few minutes, then open 🧾 As-Run
βœ“ One row per item that aired, oldest first, with real start/end times
βœ“ Duration reflects actual airtime; Status shows completed
βœ“ Header summarises item count, total airtime hours and bytes
β€Ί Try the 1d / 7d / 30d buttons and the channel filter
βœ“ Row counts and the summary change accordingly
β€Ί Press ⬇ Export CSV
βœ“ A .csv downloads and opens cleanly in a spreadsheet with a header row
β€Ί Kill the process mid-item (kill -9) and restart it
βœ“ Log line: "as-run: closed N play record(s) interrupted by the last shutdown"
βœ“ That item shows status "interrupted" β€” not stuck on "playing" β€” with its airtime to within 5 s (3.58.0)
β€Ί Stop a channel mid-item, then stop the server cleanly (SIGTERM) with another on air
βœ“ Each item shows status "stopped", ending when it stopped, with its airtime (3.57.0)
βœ“ The next start logs no "interrupted by the last shutdown" line
β€Ί As a non-admin user, open As-Run
βœ“ Only your own channels appear
βœ“ curl .../api/asrun?channel_id=<another user's> β†’ 403

18 Β· Disk I/O attribution & SRT diagnostics v1.11

β€Ί Admin β†’ System Utilisation, compare with the host:
  iostat -x 2     and     df -h
βœ“ The DISK I/O card names the device(s) actually backing uploads/HLS/the app β€”
  not whichever device has the largest kernel counter
βœ“ A device with no reads/writes shows 0% utilisation (an idle or hung disk that
  the kernel parks at 100% util is no longer reported as system disk load)
βœ“ Percentages match df; each volume appears once

β€Ί Capacity: compare "more channels" with uptime/load
βœ“ On a host whose load per core is already β‰₯ 1, the CPU headroom figure is ~0
  even when instantaneous CPU% looks low (processes blocked on I/O)

β€Ί SRT not working while HLS is fine? Check the channel's Stats β†’ error log
βœ“ A bind/settings clash now appears there, e.g.
  "SRT output unavailable: SRT port 9000 already in use with different listener settings"
β€Ί Two channels may share one SRT port only if passphrase, latency, buffer and
  max-bandwidth all match; they are then routed by stream ID.
βœ“ Give clashing channels distinct ports, or identical settings + distinct stream IDs

19 Β· Channel health indicators v1.12

β€Ί Run a channel with output mode "both" and no SRT client connected
βœ“ Card shows an amber "SRT waiting" chip (normal β€” the listener is up)
β€Ί Connect VLC to it
βœ“ Chip turns green "SRT live"
β€Ί Create a second channel on the same SRT port with a DIFFERENT latency, start it
βœ“ Chip turns red "SRT error"; hovering shows the reason
βœ“ A red count badge appears on the Channels tab from any page
βœ“ Hovering the badge lists each channel and its problem
β€Ί Watch a running channel card for ~30 s
βœ“ An OUTPUT BITRATE sparkline appears with the current Mb/s (works for HLS-only
  channels too β€” segment bytes are counted, not just SRT socket writes)
β€Ί Admin β†’ System Utilisation β†’ click the DISK I/O card
βœ“ Expands to a per-device breakdown naming what each disk holds
βœ“ Only disks backing uploads/HLS/the application are listed

20 Β· System figures sanity-check v1.13

β€Ί Run several HLS-only channels, then open Admin β†’ System Utilisation
βœ“ CHANNEL OUTPUT is non-zero (HLS segment bytes count as egress, not just SRT)
βœ“ The capacity box says "measured from N running channel(s)" β€” it must never
  claim nothing is running while the card beside it shows channels running
βœ“ PROCESS shows "service up …" (this process) and "host up …" separately;
  after a redeploy service uptime resets while host uptime does not
βœ“ Compare with the OS:   iostat -x 2   Β·   df -h   Β·   uptime
  Disk device, percentages and load should agree

21 Β· Reading System Utilisation correctly v1.14

β€Ί Compare the CPU card with:  uptime  and  iostat -x 2
βœ“ The headline is LOAD PER CORE, not instantaneous usage β€” a host at 1% CPU with
  load 9 across 8 cores is saturated and must not appear green
βœ“ "using N%" and, when significant, "iowait N%" appear beneath it
βœ“ iowait β‰₯ 20% raises a "CPU is waiting on storage" advisory naming the disk as
  the bottleneck rather than the processor
β€Ί Click the CHANNEL OUTPUT card
βœ“ Expands to a per-channel list, with a ⚠ SRT marker on any channel whose SRT
  output is failing
βœ“ The rows always add up exactly to the headline figure
β€Ί Before the second sample arrives (immediately after a restart)
βœ“ Cards show "β€”" with "awaiting a second sample", never a misleading 0.0

22 Β· Resource cleanup & silent-failure checks v1.15

β€Ί Note the segment directories:  ls -d /dev/shm/playout_hls/*/
β€Ί Delete a channel from the Channels tab
βœ“ Its directory disappears; the log records "removed segment directory …"
β€Ί Create and delete a few channels, then restart the service
βœ“ Log records "cleaned N orphaned segment directories" β€” nothing accumulates
β€Ί Start a channel, then clear its rundown while it is on air
βœ“ Channel stays up transmitting padding (correct) BUT reports
  "Playlist is empty β€” channel is on air but transmitting padding only"
βœ“ It appears in Stats β†’ error log and is counted by the nav issues badge
β€Ί Admin β†’ System Utilisation, cross-check against the OS:
  uptime Β· iostat -x 2 Β· df -h
βœ“ Channel Output expands to a per-channel breakdown; parts sum to the total

23 Β· HTTPS & sign-up codes v1.16

β€Ί Set tls.enabled true with staging:true and your real domain; restart
βœ“ Log shows HTTPS listener, HTTP challenge/redirect listener and cache path
βœ“ http://your-domain/ returns 301 to https://your-domain/ (path and query kept)
βœ“ /.well-known/acme-challenge/… is answered, not redirected
βœ“ Certificate cache directory is mode 0700
β€Ί Set tls.enabled true but leave domains empty
βœ“ Startup refuses with a message naming the problem

β€Ί Admin β†’ Invite Codes β†’ generate a code with max_channels = 2, max_uses = 1
βœ“ With require_invite_code=true, signing up WITHOUT a code is refused
βœ“ Signing up WITH the code succeeds and grants max_channels = 2
βœ“ The user can create 2 channels; the 3rd returns "channel limit reached (2/2)"
βœ“ Re-using the code returns "already been used the maximum number of times"

24 Β· Generate & import schedules v1.19

β€Ί Rundown β†’ 🎲 Generate β†’ enter "20"
βœ“ 20 items appear; no file plays twice in a row
βœ“ Files still probing or marked error are NOT scheduled (the note says how many)
β€Ί Generate again with "5m"
βœ“ Items are added until ~5 minutes of content is reached; the toast reports the total
β€Ί Via API with the same "seed" twice
βœ“ Identical ordering both times

β€Ί Rundown β†’ ⬆ Import β†’ choose a CSV with a file,start header
βœ“ Toast reports format, added and anchored counts
βœ“ Rows with a start time appear πŸ”’ anchored; rows without flow after the previous item
βœ“ An unknown filename is reported by line number instead of failing the import
β€Ί Repeat with native XML, a BXF export and an XMLTV guide
βœ“ Each is detected automatically (format shown in the toast)
β€Ί Import a file with no recognisable entries
βœ“ Refused with a message naming what was expected

25 Β· Multi-clip gap filler v1.27

β€Ί Upload three short clips of different lengths (say 0:30, 0:10, 0:05)
β€Ί Channel settings β†’ Gap filler content β†’ select all three (⌘/Ctrl-click) β†’ Save
β€Ί Rundown: one short item, then a second item anchored ~1 minute ahead
β€Ί Start the channel and watch the log
βœ“ "Filler: covering 55s with a.mp4 + b.mp4 Γ—2 + c.mp4 (2s null-padded)"
βœ“ The anchored item still starts on its exact scheduled second
βœ“ The same clip is not played twice in a row while an alternative fits
β€Ί Clear the filler selection and repeat
βœ“ The gap airs the built-in black slate; the stream stays up and the channel stays running
β€Ί Select a clip that has no duration
βœ“ It is greyed out and cannot be chosen (re-probe it first)

26 Β· App API v1.31

β€Ί EPG tab β†’ App API Keys β†’ + New Key
βœ“ A pgk_… key appears with copy buttons for the key and the channels URL
β€Ί curl "$URL/api/app/channels?key=pgk_…"
βœ“ Every channel returns stream_hls, thumbnail_url, epg_url, now_playing, next_up
βœ“ Over HTTPS the URLs are https:// β€” never mixed content
β€Ί curl -o t.jpg "$URL/api/app/channels/1/thumbnail.jpg?key=pgk_…"
βœ“ A real JPEG of what is on air right now (needs ffmpeg on the server)
βœ“ Requesting again within 10s is served from cache in milliseconds
β€Ί curl "$URL/api/app/channels/1/epg?key=pgk_…"
βœ“ Programmes with start/end times matching the Rundown
β€Ί Try without a key, with a bogus key, and with another user's key
βœ“ 401, 401, and an empty list / 403 respectively
β€Ί Revoke the key, then repeat the first call
βœ“ 401 immediately

27 Β· App API β€” keys, thumbnails, logos v1.31

β€Ί EPG tab β†’ App API Keys β†’ + New Key
βœ“ A pgk_… key appears; "Copy Channels URL" gives a ready-to-paste endpoint
β€Ί curl "https://your-host/api/app/channels?key=pgk_…"
βœ“ Returns your channels with stream_hls, thumbnail_url, epg_url, now_playing
βœ“ URLs use https:// when the request did β€” paste one into a player and it plays
β€Ί curl with no key, and with a wrong key
βœ“ Both return 401
β€Ί curl -H "X-API-Key: pgk_…" (no query parameter)
βœ“ Works β€” use this form in production so keys stay out of logs
β€Ί Open the thumbnail URL for a RUNNING channel in a browser
βœ“ A JPEG of what is on air right now; reload after 10s and the frame has moved
β€Ί Open it for a STOPPED channel
βœ“ 404 with the reason in the body (apps should show their own placeholder)
β€Ί Upload a logo:  curl -X POST .../api/channels/1/logo -H "Authorization: Bearer $JWT" -F "file=@logo.png"
βœ“ logo_url appears in the channel payload and is used when no thumbnail exists
β€Ί As a second user, request the first user's channel by id
βœ“ 403 β€” keys are scoped to their owner
β€Ί Revoke the key in the interface, then retry
βœ“ 401 immediately

28 Β· Trim points, repeating days, watch folders v1.38

β€Ί Rundown β†’ any row β†’ β‹― β†’ in 2, out 8 on a 12-second file
βœ“ Duration shows 0:06 with a βœ‚ marker; hovering shows the original length
βœ“ Every air time after it moves earlier by 6 seconds
βœ“ The EPG for that channel shows a 6-second programme, not 12
β€Ί Try out=3 with in=5, then in=999 on a 12-second file
βœ“ Both refused with a specific reason
β€Ί Start the channel and watch the log
βœ“ "Trim in: starting 2000ms into …" then "Trim out reached …" 4 seconds later
β€Ί Rundown β†’ πŸ” Repeat Day β†’ pick today β†’ enter "weekdays 4"
βœ“ Four weekday copies appear, same clock times, weekend skipped
βœ“ Trimmed items stay trimmed in every copy
β€Ί Repeat a day that has no anchored items
βœ“ Refused β€” a loop rundown has no days to copy
β€Ί Drop an MP4 into a configured watch folder
βœ“ Nothing happens on the first poll (size is still being confirmed)
βœ“ It appears in Media on the next poll, probed and ready
βœ“ With move=true the source file is gone
β€Ί Copy a large file in slowly, or touch it during the copy
βœ“ It is not imported until its size stops changing

Fuzzing and combinations 3.56.0

Beyond the checklist above, two kinds of automated test run with the suite.

Fuzz targets feed generated input to every part of the server that reads bytes from outside. Each checks more than survival: a traffic log that parses writes back byte for byte; a token signed with one secret never verifies with another; a sample comes back out of the muxer's packets unchanged; a splice section parses back to what was asked for. Each fails an input that takes over a second (five for a whole MP4).

TargetWhat it feeds
FuzzAMF0Decode, FuzzRTMPMessagesThe RTMP publishing point: AMF0 commands and chunked messages, with a published, taken feed so the media paths run.
FuzzMP4Reader, FuzzMP4FieldsThe MP4 demuxer: bytes, and real files with their box sizes, counts, timescales, durations and offsets rewritten.
FuzzH264AACSPS, SEI and caption probing, codec strings, AVC and ES configuration, ADTS.
FuzzRPXRoundTrip, FuzzRPXFieldsTraffic logs, byte by byte and field by field, through import and export.
FuzzScheduleImport, FuzzAdBreaks, FuzzChannelSettingsCSV and XML imports and date formats, break lists, every channel setting a client can send (and the DVB tables built from them).
FuzzSessionToken, FuzzKeyringSession tokens; the keyring file and sealed values.
FuzzTSMuxerPES, FuzzSCTE35Section, FuzzDeadAirRendition, FuzzPMTRewriterThe engine's transport-stream code: its muxer, splice sections, the dead-air rendition rewriter, the program-map rewriter.
# one target, for as long as you like (seconds here; 10m or 1h for a soak)
go test -run '^$' -fuzz '^FuzzRTMPMessages$' -fuzztime 300s .

An input that fails is written to testdata/fuzz/<target>/; committed there, it runs in every go test from then on. The muxer the programme leaves through on air is a library (gomedia) and is outside these targets.

Combination tests (TestCombo…) air features together to a real SRT receiver and read what arrived with an independent stream reader β€” tables and their CRCs, every declared stream carrying data, continuity, a clock that never jumps more than 100 ms or runs backwards, the rate per second β€” then ffmpeg decodes the capture for picture and sound per language. The combinations: constant rate with dead air and a receiver joining in the gap; a live take during a gap; two languages with a break at a constant rate; DVB with two languages; DVB through dead air; filler with fewer tracks than the channel; a break that outlasts its item into dead air; a one-track live feed on a two-language channel; and DVB at a constant rate with a receiver joining mid-programme, between keyframes.

Encoding API

GET /api/encode/queue
Work waiting for a node, with position, estimated wait and whether an item was moved up because it is due on air soon. DELETE /api/encode/queue/{id} cancels one.
πŸ” Requires auth
GET /api/encode/nodes
Encoding nodes with their health, capacity and free disk. ?fresh=1 re-probes now rather than using the cached figure, which is what the Refresh button sends β€” the only time an operator expects to wait.
πŸ” Requires auth
POST /api/encode/start
Encode one media file. Body: file_id, an encode profile, and optionally channel_id β€” which is what lets the job find that channel's QR overlay.
πŸ” Requires auth
POST /api/encode/jobs/{id}/retry
Encode this job's file again. Used by ↻ Encode again on a failed row. Only a failed or cancelled job can be retried β€” anything else is 409 (until 3.52.0 that guard sat in unreachable code, and Retry on a running job started a second encode of the same file). The settings repeated are the channel's current profile when the job came from a channel, and the job's own recorded profile otherwise β€” so a file encoded from the Media page can be retried too. The failed row is cleared on success, and the retry is written to the audit trail. Answers 200 with the new job id, or 202 when every node is busy and the work was queued. 404 means the job has already been cleared from the list β€” start it again from the Media page.
πŸ” Requires auth
POST /api/encode/jobs/{id}/cancel Β· DELETE /api/encode/jobs/{id}
Cancel a running encode (on the node too), or clear a finished one from the list. A job's state and progress are listed in GET /api/encode/nodes under jobs[]; there is no per-job GET.
πŸ” Requires auth
GET /api/encode/notices
Completion notices for this account, kept twelve hours β€” so an encode that finished while nobody was watching is still reported.
πŸ” Requires auth
GET /api/encode/nodelist
Configured nodes. POST to add, DELETE /api/encode/nodelist/{id} to remove. Tokens are never returned: has_token says one is stored, and token_locked is set when it cannot be decrypted with this server’s keyring — re-enter it.
πŸ” Administrator only

Traffic: Broadcast Logs, Library, Templates 3.49.0

The πŸ“‘ Traffic tab is where a broadcast day is planned before it is played. It reads and writes ProTrack RPX logs, keeps a library of programs and interstitials, builds days from templates, and loads a released day into the Rundown. The format and every rule below were taken from three real 3ABN logs (English, International, Russian, 14 October 2026 β€” 775 events), which the test suite uses as fixtures.

The workflow

  1. Import a ProTrack RPX file. It is read field by field and can be written back out byte-for-byte. A file with data in columns the samples never use is refused rather than trimmed. The first import into a channel also learns the channel's profile: list number, log name, outputs, control codes, graphics timings, station-ID code and logo dayparts.
  2. Check. The log view shows the integrity check β€” the day must run from 00:00:00 to 24:00:00 with no gap or overlap; on a grid channel every program starts on a quarter hour and fills whole quarter hours with its breaks; every graphic sits inside its program β€” and which house codes have no playable file. The English sample has one real one-second gap (promo BF190020A ends at 02:59:59, the next program starts at 03:00:00) and the check reports exactly that.
  3. Assign media. A file plays a house code when its house ID is that code, or when its file name without the extension is (SFS220020S.mp4). Assign file… gives a file the house ID.
  4. Release. Only a released log can be loaded; releasing another log for the same day supersedes the first. An imported log with integrity errors can be released only with an acknowledgement and a reason, which go into the audit log. A generated log with errors cannot be released at all β€” the template or library is what needs fixing.
  5. Load into Rundown. A dry run says what will happen first. Each program's first part is anchored at its time, everything else is chained after it, and every file is trimmed to the length the log gives it, so the rundown keeps the log's clock to the second. Pending rundown items for that day are replaced; loading the same day again replaces it again rather than doubling it. Events less than 30 seconds away are not loaded (they would replay the past), and the first event loaded is always anchored. Loading refuses while any house code has no playable file.
  6. Export RPX writes any log back out: an imported one exactly as it came in, a generated one with the channel profile's values.

Building days

Learn from this log turns a real day into library entries (each program with its parts and the breaks between them, every promo, filler and ID) and a day template, and can assign that template to a weekday. Generate a day then builds any date from the template assigned to its weekday. Learned from each of the three samples, the generator rebuilds that sample day with every program, part, start time and graphic where ProTrack put it, every station ID that closes a block, and every break filled to the second β€” and for the English day without the one-second hole. Since 3.53.0 that includes the 24 hour spots, and so the one English ID that does not close a block β€” X050000S at 23:58:30, the spot for Thursday's midnight, which opens the last break of the day.

The template editor 3.53.0

Templates β†’ Edit (or + Template) opens a table with one row per block. In a grid template each block starts where the one before it ends, so only its length is typed β€” in minutes, whole quarter hours β€” and the start times below it and the day's coverage (Covers 00:00 to 24:00 βœ“, or how much is missing or over) update as you type. In a flat template the start and length columns become an Anchor column. Every row has a program (offered from the library while typing), a series and an episode rule, the breaks between the program's parts (blank keeps the program's own), and ID at end; ↑ ↓ move a row, + inserts one below, βœ• removes it. Fixed spots are a second table: house code and time. Save checks everything first and names the first problem in the editor β€” nothing is sent until the template is whole; the server checks again. Leaving with unsaved changes (Cancel, another view, another channel) asks first. Until 3.53.0 a template was edited as one line of text per block.

Series 3.53.0

A house code is a series and an episode: CR001622S is episode 1622 of CR, RUP_BDV041018 episode 41018 of RUP_BDV β€” everything before the last run of digits is the series, the digits the episode. A program's Series field overrides that when the codes do not follow it. A template block names a series and a rule instead of (or as well as) a program:

The block's program, when set, is where the series starts if it has never aired. "Most recent" reads the channel's logs for the 92 days before the day being built β€” the released log of each day when there is one β€” and then follows what the day itself has scheduled so far.

Learning marks a block same when its program is the series' most recent airing again, and next when it is the episode straight after it β€” no other known episode (in the library or that log) lies in between, so the RUSS bumpers RUP_OFC000001 and RUP_OFC000017, numbered but not consecutive, stay fixed. A series' first airing in a log is compared with the logs of the days before it; with none it stays a fixed program, because one log cannot say whether it is a new episode or a repeat. Learned from the samples: ENG β€” CR next at 11:30, TDYC same at 15:00, 20:00 and 23:00, MH and IIW same; INTL β€” TTK same at 09:30 and next at 23:30; RUSS β€” RUP_BDV next three times.

Hour spots and fixed spots 3.53.0

Every one of the 24 breaks that end on the hour in the ENG sample opens with a spot named for its hour: X{DOW}{HH}00S, with the day of the week (Sunday = 01, so Wednesday 14 October is 04) and the hour β€” X040100S at 00:58:30, X040200S at 01:58:00 … and X050000S at 23:58:30, because midnight belongs to Thursday. INTL uses IDX{DOW}{HH}00S; RUSS has none. The profile's hour spot holds the pattern, learned from a log when at least 12 of the day's hours agree on one. The generator puts that hour's spot first in the break whose slot ends on the hour (through a closing station ID); codes that match the pattern never fill other breaks. A spot the library lacks is listed in the notes and its break is filled without it β€” so a Thursday needs Thursday's 24 spots in the library. Before 3.53.0 the 24 spots were ordinary promos to the filler and could land anywhere.

A fixed spot (template, fixed_spots) airs one promo, filler or ID at an exact second of the day, inside whichever break holds it whole: the break is filled exactly up to it and after it. One that would cut into a program, or that the library does not have, is not placed and the notes say so. A fixed spot's code never fills other breaks.

Rotation 3.53.0

Breaks are filled with the fewest items that add up exactly, and an item used within the last four hours costs more the more recently it aired, so the same promo does not air in consecutive breaks. That was all rotation knew, and it looked back over the day before only. The profile's rotation_days (1–14; 0 or 1 is the old behaviour) looks back that many days of logs, and an item that aired more recently within them costs a little more β€” so the promos that have not aired for longest come first and the whole library gets air.

Promo minimum gap 3.59.0

Rotation prefers promos that aired longest ago, but a cheap fill can still bring one back soon. The profile’s promo_min_gap_secs is a hard limit: a promo (type PR) that started less than that many seconds before a break is left out of it β€” 3600 for “not twice in an hour”, 0 (the default) for no limit. The previous days’ logs count, so a promo at 23:55 is not repeated at 00:20. When no combination fills a break without breaking the limit, the break is still filled and the day’s notes name the promo (could not keep promos … apart): add promos, or lower the limit. Fillers and filler programs are not limited. On 3ABN’s 14 October ENG library rotation alone already keeps each promo about four hours apart; the limit matters with a smaller library. It is ready for 3ABN’s answer on promo rules.

Flat anchors and filler programs 3.53.0

A flat day drifts: each program starts where the one before it ended. In the RUSS sample the openings at 06:00:00, 12:00:00 and 18:00:00 (and 03:00:00) are exact anyway. A block with an anchor starts at exactly that second: the time before it is filled like a break β€” from promos, fillers and programs marked filler β€” and a day that runs past it is noted and left as an overlap the integrity check refuses. More than an hour to fill before an anchor is noted rather than attempted. Learning a flat log anchors every program that starts exactly on the hour, and marks one-part programs of five minutes or less as fillers (the RUSS openings, bumpers, music clips); the notes say how many, and Filler in the program dialog changes it.

Graphics (secondary events)

Graphics are not placed by hand. They follow from the program and the profile by the rules all 197 graphics events in the ENG and INTL samples obey:

On air, a composited channel shows them, with the images the profile assigns to each code, in the corner and size given, while the code's event is on. A channel whose graphics are a page (HTML or URL) draws them in the page, which polls /graphics/{id}/cues.json twice a second. A channel on the built-in frame sends them to the node as overlays, in the session and through the update endpoint whenever the set changes (3.53.0 β€” before, they never reached air there; the node must support the field, see the node contract). A normal channel passes video through without re-encoding and cannot draw them.

Dayparts per day of the week 3.58.0

The dayparts above were learned from one Wednesday, and every day used them. A day of the week can now have its own: the profile’s Dayparts for one day of the week, one line each (Sat 00:00 ENG_6, Sat 09:00 ENG_8). A day with lines uses only its own; a day without uses the dayparts above. Learning a log sets its own day’s. A Monday-to-Friday log also sets the default, as learning always did; a weekend log sets the default only while there is none, so learning a Saturday no longer changes the weekdays. Generation uses the day’s dayparts, and learning a library from a log reads that log’s programs against its own day’s β€” so a program carrying a weekend daytime logo is not given it as its own, to carry on every other day. Templates were per weekday already (Assign to weekday).

Clock-change days 3.58.0

A broadcast day on a clock change is 23 or 25 hours long, and a 24-hour log cannot cover it exactly. How ProTrack writes such a day is a question for 3ABN, so the profile’s Clock-change days chooses:

Either way a log is 24 hours of the clock, checked from 00:00:00 to 24:00:00 as on any other day.

3ABN’s answers 3.61.0

3ABN answered PlayoutGo’s ProTrack questions on 13 October 2026 (Moses Primo, Jon Bivin). What each answer changed:

The RapidPlay X2000 format and the weekend logs 3.62.0

3ABN sent the format’s document (Product Architecture for RapidPlayX2000 File Format, version 1.01) and the weekend logs of eight channels (CANADA, CHILD, DARE, ENG, INTL, LAT, PRAIZ, PRO) on 14 October 2026. What they changed:

3ABN requirements: built and tested 3.64.0

Every requirement 3ABN gave the traffic system, where it came from and the test that proves it: 73 requirements, 72 built and tested, 1 partly tested, 0 not built; 13 wait on something only 3ABN can do. Sources: LOG the three sample logs of 14 October 2026, ANS-n 3ABN’s answer n of 13 October, DOC the RapidPlay X2000 format document, WKD the weekend logs of eight channels, OPS what airing their channels needs. This table is the code’s own (requirements3abn.go); a test fails if a row names a test that does not exist or claims a test it does not have.

Log format (ProTrack / RapidPlay X2000)

IDRequirementSourceStatusTests
FMT-01Read and write the 696-character ProTrack record; every log comes back byte for byte (All 19 logs 3ABN sent, through the parser, the database and the API)LOGTestedTestRPXRoundTripsByteForByte
TestImportedLogExportsByteForByte
TestRPXEveryRealLogRoundTrips
FMT-02CRLF line endings; a file converted to LF in transit is read and exported as CRLFLOGTestedTestRPXAcceptsConvertedLineEndings
FMT-03Refuse what is not understood rather than drop it: start copies that disagree, short records, unknown event types, bad timecodes, another day's date, non-ASCIILOGTestedTestRPXRefusesWhatItDoesNotUnderstand
FMT-04Only the reserved columns, 367 and 379–406, must be blank; every documented column is acceptedDOCTestedTestRPXReservedColumnsAreRefused
FMT-05Material ID (house code) in columns 6–35, at most 30 characters; media locator 36–85DOCTestedTestRPXWriterRefusesFieldsThatDoNotFit
TestRPXEveryDocumentedFieldRoundTrips
FMT-06Event ID in columns 86–105, all 20 columns: library (PG/FI) and numberANS-2TestedTestRPXEventIDColumns86To105
FMT-07Video Source (the output) in columns 139–158DOCTestedTestRPXWriterRefusesFieldsThatDoNotFit
TestRPXEveryRealLogRoundTrips
FMT-08Title in columns 269–318 as a field of its own (CANADA's logs carry titles)DOC, WKDTestedTestTitlesAreLearnedAndWritten
TestRPXEveryRealLogRoundTrips
FMT-09Optional fields (locator, backup sources, comment, end method, EOM, switcher levels, record sources, …) kept and written back unchangedDOCTestedTestRPXEveryDocumentedFieldRoundTrips
FMT-10Record types C, B, '-' and H; comment and header lines kept in place (B has no documented meaning: played as an ordinary event)DOCTestedTestRPXEveryDocumentedFieldRoundTrips
FMT-11Event types PR, CO (listed, never aired), OS, and OE (timed from the program's end) (3ABN's logs have no OE event; checked on the document's sample)DOCTestedTestRPXEveryDocumentedFieldRoundTrips
FMT-12FCC types CA (also when blank) and PS acceptedDOC, WKDTestedTestRPXEveryDocumentedFieldRoundTrips
TestDocumentedSampleIsLearned
FMT-13Start method C (clock) or F (follow) in columns 326–327; a follow event with no start time starts where the one before endsLOG, DOCTestedTestRPXEveryDocumentedFieldRoundTrips
FMT-14A log with no ProTrack header line takes its date from its first eventDOCTestedTestRPXReservedColumnsAreRefused
FMT-15What the player does and PlayoutGo cannot (manual or external start, record, dead-roll, switch only, rate other than 1) is a warning, never a refusal (start action and rate warnings first tested in 3.64.0)DOCTestedTestRPXEveryDocumentedFieldRoundTrips
TestIntegrityCheckCatchesEveryKind
FMT-16Drop-frame timecodes (HH:MM:SS;FF) read, kept and written back; a file mixing : and ; is refusedANS-5TestedTestRPXDropFrameTimecodes
FMT-17Generated logs written in drop-frame when the channel's profile says so (00:00:00;00 – 23:59:59;29) (first tested in 3.64.0)ANS-5TestedTestGeneratedLogsUseTheProfilesTimecodes
FMT-18Fixed fields follow the channel's log: control codes, transition, SOM, rate, discrepancy code 0000 (693–696) (on generated records first tested in 3.64.0)ANS-3TestedTestProfileIsLearnedFromTheLog
TestGeneratedLogsUseTheProfilesTimecodes
FMT-19Export file name per channel and day, with the channel's extension (CANADA20261024.txt)WKDTestedTestWeekendLogsAreLearned
TestTrafficReleaseGateAndExport
FMT-20SOM (columns 341–351) is where the file is cued when the day is loaded (frames ignored: the frame rate is not in the file)DOCTestedTestSOMCuesTheFileOnLoad

Integrity check and release

IDRequirementSourceStatusTests
INT-01The day runs 00:00:00 to 24:00:00 with no gap or overlap; the one real gap in ENG (02:59:59) is foundLOGTestedTestIntegrityCheckFindsTheEnglishGap
TestIntegrityCheckCatchesEachProblem
INT-02Up to 10 seconds before a clock start, or at the end of the day, is a warning; longer, or before a follow event, an error (3ABN to say whether the player accepts these few seconds)WKDTested
Answer awaited
TestClockStartGapTolerance
INT-03On a grid channel programs start on the quarter hour, and a program with its breaks fills whole quarter hours (the whole-quarter-hour check first tested in 3.64.0)LOGTestedTestIntegrityCheckCatchesEachProblem
TestIntegrityCheckCatchesEveryKind
INT-04Graphics are attached to a program and inside it: not orphaned, not before it, not past it, on a program, naming an output (three of the five first tested in 3.64.0)LOGTestedTestIntegrityCheckCatchesEachProblem
TestIntegrityCheckCatchesEveryKind
INT-05The right channel's log: its list number (the player's channel number) and outputs match the channel (first tested in 3.64.0)LOG, ANS-9TestedTestIntegrityCheckCatchesEveryKind
INT-06Every program carries its rating graphic; an event with no duration is an error (first tested in 3.64.0)LOGTestedTestIntegrityCheckCatchesEveryKind
INT-07A clock-change day is flagged, and the warning says what loading will do on this channel (by clock time, or by elapsed time) (fixed in 3.64.0: 3ABN's channels were told events would be an hour off)ANS-5TestedTestClockChangeWarningFollowsThePlacement
TestIntegrityCheckCatchesEachProblem
INT-08Only a released log loads; a second release supersedes the first; an imported log with errors needs an acknowledged reason (audited); a generated one with errors cannot be releasedLOGTestedTestTrafficReleaseGateAndExport
TestGeneratedLogCannotBeReleasedWithErrors
INT-09Every kind of problem the check can report has a test that makes it fire (a permanent check added in 3.64.0)LOGTestedTestEveryIntegrityKindIsTested

Loading a day and playing it

IDRequirementSourceStatusTests
LOD-01Load into the Rundown after a dry run: program starts anchored, the rest chained, every file trimmed to the log's length, the day's pending items replaced, a second load not doubled, the past not loadedLOGTestedTestTrafficLoadsADayIntoTheRundown
LOD-02A file plays a house code by its house ID or its file name; Assign file sets the ID; two candidates or a file not ready block loading; a short file warns (the house ID path first tested in 3.64.0)LOGTestedTestMediaIsMatchedByHouseIDOrFileName
TestTrafficLoadsADayIntoTheRundown
LOD-03A channel restarted mid-day resumes the right item at the right offset, trims includedOPSTestedTestRestartPositionHonoursTrims
LOD-04Clock-change days placed by clock time: the skipped hour's events not loaded and the next anchored; the repeated hour aired once (3ABN to say how the player airs that night)ANS-5Tested
Answer awaited
TestWallClockAtOnClockChanges
TestWallClockPlacementOnClockChanges
TestClockChangeDaysAreGeneratedAndLoadByTheClock
TestWallClockAtTakesTheFirstOccurrenceEverywhere
LOD-05A channel set to place by elapsed time asks for an acknowledgement before loading a clock-change dayLOGTestedTestTrafficLoadWaitsForADSTDecision
LOD-06RUSS is autonomous: imported, released and loaded, never generatedANS-8TestedTestAutonomousChannel
LOD-07SRT receivers keep getting the stream through a slate gap before an anchored itemOPSTestedTestSlateGapReachesSRTReceivers

Learning from 3ABN's logs

IDRequirementSourceStatusTests
LRN-01The channel profile learned from its first log: list number, log name, outputs, control codes, graphics timings, station ID, daypartsLOG, ANS-9TestedTestProfileIsLearnedFromTheLog
LRN-02The library learned: programs with their parts and breaks, promos, fillers and IDs; a day template, assigned to a weekdayLOGTestedTestTrafficLearnAndGenerateThroughTheAPI
TestGeneratorRebuildsTheSampleDays
LRN-03Dayparts per day of the week, learned from that day's log; a weekend log does not change the weekdaysWKDTestedTestWeekdayDaypartsAreLearnedAndUsed
TestLearningASaturdayUsesItsOwnDayparts
LRN-04Clock start on every event (CANADA, PRAIZ) learned and writtenWKDTestedTestWeekendLogsAreLearned
TestTitlesAreLearnedAndWritten
LRN-05A later part continues its program (TDYQA250016B; LAT's DLDL000155A off the grid) and is not counted as an airingWKDTestedTestWeekendLogsAreLearned
TestHistoryCountsAiringsNotParts
TestGeneratorRebuildsTheWeekendDays
LRN-06Program titles learned; an operator's own title keptWKDTestedTestTitlesAreLearnedAndWritten
LRN-07Station ID rotation learned (DARE six, PRO four)WKDTestedTestWeekendLogsAreLearned
LRN-08Each channel's Up Next prefix learned (X, IDX, CX, DDX, LX, PX; none on CHILD and PRAIZ) (3ABN to say why some weekend hours have no Up Next spot)WKDTested
Answer awaited
TestWeekendLogsAreLearned
LRN-09A logo starting with its program is not taken for a rating (CANADA's CAN_W)WKDTestedTestWeekendLogsAreLearned
TestGeneratorRebuildsTheWeekendDays
LRN-10Spots (CA) and PSAs (PS) learned into the library; they air only as fixed spotsDOCTestedTestDocumentedSampleIsLearned
LRN-11A flat log (RUSS): programs on the hour anchored; one-part programs of 5 minutes or less learned as fillersLOGTestedTestFlatAnchorsHoldTheClock

Building days

IDRequirementSourceStatusTests
GEN-01Each sample day rebuilt from what was learned: every program, part, start time, graphic and closing ID where ProTrack put itLOGTestedTestGeneratorRebuildsTheSampleDays
GEN-02Each weekend day of the eight channels rebuilt the same way, with the same Up Next spots and rotating IDsWKDTestedTestGeneratorRebuildsTheWeekendDays
GEN-03Breaks filled to the second from active promos and fillers; a break nothing fills is left open and namedLOGTestedTestFillerIsExactAndRotates
GEN-04When no combination fills a break exactly, it may end up to 10 seconds early, after its station ID, as ProTrack's do (same question as INT-02)WKDTested
Answer awaited
TestGeneratorRebuildsTheWeekendDays
GEN-05Rotation: no promo in consecutive breaks; rotation_days looks back up to 14 days of logsLOGTestedTestFillerIsExactAndRotates
TestRotationDaysLooksFurtherBack
GEN-06Promo minimum gap (promo_min_gap_secs), across midnight (administration's promo rules not received)ANS-10Tested
Answer awaited
TestPromoMinGapKeepsAPromoOutOfTheHour
TestPromoMinGapIsRelaxedOnlyWhenABreakCannotBeFilled
TestPromoMinGapThroughTheGenerator
GEN-07Promo airing rules: days and times, first and last day, at most so many a day; never relaxed (built; the rules themselves are administration's list, not received)ANS-10Tested
Answer awaited
TestAiringRules
TestAiringRulesThroughTheAPI
GEN-08Airing numbers: one per airing, shared by its parts, never reused by another day or another itemLOG, ANS-2Tested
Confirm in a test import
TestTrafficLearnAndGenerateThroughTheAPI
TestNumberEveryEvent
GEN-09Promos, fillers and IDs with no library number given event IDs from the same counter (number_every_event) (does the player accept PlayoutGo's event IDs on Up Next spots?)ANS-2Tested
Confirm in a test import
TestNumberEveryEvent
GEN-10Series: same, next or cycle per block, from 92 days of logs; learned from the logs (which programs repeat and which air new episodes)LOG, ANS-7Tested
Answer awaited
TestSeriesAreLearnedAndAdvance
TestSeriesInTheRussianAndInternationalDays
GEN-11A series starts over when its episodes run out (learned blocks cycle)ANS-7TestedTestSeriesAreLearnedAndAdvance
GEN-12Series starting points: a chosen episode from a chosen day (no starting points received)ANS-7Tested
Answer awaited
TestSeriesStartingPoint
GEN-13Up Next spots X{DOW}{HH}{MM}S, Sunday = 01, 15 seconds, a promo, opening the break before the program they nameANS-6TestedTestUpNextSpotsNameTheNextProgram
TestHourSpotsAreLearnedAndOnlyOpenTheirBreaks
TestProfileValidates361
GEN-14An Up Next spot marked ID (X050000S) is learned as a promo, and the notes say soANS-6TestedTestUpNextSpotsNameTheNextProgram
GEN-15Fixed spots at an exact second, inside the break that holds them, never filling other breaksLOGTestedTestFixedSpotsAirAtTheirTime
TestAFixedSpotNeverFillsOtherBreaks
GEN-16A flat day holds its anchors; time before one is filled from fillers; an overrun is reportedLOGTestedTestFlatAnchorsHoldTheClock
GEN-17Station IDs rotate, the least recently aired first, leaving room for the break's Up Next spotWKDTestedTestGeneratorRebuildsTheWeekendDays
GEN-18CANADA's generated logs carry titles and a clock start on every eventWKDTestedTestTitlesAreLearnedAndWritten
GEN-19The clock-change days are generated as 24 hours of the clock, release without an acknowledgement, and load by the clock (first tested in 3.64.0; how the player airs that night is 3ABN's to confirm)ANS-5Tested
Answer awaited
TestClockChangeDaysAreGeneratedAndLoadByTheClock
GEN-20A generated log imports into 3ABN's player (3ABN said generated logs can be imported; the test packs of 3.58.0, 3.61.0 and 3.62.0 are not yet imported)ANS-1Tested
Confirm in a test import
TestRPXEveryRealLogRoundTrips
TestTrafficLearnAndGenerateThroughTheAPI

Graphics

IDRequirementSourceStatusTests
GFX-01Rating for 15 seconds at the start of every part (all 197 graphics events in the samples)LOGTestedTestSecondaryRulesReproduceTheSamples
GFX-02Logo from 1:30 into each part to 2:00 before its end, chosen by daypartLOGTestedTestSecondaryRulesReproduceTheSamples
GFX-03E/I label from 1:30 to the end of each part of a children's program, on its own outputLOGTestedTestSecondaryRulesReproduceTheSamples
GFX-04_8 (800 number) only 08:00–17:00 Monday to Thursday; _6 (618 number) at all other times (do channels other than ENG have business-hours logos?)ANS-4Tested
Answer awaited
TestEIFollowsTheDaypart
TestWeekdayDaypartsAreLearnedAndUsed
GFX-05E/I follows the daypart: _8_E in business hours, _6_E otherwiseANS-4TestedTestEIFollowsTheDaypart
GFX-06Graphics reach air: a page channel draws them from the cues; the built-in frame is sent them as overlays (drawing them on the built-in frame needs a gpuff release that draws overlays)OPSPartly testedTestCueStateFollowsTheSecondaryTiming
TestCuesEndpointReportsTheItemOnAir
TestBugLayerInBrowser
TestBuiltInFrameCarriesTheTrafficGraphics

Waiting on 3ABN

What the RPX format holds

Lines of 696 characters, CRLF after each. The first is usually ProTrack’s header (-, 22 spaces, the log name, 2 spaces, Log for: MM/DD/YYYY); then one record per event. Columns 2–5 are the channel (player) number (0001 ENG, 0002 LAT, 0003 INTL, 0004 DARE, 0005 RUSS, 0006 CHILD, 0007 PRO, 0009 PRAIZ, 0020 CANADA). Columns 6–35 are the material ID β€” the house code. Columns 86–105 are the event ID, the scheduler’s (3.61.0): 86–87 the library (PG program, FI everything else), 88–105 its number β€” per airing for programs. Start and duration appear twice on primary events (106–138 and 407–428); a graphic has no absolute start, only a duration and an offset from its program in 407–428. Columns 139–158 name the output, 269–318 the title, 319–322 the type. Columns 326–327 are the start method: C on the first part of a program (on every event for CANADA and PRAIZ) and F on everything else. The rest are described in the RapidPlay X2000 format; rpx.go has the full column map.

Traffic API

GET /api/traffic/channels/{id}/logs
The channel's logs, newest day first, with status and event counts.
πŸ” Requires auth + ownership
POST /api/traffic/channels/{id}/import
{"filename","content"} β€” an RPX file as text. Stored as a draft. 422 with the line and column when the file is not one PlayoutGo can import. profile_learned says whether the channel's profile was learned from it.
πŸ” Requires auth + ownership
POST /api/traffic/channels/{id}/generate
{"date":"YYYY-MM-DD","template_id"} β€” builds a day; template_id 0 uses the template assigned to that weekday. Returns the draft and any notes (problems left visible in the log).
πŸ” Requires auth + ownership
GET /api/traffic/channels/{id}/profile
The automation profile. PUT replaces it (weekday templates must be the channel's own). 3.53.0 adds hour_spot (a pattern with {HH} and optionally {DOW}, like X{DOW}{HH}00S; blank for none) and rotation_days (0–14). 3.58.0 adds weekday_dayparts (seven lists, Sunday first, each like dayparts; an empty list uses dayparts) and dst_mode (elapsed or wallclock; clock-change days). A load plan says placement and, on a clock-change day placed by clock time, lists skipped_dst. 3.59.0 adds promo_min_gap_secs (0–86400; promo minimum gap). 3.61.0 (3ABN’s answers) adds ei_suffix, autonomous, number_every_event, drop_frame and series_starts (a list of {"series","episode","from"}); hour_spot takes {MM}. An interstitial takes rules: airs (text, returned normalised, with the parsed windows), start_date, end_date, max_per_day. 3.62.0 (the RapidPlay X2000 format) adds clock_every_event, titles, file_ext (blank or .RPX, or a dot and up to 8 letters or digits) and id_codes (a list of station ID codes). A log’s events carry title and fields (the optional columns by name; keys starting _ say how the record was written where PlayoutGo normalises it), and the log extra_lines (its comment and header lines).
πŸ” Requires auth + ownership
GET /api/traffic/channels/{id}/library
Programs and interstitials. POST /api/traffic/channels/{id}/programs or …/interstitials adds or replaces one by house code; DELETE …/programs/{code} removes one. A program takes series (blank: read from the code) and filler (one-part programs only) since 3.53.0.
πŸ” Requires auth + ownership
GET /api/traffic/channels/{id}/templates
Day templates. POST creates (id 0) or updates one; DELETE …/templates/{templateId} removes it and unassigns its weekdays. A block is {start_sec, len_sec, program, mid_breaks, id_at_end}, and since 3.53.0 also series with episode same|next|cycle (next when omitted), and on a flat template anchor_sec. fixed_spots is [{code, at_sec}].
πŸ” Requires auth + ownership
GET /api/traffic/channels/{id}/media
The files the channel can play, with their house IDs.
πŸ” Requires auth + ownership
POST /api/traffic/channels/{id}/assign
{"code","file_id"} β€” gives one of the owner's files a house ID; any other file holding it loses it.
πŸ” Requires auth + ownership
GET /api/traffic/logs/{id}
The log, its events, the integrity issues and the media match per house code. DELETE removes a log that is not released.
πŸ” Requires auth + ownership
POST /api/traffic/logs/{id}/release
{"acknowledge","note"} β€” 409 with the issues when the log has integrity errors (imported logs: unless acknowledged with a reason; generated logs: always).
πŸ” Requires auth + ownership
POST /api/traffic/logs/{id}/load
{"dry_run","acknowledge_dst"} β€” loads a released log into the rundown, or says what it would do. 409 while house codes have no playable file, and on a daylight-saving day until acknowledge_dst.
πŸ” Requires auth + ownership
GET /api/traffic/logs/{id}/rpx
The log as an RPX file (Content-Disposition carries its name).
πŸ” Requires auth + ownership
POST /api/traffic/logs/{id}/learn
{"profile","library","template","template_name","weekday"} β€” learns from the log. Existing library entries are never changed.
πŸ” Requires auth + ownership
GET /graphics/{id}/logo
The logo uploaded for the channel (PNG or JPEG), which the L-shape uses when logo_url is empty (3.53.0). Unauthenticated like the graphics page β€” the node fetches it β€” and 404 when none is uploaded.
🌐 Public
GET /graphics/{id}/cues.json
Which traffic graphics are on air now, with image, corner and size β€” read by the channel's graphics page. Unauthenticated like the page, and readable from its sandboxed origin.
🌐 Public

Application Keys

GET /api/appkeys
Keys for the read-only application API. POST to issue one β€” the secret is shown once and stored only as a hash, so it cannot be recovered, only replaced. DELETE /api/appkeys/{id} revokes one, which takes effect immediately rather than when a cache expires.
πŸ” Requires auth
GET /api/app/channels
Channel lineup for a television application, authenticated with an application key rather than an account. /api/app/channels/{id} for one channel. Read-only by design: a key embedded in a shipped application cannot change anything.
πŸ”‘ Application key

Playback, As-Run and Metrics

GET /api/hls-proxy/token
A token for URLs a player cannot add a header to, valid six hours, for reading only: it is accepted on /api/hls-proxy, /api/files/{id}/… and /api/stream/stats*, for GET and HEAD, and nowhere else (the three tokens). The account token is not placed in playlist URLs: those are handed to players, logged by caches and shared in support threads, and a token that only reads is worth far less if it escapes.
πŸ” Requires auth
GET /api/asrun
What actually played, and when β€” the record a broadcaster reconciles against. Filterable by channel and date range; quick ranges use the channel's local calendar dates rather than UTC, because a broadcast day is a local thing.
πŸ” Requires auth
GET /metrics
Prometheus format. Channel state, delivery, encoding capacity and queue depth, plus constant-rate padding and clock health for broadcast channels. playoutgo_channel_pcr_late_total above zero is a feed that will fail an operator's ingest test β€” the figure worth alerting on.
🌐 No token: only addresses in metrics_allow_from (404 otherwise)

Changelog

v3.64.0 — 3ABN’s Requirements, Each Traced to Its Test

Every requirement 3ABN gave the traffic system — from the sample logs, the ten answers, the RapidPlay X2000 document and the weekend logs — is now a row in a table the code keeps true, each with the test that proves it: 3ABN requirements. Tracing them found eleven requirements with no test, or only part of one, and one wrong message on the clock-change days, which matter to 3ABN on 1 November.

New

Fixed

Verification

Known gaps

v3.63.0 — UDP/RTP Multicast Output and Alert Webhooks

Two of the features the roadmap listed as not built: the transport stream over UDP or RTP to a multicast group or an address (UDP / RTP output), and alert webhooks, so the server tells an operator’s systems when something goes wrong on air (alert webhooks).

New

Fixed while building this release

Verification

Known gaps

v3.62.0 — The RapidPlay X2000 Format, Eight 3ABN Channels, and SDI Output

3ABN sent the RapidPlay X2000 format document and the weekend logs of eight channels. The traffic system now reads every column the document defines and handles what the weekend logs showed: the RapidPlay X2000 format and the weekend logs says what each changed. And a channel can now play out of an SDI port on a Blackmagic DeckLink card (SDI output).

New

Fixed

Verification

Known gaps

v3.61.0 — 3ABN’s Answers in the Traffic System

3ABN answered the ProTrack questions (Moses Primo, Jon Bivin, 13 October 2026), and the traffic system now follows the answers instead of the guesses made from three sample logs: 3ABN’s answers says what each one changed.

New

Fixed

Verification

Known gaps

v3.60.0 — Conform on Ingest, Loudness, and Two-Factor Sign-In

Three of the features the code check found missing. A file with AC-3 audio β€” the usual audio of a US broadcast master β€” no longer airs mute, and MOV, MXF, transport streams and HEVC or ProRes masters are converted at ingest instead of refused. Every file’s loudness is measured, and with a target set (βˆ’24 LUFS for the CALM Act) a file outside it is normalised. Accounts can add an authenticator-app code to their password. The features still not built are listed in the roadmap.

New

Fixed

Verification

Known gaps

v3.59.0 — The Installer on a Real systemd, HTTPS from Your Own ACME CA, and a Promo Gap for 3ABN

Until now the installer’s systemd path had only met a stand-in systemctl, and built-in TLS had never been issued a certificate. This release ran both for real: the shipped bundle on a real systemd (version 255, as PID 1 of its own namespaces) and built-in TLS against Pebble, Let’s Encrypt’s ACME test server, on the real ports 443 and 80. That found two installer defects, fixed here. Built-in TLS can now use an ACME CA of your own, and traffic gains the promo limit 3ABN’s answer on promo rules will set.

New

Fixed

Verification

Known gaps

v3.58.0 — Crash-Safe As-Run, Alert Rules, HTTPS from the Installer, and Traffic Options for 3ABN

The as-run log keeps an item’s airtime even when the process dies, the bundle ships alert rules, and the installer sets up built-in HTTPS β€” and upgrades a host that runs it, which until now always rolled itself back. Traffic gains the two options 3ABN’s answers will choose between: how a clock-change day is placed, and dayparts per day of the week. A test pack of generated days for 3ABN to import into ProTrack was built with the real binary.

New

Fixed

Verification

Known gaps

v3.57.0 — The Production Release: a Deploy Bundle, an Installer That Rolls Back, a Hardened Unit, a Configuration Check, Process Metrics, an Hour-Long Soak

Everything needed to put PlayoutGo on a server and keep it there: one archive with the binary, the unit and an installer; an upgrade that checks itself first and undoes itself if the new version does not come up; a unit that confines the service; a check that names configuration mistakes before a start; and figures for a monitoring system to see a leak coming. The server was run for an hour with five channels on air to prove it holds steady. Five defects found on the way are fixed.

New

Fixed

Verification

Known gaps

v3.56.0 — Every Parser Fuzzed; Features Aired Together; What They Found, Fixed

A hardening round. Every part of the server that reads bytes from outside now has a fuzz target, and features that had each been tested alone were aired together to a real receiver and checked as a stream monitor would (fuzzing and combinations). Between them they found the defects below, each fixed with a test that fails without the fix. Three of them could take the whole server down.

Fixed — found by fuzzing

Fixed — found by airing features together

New — the tests

Verification

Known gaps

v3.55.0 — Audio Renditions Through Dead Air; Measured Masters with RESOLUTION; Every Change Audited; Exact Per-Item Bytes

Closes the four limits 3.54.0 documented. An audio rendition has its own segments through dead air. Every master β€” single-variant and ABR β€” states RESOLUTION, and CODECS and BANDWIDTH measured from what the window holds. Every request that changes state records an audit entry, and a check that reads the code fails the build when one does not. The as-run log's bytes are the item's own, exactly. What is left needs the operator (roadmap).

New — the HLS presentation

New — every change audited

(audited actions, the coverage check)

Fixed

Verification

Known gaps

v3.54.0 — An HLS Master Playlist with Audio Renditions and Captions; ABR Items in Full; GPU Decoding; the Keyring off the Host; Every Audited Action Listed

The last implementation round. A multi-language or captioned channel's index.m3u8 is now a real master playlist, so players offer an audio menu and Apple players a captions menu. An item airing as an ABR ladder now gets everything a single-rendition item gets. This server's cards can decode and scale as well as encode. The secrets keyring can come from a key-management service, a descriptor, the environment or a TPM-sealed systemd credential and never touch the disk. The manual lists every action the audit trail records, and a test keeps it complete. What is left needs the operator (roadmap).

New — the HLS presentation

New — ABR items in full

(ABR items)

New — encoding and security

Fixed

Verification

Known gaps

v3.53.0 — The Traffic Scheduler Finished; Traffic Graphics and the Uploaded Logo on the Built-In Frame; Six Engine Gaps Closed

Generated days now follow series, place the hour spots and fixed spots where 3ABN's logs have them, rotate promos over several days, and hold a flat day's clock to its anchors. Templates are edited in a table. The built-in L-shape frame is sent the traffic graphics and the uploaded logo. Six engine gaps the 3.52.0 audit listed are closed, each with a test that fails without the fix.

New — traffic scheduler

New — graphics

Fixed

Verification

Known gaps

v3.52.0 — The Manual, Checked Against the Code; Live Insertion, SCTE-35 and DVB Fixed on the Wire

This release audited the manual line by line against the code and rewrote what had stopped being true. It also added a feature reference of every feature with its version, a roadmap of what is left, sections for features that were described only here in the changelog, and a README.md.

The audit measured each feature on the wire, and several did not work as described:

All of these are fixed, and each fix is measured by a test that fails without it.

New

Fixed β€” live insertion

Fixed β€” SCTE-35 and HLS

Fixed β€” DVB and constant rate

Fixed β€” engine, encoding and audio

Fixed β€” the console

Security

Verification

Removed

Known gaps

v3.51.0 — Encoding on This Server's Own GPUs; One Content Policy for Every Page; No Native Dialogs

A server with NVIDIA cards can now encode on them, each card taking work from the same queue as the remote nodes. Every page this binary serves now runs under script-src 'self', which closes audit U-11. The console's last 66 native prompt/confirm/alert dialogs are gone, which closes U-10. The work turned up eight defects that were already shipped, listed under Fixed. The stream viewer had not played anything since 3.47.0.

New — this server's NVIDIA cards as encode capacity

Fixed

Security — U-11 and U-10 closed

Verification

Removed

Known gaps

v3.50.0 — No Inline Script on the Console; a Constant Rate from the First Second

The console now runs under script-src 'self' alone, the first half of audit U-11. A constant-rate SRT channel no longer goes silent when a receiver connects. Building and proving those turned up two more defects: the Help tab had been blank since 3.47.0, and an SRT refusal burst could go unreported.

Fixed

Security — U-11, the console's script

Correction

Removed

New permanent checks

Known gaps

v3.49.0 — Traffic: Broadcast Logs, Templates and a Scheduler

A new πŸ“‘ Traffic tab plans the broadcast day before it plays. It reads and writes ProTrack RPX logs, keeps a library of programs and interstitials, builds days from templates, and loads a released day into the Rundown. It was built against three real 3ABN logs (English, International, Russian for 14 October 2026), which are now the test suite's fixtures. See Traffic.

New

What the 3ABN logs taught

Fixed β€” found while building this

New permanent checks

Known gaps

v3.48.0 — Secrets That Stay Secret, Time That Means One Thing

3.47.0 closed the audit’s blockers and listed what it had not done. This release does the data-model half of that list: recoverable secrets are encrypted at rest, every stored timestamp has one meaning, and the four columns 3.47.0 left in the old form are migrated. Proving those changes on real traffic turned up three further defects that no audit had found, one of them critical: an SRT passphrase did not protect a listener-mode stream at all.

Found while proving this release

Secrets at rest

One meaning for every timestamp

Accessible dialogs (audit U-10)

New permanent checks

Upgrade actions

  1. Back up secrets.key separately from your database snapshots.
  2. Delete or secure snapshots taken before 3.48.0; they hold plaintext secrets. Startup lists them.
  3. Press New key in each channel’s live-insertion panel and update its contribution encoder. Startup lists the channels still on a guessable key.
  4. Give SRT receivers the passphrase. Listener-mode channels with a passphrase now refuse receivers without it.
  5. If you already rotated the JWT secret after 3.47.0, reconnect YouTube accounts: tokens encrypted under the old secret could not be read and cannot be recovered.

Known gaps

v3.47.0 — The Third-Party Audit, Worked Through

An external audit of the 3.46.0 source and interface found 3 release blockers, 6 high-priority issues, 8 medium ones and 12 interface/accessibility findings. This release works through them in the order the audit recommended. It also fixes two defects the audit could not find, because its sandbox had no access to the Go module proxy and therefore could not run the test suite — four as-run tests fail in the shipped 3.46.0 release.

Release blockers

Found by running the test suite, which the audit could not

Service boundaries

Interface correctness

Browser policy

Accessibility and small screens

Build and release engineering

Known gaps

v3.46.0 β€” A Durable Record of Encode All

Fixed

Why a batch can vanish from the panel

Three reasons, all normal, worth knowing before hunting a fault:

The durable answer to “was this encoded?” is the file itself β€” GET /api/files/{id}/renditions, or the Media page β€” and from this release, the audit trail for the action.

v3.45.0 β€” The Constant-Rate Bug, Closed

Fixed

Measured after the fix: a 2 Mb/s programme on an 8 Mb/s channel transmits 20.15 MB with 18% padding, where before it transmitted zero bytes.

Two earlier attempts are worth recording because both were wrong in instructive ways. Chunking the write did nothing β€” the SRT writer already chunks internally. Capping the elapsed time fixed the wire and broke a legitimate case, a caller writing once a second, whose padding collapsed and whose rate sagged by half. The constraint is the size of one write, not the size of the budget.

v3.44.0 β€” Break Now

Added

v3.43.0 β€” A Bad Padding Write No Longer Kills the Output

Fixed

Open β€” read this before configuring a constant rate

A channel whose CBR target sits well above its programme rate transmits nothing over SRT. Reproduced repeatedly: a 2 Mb/s programme in a 12 Mb/s slot gives zero bytes, with one language or four; the same channel with CBR off delivers normally. The receiver connects, then the connection closes under padding load a few seconds later.

It was missed until now because the earlier verification set a target below the programme rate, where padding is a rounding error β€” correct as far as it went, and never the case the feature exists for.

Ruled out: multi-language (single fails identically), write size (the SRT writer already chunks to 1316 bytes), and burst interval (already 20 ms). The remaining candidate is the socket’s bandwidth ceiling, which is left at the library default unless a channel sets srt_max_bw. Until this is closed, do not run a constant rate above the programme rate over SRT β€” the channel will look healthy and transmit nothing. HLS is unaffected.

v3.42.0 β€” Four Languages Down One Pipe

Fixed

Verified against a real RTMP server with a four-language channel: 1344 main-track frames sent, 1775 alternate samples dropped, and the received audio decodes at 440 Hz with no complaints β€” one language, not four mixed.

Third fault in three releases found the same way: by running two finished features together. Each was correct alone.

v3.41.0 β€” Splice Information Was About to Land on the German Track

Fixed

Verified with four languages and ad breaks on one channel: audio on 0x0101–0x0104 labelled eng, spa, fra, deu, and splice information on 0x0114 with sections actually on it. A test pins the choice.

Nothing found this by reading. It came from putting two finished features on one channel β€” four soundtracks and ad breaks β€” which nothing had done before, because each was tested alone.

v3.40.0 β€” An Audio Menu That Stops Changing Under the Viewer

Fixed

This is what a broadcaster does anyway: the audio structure of a linear channel stays constant whether or not a given programme has the alternate language.

v3.39.0 β€” A Deployment Guide

Added

Each claim in it was checked against the running server rather than written from memory: the health check answers unauthenticated, metrics really are 404 until metrics_allow_from is set, restore really does demand {"confirm":"RESTORE"}, and backups really are verified nightly with a full integrity check before being kept.

Two things it says that are easy to get wrong. Certificates are obtained here, not by a proxy β€” with TLS enabled the server renews them itself and needs port 80 for the challenge; put a proxy in front and TLS must be turned off here, because two things renewing one certificate is a bad afternoon. And relative paths resolve against WorkingDirectory, not against wherever anyone tested from.

v3.38.0 β€” A Health Check, and No Leak

Added

Measured

v3.37.0 β€” Does It Play, and How Many Fit

Measured

v3.36.0 β€” An Adversarial Pass

Verified

No faults found. Recorded because “we checked and it held” is worth as much as a fix when the question comes up again.

v3.35.0 β€” Restore and Restart, Exercised

Verified

Audit totals: 61 + 43 API checks, 35 in each browser.

v3.34.0 β€” Closed Captions, Finally Tested

Verified

Worth recording why this mattered. The existing test proved a caption SEI survives Annex-B conversion and PlayoutGo’s own muxer β€” but the programme reaches air through a different muxer, which is precisely the gap that hid the SCTE-35 declaration bug for months. A test can be correct, meaningful, and about the wrong object.

v3.33.0 β€” Multi-Language Audio in HLS, and a Second Language That Was Noise

Added

Fixed

Verified by decoding: the source carries a 440 Hz English track and an 880 Hz Spanish one, and the two streams off air measure 439 Hz and 880 Hz. Counting streams would have passed while one of them was noise.

v3.32.0 β€” Multi-Language Audio on the Transport Output

Added

Fixed

Verified on a file with English and Spanish tracks: the program map reads AAC PID 0x0101 lang=eng and AAC PID 0x0102 lang=spa, both streams carrying packets, and ffprobe lists three streams where it previously listed two.

Still to come

HLS carries the first track only. Offering the others there needs EXT-X-MEDIA audio groups in the master playlist, which is the next piece of this feature rather than part of it.

v3.31.0 β€” Connect a YouTube Account

Added

Not built, deliberately

Signing in with a Google password. Google blocks programmatic password sign-in for consumer accounts, so it would fail on every real customer β€” and a Google password unlocks Gmail, Drive and Ads, not just the one channel an operator meant to grant. A refresh token is limited to YouTube and the account holder can revoke it themselves.

Fixed

Verified end to end against a stand-in for Google β€” consent URL, code exchange, token storage, account identity, broadcast creation, ingest address on the channel, key redacted, disconnect: 13 checks, all passing. The stand-in is in the repository as audit/fake-google.py. Not yet verified against a real Google account, which needs credentials this environment does not have.

v3.30.0 β€” A Guide for the Push, and Where the Stream Key Comes From

Added

Checked at desktop and phone widths: five steps, two tables, no horizontal overflow.

v3.29.0 β€” Fields That Were Saved and Never Came Back

Fixed

Checks added

Totals: API 59/59 Β· second audit 37/37 Β· Firefox 35/35 Β· WebKit 35/35, suite green under -race.

v3.28.0 β€” Push a Channel to YouTube

Added

Fixed while building it

Verified against a real RTMP server: 35 MB received, h264 1280×720 + AAC 44.1 kHz, and a decoded frame matching the source picture. Audit 59/59, Firefox 35/35, WebKit 35/35.

v3.27.0 β€” Saying When a Rate Cannot Be Held, and When a Code Cannot Be Burned

Fixed

Added

v3.26.0 β€” SCTE-35 Declared Where a Splicer Reads It

Fixed

Worth knowing

A test named TestPMTDeclaresSCTE35 has been passing for months. It checks the engine’s own muxer, which was never the problem β€” a green test proving the wrong map. The new one beside it checks the map that actually leaves the machine, and both now run.

Also added to the repository: a raw SRT receiver, because ffmpeg -c copy discards packets on undeclared PIDs and rewrites the PSI with its own. Two conclusions in this project were wrong because the instrument, not the code, was removing the evidence.

Verified

PMT reads type 0x1B H.264 / type 0x0F AAC / type 0x86 SCTE-35 PID 0x0104, sections decode as splice_insert, DVB service reads TULIX ONE from Tulix Systems. Audit 59/59, Firefox 34/34, WebKit 34/34, suite green under -race.

v3.25.0 β€” What SCTE-35 Actually Does

Tested

Found β€” and stated rather than papered over

v3.24.0 β€” Proving the Node Does the Work

Verified end to end against gpuff 1.8.3

Added

Full audit: 59/59 API checks against gpuff 1.8.3, with 34/34 in each browser.

v3.23.0 β€” Documenting What We Send a Node

Added

Verified against gpuff 1.8.3

v3.22.0 β€” A Functional Audit, and Two Faults It Found

Fixed

Added β€” the audit itself

Result: API 57/57, Firefox 34/34, WebKit 34/34, no JavaScript errors.

v3.21.0 β€” Live Insertion, In and Out

Fixed

Proven in both directions from the channel’s own output: schedule at luma 125.6 β†’ take β†’ 78 / chroma 187 (the live feed) β†’ return β†’ 125.6 with segments flowing again.

v3.20.0 β€” Tell the Node the Sample Rate

Added

v3.19.0 β€” Collections, and Drop Boxes That Feed a Channel

Added

Fixed while building it

Each of those was silent and none would have failed a compile. The duplicate-function guard added in v3.13 caught a fourth during this work β€” a second renderBulkBar that would have replaced the rundown’s and broken its bulk actions.

v3.18.0 β€” The Manual Now Shows You What To Send

Added

Checked in a browser: every table-of-contents link resolves, no horizontal overflow, 110 code blocks and 19 tables render.

v3.17.0 β€” Sound Stops When You Leave, and an Overview Page

Fixed

Added

v3.16.0 β€” The Live Cut Reaches Air, and Safari Is Checked

Fixed

Verified

v3.15.0 β€” L-shape Works, and the Live Buttons Were Dead

Fixed

Verified

Checks added

v3.14.0 β€” The QR Preview Never Worked, and Warnings Now Say What To Do

Fixed

Added

v3.13.0 β€” A Sweep for the Mistakes That Do Not Announce Themselves

Fixed

Checks added, because finding these one at a time in production is not a method

Result of the sweep: no duplicate functions, no undefined handlers, no unrouted calls, and zero JavaScript errors across every page and handler exercised.

v3.12.0 β€” The Preview Panel, Actually Fixed

Fixed

Three earlier releases attacked this from the wrong end β€” rebuilding a removed element, sizing through the player API, adding error notes. Each was a real fault and none was THIS one, because the panel had never been opened against a live channel in a browser here. It has now.

v3.11.0 β€” Failures That Say Why, and Retries That Work

Fixed

Documentation

v3.10.0 β€” Retrying a Failed Encode

Fixed

A test asserted the old behaviour β€” that a ladder missing a rung counted as encoded β€” so it was corrected along with the code, and now pins the opposite: a partial ladder is unfinished work, and an empty file is not a rendition.

v3.9.0 β€” Encode Again

Changed

Verified against a running system: a cancelled job re-queued as a new attempt (R.mp4 queued again as single 480p @ 800k) with the old row gone, a finished job refused with 409, and an unknown job with 404.

v3.8.0 β€” Stop Contradicting Yourself About the GPU

Fixed

v3.7.0 β€” Say Which Machine, Which Encoder, and Which Channel

Fixed

v3.6.0 β€” Let the Node Draw the Bar

Fixed

v3.5.0 β€” Branding as Pixels, Not a Page

Changed

Fixed

v3.4.0 β€” L-shape Reaches the Picture

Fixed

Verified against gpuff 0.84.0

A composited channel drove a session end to end and the branding is on the picture: measured in the composited output, the two branding bands read 235 luma against 151.8 in the programme area β€” an 83-point difference exactly where the page paints.

Open: both bands render near-white where the page specifies a dark bar and a blue ticker. The overlay is unmistakably present and correctly placed; its colours are not yet what the page asks for.

v3.3.1 β€” A Publishing Point for Channels Made After Startup

Fixed

v3.3.0 β€” L-shape Comes Back, on gpuff 0.78.0

Added

Verified against gpuff 0.78.0

v3.2.0 β€” Live Insertion

Added

Configure the port with "rtmp_port" (default 1935; -1 disables the listener entirely for a deployment that does not want the port open).

v3.1.1 β€” Verified Against gpuff 0.49.0, Unmodified

No changes were needed to talk to 0.49.0. Checked against a node built from the released source with nothing patched: health parses, an ABR ladder and a constant-bitrate rendition both encode to done, and cbr reaches the node’s job specification.

v3.1.0 β€” Verified Against gpuff 0.48.0

Fixed

Added

Verified against gpuff 0.48.0

v3.0.1 β€” gpuff 0.44.1, and CBR That Actually Reaches the Encoder

Fixed

Verified against gpuff 0.44.1 with the CBR patch: a channel whose profile asks for a constant rate produced done, 100%, “ready” with spec.cbr = true recorded on the node.

v3.0.0 β€” Works With gpuff As Well As gpunode

Fixed β€” four interop defects, all the same shape

Each one was this server assuming the node was gpunode specifically rather than reading what the node actually said. Against gpuff β€” whose /v1 is a superset β€” every one broke the exchange completely, because Go rejects an ENTIRE JSON document over one mismatched field.

Fixed β€” the message that hid all four

Verified end to end against gpuff 0.43.0: a two-rung ABR ladder placed, encoded and recorded β€” done, 100%, “ready β€” 480p, 720p” β€” with both renditions at the requested bitrates. gpunode remains fully supported; every shape above is tested in both dialects.

v2.99.0 β€” Constant Bitrate Never Reached the Programme

Fixed

This is the defect the conformance test was built to find, and it found it. Three sessions of indirect measurement pointed at the pacer’s arithmetic, which was correct all along; what was wrong was where it had been wired.

v2.98.0 β€” The CBR Test Now Checks the Padding

Changed

Still five minutes maximum, still stops itself, still refuses a channel that is not on air, has no CBR rate, or publishes only HLS.

v2.97.0 β€” Constant Bitrate, End to End

Added

v2.96.0 β€” Constant Bitrate Ran Above Its Rate

Fixed

Added

Stated plainly

v2.95.0 β€” Filling a Whole Day, Honestly

Fixed

Added

Verified β€” no change needed

v2.94.0 β€” Alternate Languages Stop Vanishing Quietly

Fixed

Added

Stated plainly, not implemented

v2.93.0 β€” CBR Conformance Test

Added

v2.92.0 β€” Nothing Succeeds Silently

Fixed

Checked and found correct β€” no change needed

v2.91.0 β€” Renditions Stop Advertising Zero

Fixed

Checked and found correct β€” no change needed

v2.90.0 β€” When Encoding Stops, You Are Told

Fixed

Checked and found correct β€” no change needed

v2.89.0 β€” The Guide Stops Showing Filenames

Fixed

Checked and found correct β€” no change needed

v2.88.0 β€” 360p Rung, and the 4K Threshold Corrected

Changed

v2.87.0 β€” House Ladder, and an Audit Trail That Covers the Schedule

Changed

Fixed

v2.86.0 β€” A Rejected Upload Now Says Why

Fixed

Checked and found correct β€” no change needed

v2.85.0 β€” A Waiting Channel Now Transmits Something Playable

Fixed

Checked and found correct β€” no change needed

v2.84.0 β€” The Rundown Now Shows What Is On Air

Fixed

Found by exercising a live rundown rather than reading it: the engine’s log said “Playing: A.mp4” while the API serving the UI reported the same item as pending.

The same sweep confirmed the destructive edits are already safe: deleting the on-air item is refused, emptying the rundown mid-broadcast leaves the channel in a defined state and keeps transmitting padding, and the server survives every step.

v2.83.0 β€” Two Bugs From Hammering the Lifecycle

Fixed

Both were found by exercising the running system rather than reading it: ten start/stop cycles, four simultaneous starts, deleting the file that was on air, and deleting a channel mid-broadcast. The same sweep confirmed what already held β€” no ffmpeg processes leak across start/stop cycles, deleting an on-air file is refused with 409, deleting a running channel leaves nothing behind, and the server stays healthy throughout.

v2.82.0 β€” One Bad Name Broke Every EPG

Fixed

Unchanged on purpose

Found by fuzzing the API with hostile payloads rather than by a report. The same sweep confirmed what already held: type and range validation rejects malformed bodies, SQL-ish names are stored literally (queries are parameterised), path traversal in a name cannot escape the HLS directory, and the server survives every malformed body without a restart.

v2.81.0 β€” Auto-Refresh, Audited

Fixed

Audited (browser-measured, not eyeballed)

v2.80.0 β€” HTML Graphics Land Where They Can Render

Fixed

Verified

v2.79.0 β€” The Fleet View

Added

v2.78.0 β€” Fleet-Aware Dispatch

Added

Selection rules unchanged and still contract-tested: accepting is asked never derived, absence is not refusal, draining takes no new work except by an explicit pin, ties break by configuration order.

v2.77.0 β€” Ads at FAST Scale, Ladders Without a Channel

Added

Fixed

v2.76.0 β€” The Phone Audit

A verification gap I had wrongly assigned to you: narrow viewports are testable here, and I had never done it. Measured at 1024, 768 and 390 px β€” tablets were already clean; the phone was not.

Fixed

Hardened

v2.75.0 β€” Ads Reachable, Encoding Usable, Two Dead Buttons Found

Fixed

Added

Hardened

v2.74.0 β€” The Guided Tour

Added

Audited

v2.73.0 β€” The Full UI Audit

Every page, every button, exercised in a live browser with effect assertions β€” so "something basic doesn’t work" surprises die in rehearsal instead of production. Twenty-two flows verified green across all nine pages (channel edit persistence, Set & flow, Clear times, Undo, Take next, Restart item, Fill day, Repeat Day, Import, Encode All, all three rundown views, EPG keys, stats rendering, as-run ranges and CSV export, snapshots, storage sweep, node refresh). Three findings, three fixes:

Fixed

v2.72.0 β€” One Dispatcher, Shared

Changed

v2.71.0 β€” The Board Cleared

Added

Fixed (gpunode 1.39.0)

v2.70.0 β€” Black Screens Named, Days Filled

Fixed

Added

Proven by test

v2.69.0 β€” Your Place in Line

Added

Proven by test

Known gaps

v2.68.0 β€” Warm Before the Fire

Added

Proven by test

v2.67.0 β€” Spread the Load

Added

Proven by test

v2.66.0 β€” The Arbiter

Added

Proven by test

Fixed

v2.65.0 β€” Full Disks and Far Storage, Proven

Verified live

Added

v2.64.0 β€” Proven Live; Backups Audited

Proven live (both release binaries, one chain)

Added

v2.63.0 β€” Text Changes On Air, Within a Frame

Added

Known gaps

v2.62.0 β€” The Watchdogs

Added

v2.61.0 β€” No Matter What

Added

Known gaps

v2.60.0 β€” On Screen, Where You Can Find It

Fixed

Added

Known gaps

v2.59.1 β€” The Sweep’s Findings

Fixed

Browser-verified this release (headless Chromium on the release binary)

v2.59.0 β€” The Black Box

Added

Known gaps

v2.58.0 β€” Self-Protection, Purge, and File Preview

Added

Known gaps

v2.57.0 β€” Three Carried Items Closed

Fixed

Added

Known gaps

v2.56.0 β€” The L-Shape Comes Back On Its Own

Fixed

Proven live — the complete failure→recovery loop, both release binaries

Known gaps

v2.55.0 β€” The Joint Rehearsal

The full playout↔gpunode L-shape chain ran live for the first time β€” one channel, both programs, a viewer, and a kill switch β€” and it now works end to end. Three bugs that only the whole system could reveal were found and fixed on the spot.

Fixed

Proven live (both release binaries, one machine)

Known gaps

v2.54.0 β€” Robust Under Load (production findings)

Fixed

Added

Known gaps

v2.53.0 β€” The Grid You Can’t Miss

Fixed

Changed

Known gaps

v2.52.0 β€” Drop On The Minute

Fixed

Added

Known gaps

v2.51.0 β€” Manual Control

Added

Known gaps

v2.50.0 β€” The L-Shape, On Air

Requires gpunode 1.34.0 for composited channels (earlier nodes refuse the session cleanly; everything else pairs with 1.31+).

Added

Known gaps

v2.49.0 β€” The Disk Is Allowed To Die

Pairs with gpunode 1.33.0 (unchanged this release).

Added

Fixed

Known gaps

v2.48.0 β€” Whose Bytes Are These

Pairs with gpunode 1.33.0 (unchanged this release).

Added

Already present, verified this release

Known gaps

v2.47.0 β€” Demo Accounts, and the Grid Shows the Whole Day

Pairs with gpunode 1.33.0 (unchanged this release).

Added

Fixed

Known gaps

v2.46.0 β€” A Restore Button, At Last

Pairs with gpunode 1.33.0 (unchanged this release).

Added

Known gaps

v2.45.0 β€” The L-Shape Gets Its Foundations

Pairs with gpunode 1.33.0. First release of the agreed L-shape design (items 38–47); the capacity plan β€” roughly 3–4 composited channels per Quadro, 6–8 total β€” is accepted.

Added

Known gaps

gpunode 1.33.0 β€” A Node That Knows Its Role

Pairs with PlayoutGo v2.45.0.

Added

Fixed

v2.44.0 β€” Captions Were Being Stripped From Compliant Source

Pairs with gpunode 1.32.0.

Fixed

Added

Known gaps

gpunode 1.32.0 β€” Encodes No Longer Discard Captions

Pairs with PlayoutGo v2.44.0.

Fixed

Known gaps

v2.43.0 β€” The Channel That Played and Reported Nothing

Fixed

Added

Known gaps

gpunode 1.31.0 β€” Two Buttons That Reported Success and Did Nothing

Shipped with PlayoutGo v2.42.0.

Fixed

Found by asking the same question that found three invisible features in PlayoutGo last release, pointed the other way: not "is there an interface for this endpoint" but "is there an endpoint behind this interface".

v2.42.0 β€” Registration Could Not Be Closed From the Product

Fixed

default install     stranger registers β†’ token issued
close with no codes β†’ refused (nobody could then sign up)
create a code, close β†’ "an invite code is required to sign up"
Found by asking the same question as the previous release β€” not "does the endpoint work" but "can anyone reach it" β€” and running it across all forty-eight routes rather than the three newest. This one had been reachable-only-by-curl for far longer than the features fixed last time.

v2.41.0 β€” Three Features Nobody Could Reach

Fixed

Found by asking a different question than usual: not "does the API work" but "can anyone actually get to it". Three consecutive releases had added working, tested, documented features that the product did not expose.

gpunode 1.30.0 β€” The Node Documents Itself

Shipped with PlayoutGo v2.40.0.

New

Both binaries now document themselves completely: PlayoutGo's 49 routes as of v2.39.0, and the node's as of this release. Each has a test that fails if a route is added without a description, so neither can drift again.

v2.39.0 β€” Twenty-Eight Undocumented Endpoints

Documentation

routes documented   49 / 49
endpoints rendered  73
markup balanced     yes

v2.38.0 β€” The Summary Was Missing Three Fields

Fixed

summary      189 bytes/channel   (8 fields, all of them used)
full record  803 bytes/channel   (32 fields)
Still a four-fold reduction on the polling path, and now correct. The performance gain in v2.35.0 was real; three fields of it were not the gain, they were a regression sold as one.

v2.37.0 β€” Writes Queue Instead of Colliding

Performance

20 concurrent writers Γ— 15 writes
  before   p50 20ms   p95 609ms   max 790ms   272/s
  after    p50 21ms   p95 532ms   max 659ms   290/s
A modest gain, reported as such. The theory predicted more; the measurement says thirteen percent, and the remaining spread is not SQLite. The change is still right β€” it converts retry-under-backoff into fair queueing, which matters more as contention rises than this one-core machine can demonstrate β€” but it is not the fix the shape of the numbers first suggested.

What the work nearly introduced

Go's mutexes are not reentrant, so a function holding the write queue that calls another write method hangs forever β€” and a deadlock in a database write is a service that stops with no error to explain why. One such path existed in the migration code, saved only by statement ordering. There is now a permanent check that reads every function taking the lock and fails if any calls another write method while holding it, plus a concurrency test that would hang rather than pass.

gpunode 1.29.0 β€” The Last Recommendation, Without a Flag Day

Shipped with PlayoutGo v2.36.0. Every item on every list of the shared-pool review is now implemented.

Security

Verified both ways

no operator token   health 200  jobs 200  clients 200  metrics 200   (unchanged)
separated, submit   health 200  jobs 200  clients 403  metrics 403
separated, operator                       clients 200  metrics 200

v2.35.0 β€” Measured Under Load, Then Fixed

Everything until now was verified functionally. This was measured: two hundred channels, twenty operators polling at once.

Performance

Measured

20 operators Γ— 20 polls, 200 channels

  before   p50 292ms   p95 964ms   54 req/s   144 KB/poll
  after    p50  82ms   p95  94ms  249 req/s    18 KB/poll
The lock fix alone moved p95 from 964ms to 734ms β€” worth having, and not the cause. The payload was. Worth recording because the lock was the more interesting hypothesis and the measurement disagreed: it is the reason to measure rather than reason about it.

gpunode 1.28.0 β€” The Source Cache Gets a Ceiling

Shipped with PlayoutGo v2.34.0. This closes the shared-pool review: every item on every list is now implemented.

Fixed

Cross-client deduplication, verified

client A  /v1/sources/<sha> β†’ 404 β†’ uploads the master
client B  /v1/sources/<sha> β†’ 200 β†’ encodes a different ladder, no upload
cache     1 source held
The mechanism was already there; what it lacked was a bound, which is what stopped it being safe to rely on. A client computes the hash, asks, and uploads only when the answer is no β€” so several installations encoding the same master pay for one transfer, and a failover to a node that already holds the source pays for none.

v2.33.0 Β· gpunode 1.27.0 β€” Nothing Inside Takes the Whole Thing Down

Both binaries now contain their own faults. A panic in any goroutine used to end the process β€” for PlayoutGo that is every channel off air, for a node it is every encode in flight lost β€” and the cause was usually one job's unexpected data.

New

Verified

100 malformed requests across both services
  playout: RUNNING Β· answers HTTP 200
  gpunode: RUNNING Β· answers HTTP 200
Guards are placed so that cleanup still happens: in the parallel health probes the wait group is released before the recovery is installed, so it runs last β€” a panic there must still release the wait, or a caller blocks forever on a probe that has already failed.

v2.32.0 β€” A Fixed Bug That Came Back Wearing Different Clothes

Fixed

What else was checked

A sweep for the same class of fault this session has kept producing β€” scaffolding without behaviour. Every table is written to, every configuration field is read, all 29 API paths the interface calls are served, no interface handler references a function that does not exist, and both binaries have only trivial dead helpers. The audit trail found last release was the last of that kind.

v2.31.0 β€” An Audit Trail That Works

Fixed

This is the fourth thing found this session that existed as structure without behaviour β€” a table, five call sites and a test, none of which had ever run. Worth remembering when reading any codebase: the presence of a feature's scaffolding is not evidence the feature works.

v2.31.0 β€” An Audit Trail

New

Writing to the trail never fails a request and never blocks one. An audit trail that can fail a channel start is a trail that gets removed from the hot path the first time it does β€” a missing line is a gap, a channel that would not start because logging was unavailable is an outage.
The address recorded is the direct peer, not a forwarded header. This is evidence, and a value the client controls is not evidence. An installation behind a proxy sees the proxy here, which is honest β€” a wrong address recorded confidently is worse than an obviously internal one.

v2.30.0 β€” Snapshots That Are Known to Work

v2.29.0 added automatic snapshots. This is the half that makes them a backup rather than a hope.

New

What the tests found

The first version used SQLite's quick integrity check, and a test proved it passed a file damaged in the middle. The full check is used instead: a backup is verified once when it is written, and there is no reason to economise on the check that decides whether it is a backup at all.
The same test initially wrote its corruption into unallocated space and proved nothing β€” SQLite was right that such a file is still a valid database. Real damage from a failing disk or a partial write spans the pages in use, which is what it does now. Verified live: a snapshot corrupted on disk is reported as "the snapshot could not be checked: database disk image is malformed".

v2.29.0 β€” Automatic Database Snapshots

New

Why it is not a file copy

Copying the database while the service runs is not safe. Recent writes live in a separate journal, so a plain copy can be missing its most recent transactions β€” or, worse, open cleanly and be quietly short. The snapshot is written from inside the same connection, which is the difference between a copy and a backup. It goes to a temporary name and is renamed on success, because a half-written snapshot that looks finished is the one somebody restores from.
Snapshots sit beside the database by default. That is the wrong place for a disk failure and the right one for everything else β€” a deleted channel, a bad migration, an operator mistake. Set backup_dir to put them elsewhere, and copy them off the machine if disk failure is what you are protecting against. The staleness warning exists because a backup system that has silently stopped looks exactly like one that is working, right up until it is needed.

gpunode 1.26.0 β€” Console Hardening

Shipped with PlayoutGo v2.28.0. This closes the hardening items from the shared-pool review.

Security

The attempt budget is bounded in size as well as in time: an attacker rotating source addresses would otherwise turn a rate limit into a memory leak.

gpunode 1.25.0 β€” The Silent Overlay Drop, and Capacity That Decides Itself

Shipped with PlayoutGo v2.27.0.

Fixed

New

v2.26.0 β€” Suspension, and Files That Cannot Be Played

New

Confirmed already correct

v2.25.0 β€” Three Subsystems That Did Not Know About Each Other

The encode queue, the reclaimer and the deletion guard were built in different releases, and each was correct on its own. What none of them knew was what the others were doing.

Fixed

Found by looking for interactions rather than by testing each part β€” every one of these subsystems passes its own tests, and the fault was only ever in what happened between them.

gpunode 1.24.0 β€” The Slot Limit Is Per Card, and Now Says So

Shipped with PlayoutGo v2.24.0.

Fixed

Confirmed working

v2.23.0 Β· gpunode 1.23.1 β€” A Race in This Session's Own Work

Fixed

Regression protection

Found by reviewing this session's own new code rather than from any audit. Worth recording: every file added since v2.0 was written with locking in mind, and one still had a reader nobody had thought about because it did not exist yet when the writer was written.

gpunode 1.23.0 β€” Cost, Wait and Callbacks

Shipped with PlayoutGo v2.22.0. This closes the shared-pool review: every item on both lists is now either implemented or was already in place.

New

On the callback address

It comes from whoever submitted the job and this node connects to it, which is a request-forgery primitive if left open β€” a node inside a datacentre can reach a metadata service and every private address on its network, and a caller who can make it fetch one has borrowed its position there. Callbacks may only reach ordinary public unicast addresses, decided by what the address is rather than by a list of what it is not. Verified: a callback to loopback is refused at dial time with the reason given, and a malformed URL is refused at submission rather than silently missed an hour later.

gpunode 1.22.0 β€” A Shared Node That Behaves Like One

Shipped with PlayoutGo v2.21.0. The three items the pool review flagged as changing the shape of the load rather than trimming it.

New

Deduplication by source hash already works. The source cache is keyed by hash and shared across jobs and clients, so several systems encoding the same master at different ladders upload it once β€” that is recommendation 3, and it needed no change.

gpunode 1.21.0 Β· PlayoutGo v2.20.0 β€” The Overlay Size Belongs to the Caller

Fixed

Recommendations 5 and 6 in the shared-pool document β€” the owner tag and an explicit lost state β€” were implemented in gpunode 1.14.0 and appear still open there; the document's header describes 1.20.0 but its first list was not updated.

v2.19.0 β€” Broadcast Health in Monitoring

New

These read zero on a channel with no receiver attached, which is correct rather than missing β€” an SRT listener with nothing connected writes nothing, so there is no padding to count and no clock to send.

v2.19.0 β€” Metrics

New

Hardened

v2.18.0 β€” The Last Finding

Fixed

This closes the audit. All 78 findings across the four documents are resolved: 1 Blocker, 7 Critical, 47 High and 23 Medium. Each carries a test written against the behaviour that was wrong, not merely against the code that replaced it.

v2.17.0 Β· gpunode 1.20.0 β€” An Interface That Stays Still

Fixed

v2.16.0 Β· gpunode 1.19.0 β€” Box Boundaries, Method Defaults, Node Permissions

Fixed

v2.15.0 Β· gpunode 1.18.0 β€” Lockouts That Lift, Polls That Do Not Lie

Fixed

The poll guard is released in a finally: releasing it only on success would mean one failed request stopped polling permanently, which is a worse failure than the overlap it was added to prevent.

gpunode 1.17.0 β€” Honest Resolutions and a Safe Key URL

Shipped with PlayoutGo v2.14.0.

Fixed

v2.13.0 Β· gpunode 1.16.0 β€” Leaks and Limits

Fixed β€” gpunode

Fixed β€” PlayoutGo

The goroutine test asserts the count stays flat while four callers are blocked, then that a release genuinely wakes them β€” removing the periodic nudge would otherwise have been a silent hang rather than a visible failure.

v2.12.0 β€” Medium Findings: Origins, Passwords, Permissions, Honest Failures

Security

Fixed

v2.11.0 β€” Paged Listings

Fixed

This closes the last High finding from the audit. Twenty-three Medium findings remain.

v2.10.0 β€” The QR Workflow, Proven

Fixed

Verified end to end

week 1  set https://bozztv.com/promo/week1 β†’ re-encode β†’ 1 queued
        decoded from the encoded video: https://bozztv.com/promo/week1

week 2  change to https://bozztv.com/promo/week2 β†’ re-encode β†’ 1 queued
        decoded from the encoded video: https://bozztv.com/promo/week2

Read back with a real decoder from a frame of the finished file, not inspected by eye β€” the only test that distinguishes a code that works from one that merely looks right.

The queue was also observed doing its job in passing: with no encoding node registered it held the work and raised an alert rather than failing it, and when a node appeared after a restart it dispatched the restored jobs by itself.

v2.9.0 β€” The QR Workflow, End to End

New

The overlay is sent to the encoding node ahead of the source, because the node reads the parts in order and streams the source straight to disk β€” anything after it is never seen. If the overlay cannot be read the encode proceeds without it and says so: an encode without the code is better than no encode, and far better than a channel quietly losing its code with nothing said.

v2.8.0 Β· gpunode 1.15.0 β€” QR Codes Burned Into the Picture

New

On size, which is the whole question

Fixed while building it

v2.7.0 Β· gpunode 1.14.0 β€” Broadcast Settings in the Interface, and a Shared Pool That Explains Itself

New β€” PlayoutGo

New β€” gpunode

v2.7.0 β€” Clock Accuracy

The last of the three things standing between this and an uplink test, and the one I said I could not verify. It turned out to be verifiable after all β€” because on a constant-rate stream it is not a measurement.

Fixed

Regression protection

v2.6.0 β€” The Clock, and the Network Table

Closing the two gaps named in v2.5.0 as standing between this and an uplink test.

Fixed

New

Clock packets carry no payload, so their continuity counter deliberately does not advance β€” per ISO 13818-1, advancing it would make an analyser report a continuity error on a packet that is otherwise perfectly correct. There is a test for exactly that, because it is the kind of detail that passes review and fails an ingest.

v2.5.0 β€” DVB Service Information

Constant bitrate alone does not make a stream a broadcaster will carry. A transport stream with only PAT and PMT is valid MPEG and not valid DVB: a receiver has no service name to display, no provider, no idea what kind of service it is, and no clock β€” so it shows an unnamed entry or refuses the multiplex.

New

Tables are emitted only when a channel is configured for them. Claiming a service id and network nobody allocated would collide with real services in a receiver's list, which is worse than emitting none β€” so this is absent by default and correct for HTTP delivery.
On compliance. This makes the stream structurally correct against the published checks, and the tests verify each table field by field β€” identifiers, CRCs, lengths, running status, repetition intervals. It is not a substitute for validation with a real analyser against a particular operator's requirements: they each have their own, and no amount of care here can anticipate them. What this does is make that conversation start from a stream that is right rather than one that obviously is not.

gpunode 1.13.0 β€” Constant Bitrate Encoding, and Telemetry You Can Trust

Shipped with PlayoutGo v2.4.0.

Fixed

New

v2.3.0 β€” Constant Bitrate Output

New

How it behaves

v2.2.0 β€” App Keys Stop Competing With Playout

Fixed

Confirmed closed on review

v2.1.0 β€” Replacements, Races and Resume Offsets

Fixed

gpunode 1.12.0 β€” Probing Once, and an Honest Capacity Control

Shipped with PlayoutGo v2.0.2. This completes the gpunode findings.

Fixed

v2.0.1 β€” Security Headers

Security

Regression protection

v2.0.0 β€” Every Output Write Is Checked

Fixed

Version 2.0.0. Not a break in compatibility β€” configuration, database and the gpunode protocol are unchanged, and any 1.x installation upgrades in place. It marks the point at which the system runs unattended: work queues rather than failing, survives node and server restarts, reclaims its own disk, orders itself by what is needed soonest, and asks for a person only when it has reached a limit it cannot work around.

v1.99.0 β€” Fixed Ad Timestamps, Bounded Requests, Indexed Queries

Fixed

The index migration was written before the tables existed and the service refused to start β€” correctly, because of the fatal-migration change in v1.71.0. That is the behaviour working: a schema problem surfaced at boot rather than as a runtime failure hours later.

v1.98.0 β€” The Queue You Can Actually See

New

v1.97.0 β€” Urgent Work First, and Being Told When It Is Done

New

Verified

a file due on air in 30 minutes, queued after 30 bulk encodes,
was taken first

v1.96.0 β€” Estimates and Self-Maintenance

New

Only provably unused output is removed. Anything a media file still references is left alone regardless of age, and a directory that is not recognisably ours is never touched. A database error is not treated as proof a file is gone β€” only a definite "no such record" is, because deleting on a failed read would destroy live output.

v1.95.0 β€” Running Without Anyone Watching

The system should keep working on its own and speak up only when it has reached a limit it cannot work around. Three things stood in the way of that.

New

Fixed

v1.94.0 β€” Robust Encoding Across Nodes and Tenants

New

Verified

fair queue    one tenant with 20 queued, another with 2:
              six dispatches shared 4 / 2 rather than 6 / 0
CDN modes     inherit β†’ server default Β· override β†’ that CDN Β· origin β†’ no CDN

v1.93.0 β€” An Encode Queue That Survives Anything

New

v1.92.0 β€” Visible Failures and Bounded Parsing

New

Fixed

v1.91.0 β€” A Channel That Cannot Write Now Says So

Fixed

Regression protection

v1.90.0 β€” Segments That Always Start Where They Should

Fixed

Verified

live segments   every one begins with a keyframe
multi-track     first video track aired, extra track reported and ignored

v1.89.0 β€” Local Dates and a Preview That Starts

Fixed

Verified

preview  HTTP 206 Partial Content, Content-Range: bytes 0-1023/20418478
         (streaming, not a 20 MB download before the first frame)

v1.88.0 β€” Account Tokens Out of Playlist URLs

Security

Regression protection

v1.87.0 β€” Proxy Reachability and Connection Reuse

Security

Fixed

Confirmed already fixed on review: range and conditional headers are forwarded, and playlist bodies are size-capped β€” both landed in v1.76.0. The database pool was opened up earlier as well.

v1.87.0 β€” Concurrent Reads

Fixed

Verified

40 concurrent API reads    0 errors, 0 lock contention
8 readers + 1 writer       clean under the race detector
journal_mode               WAL on every connection

v1.86.0 β€” Containment, Concurrency and Atomic Claims

Fixed

Regression protection

v1.85.0 β€” Uploads That Tell the Truth

Fixed

The reserve is deliberately proportional. A flat figure gets both ends wrong: ten gigabytes refuses every upload on a volume already below it β€” including a one megabyte file when three gigabytes are free, which helps nobody β€” while on a multi-terabyte array it is far too little to matter.

gpunode 1.11.0 β€” Job Records Survive Restarts

Shipped with PlayoutGo v1.84.0.

Fixed

Verified

submit β†’ complete β†’ SIGTERM β†’ restart
  [jobs] 1 job record(s) restored from the previous run
  same id still answers: state=done, "3 rendition(s) in 11s"

gpunode 1.10.0 β€” Console Credentials and Late Fields

Shipped with PlayoutGo v1.83.0.

Security

Fixed

Verified

login              cookie is not the token, console 200 with it, 303 without
format after file  MP4 output produced, as asked

gpunode 1.9.0 β€” Descriptors and Data Races

Shipped with PlayoutGo v1.82.0.

Fixed

Regression protection

gpunode 1.8.0 β€” Crash, Cleanup and Honest Encryption

Shipped with PlayoutGo v1.81.0. Update your nodes.

Fixed

v1.80.0 β€” Upload Deadlines and Codec Honesty

Fixed

Verified

HEVC file  β†’ video refused, "HEVC (H.265) … encode it to H.264 first"
AC-3 file  β†’ audio replaced with silence, H.264 video unaffected
H.264/AAC  β†’ unchanged

gpunode 1.7.0 β€” Encodes Actually Run on the Chosen Card

Shipped with PlayoutGo v1.79.0. Update every node, particularly any with more than one GPU.

Fixed

Servers with two Quadro RTX 4000s were using one of them. After updating, both should carry work; nvidia-smi during a multi-job run will show it.

v1.78.0 β€” Per-Install Secrets and Real Revocation

Shipped with gpunode 1.6.0. Update your nodes.

Security

Verified

fresh install   jwt_secret 64 chars, not the default
password change old token β†’ 401, new token β†’ 200

v1.77.0 β€” Proxy Headers and URL Resolution

Fixed

Regression protection

v1.76.0 β€” Uploads and Proxy Correctness

Security

Fixed

v1.75.0 β€” Credentials Out of URLs

Security

Fixed

Regression protection

v1.74.0 β€” Storage, Ranges and Rundown Positions

Fixed

Regression protection

v1.73.0 β€” Protecting What Is On Air

Fixed

Regression protection

v1.72.0 β€” Rejecting Requests That Change Nothing

Fixed

v1.71.0 β€” Refusing to Start on a Broken Schema

Fixed

Regression protection

v1.70.0 β€” Signup and Deletion Made Whole

Fixed

Regression protection

v1.69.0 β€” Ordering, Tokens and Limiter Bounds

Fixed

Regression protection

v1.68.0 β€” Closing the Recovery Backdoor

Security

"allow_password_reset": true,
"recovery_secret": "a-long-random-value-kept-separately"
Existing installations lose nothing they were using deliberately: the endpoint simply becomes unavailable until configured. Ordinary sign-in and the authenticated change-password page are unaffected.

Already addressed, confirmed on review

Regression protection

v1.68.0 β€” Revocation That Works

Security

Existing keys keep working. A key created before this change is matched against its stored value and rewritten to the hashed form the first time it is used, so deployed apps carry on without being reissued β€” an upgrade that silently broke every app would be worse than the exposure it fixes. SHA-256 rather than a password hash: these are 192 bits of random data, so there is nothing to brute-force and a slow hash would only make every API request expensive.

Regression protection

v1.67.0 β€” Day and Week Grids

New

Regression protection

v1.66.0 β€” Cross-Tenant Isolation

Security

Regression protection

v1.65.0 β€” Dead Air Carries Picture and Sound

Fixed

A failure to generate the slate is reported and not fatal: gaps then fall back to tables and a clock without picture, which is worse but not a reason to refuse to start. It requires ffmpeg on the playout host.

Verified

before: ffprobe β€” End of file, zero streams
after:  h264,video
        aac,audio

Regression protection

v1.64.0 β€” Adaptive Delivery, For Real

New

Safeguards

Verified

720p-1   1280,720    6 segments
480p-1   854,480     6 segments
360p-1   640,360     6 segments
master   HTTP 200 with 3 EXT-X-STREAM-INF variants

v1.63.0 β€” The Version Was Lying

Fixed

If you have been unsure whether a deployment took effect, this is why. Check the startup line or the top of this page: both now show the real build.

v1.62.0 β€” Per-Channel Delivery Accounting

New

This is what THIS server sent, not what viewers received. With a CDN in front the origin sees only cache fill; the two differ by the CDN's hit ratio, and only CDN logs can close that gap. The figure is named for what it is rather than implied to be audience delivery.

Regression protection

v1.61.0 β€” Watching an Encode Actually Work

Fixed

v1.60.1 β€” Saying What ABR Mode Actually Delivers

Fixed

This is a labelling fix, not a behaviour change. Assembling and serving a multi-variant set is a feature PlayoutGo does not have; the renditions are frame-aligned so that something in front of it can.

v1.60.0 β€” Resumable Artifact Collection

Fixed

Regression protection

v1.59.0 β€” Edit Lists Honoured

Fixed

Regression protection

v1.58.0 β€” Encoding You Can Actually Follow

Fixed

Regression protection

v1.57.0 β€” A Channel Knows Its Own Timezone

Air times are stored as absolute instants, so playback was always correct. What was not consistent is which zone a day means β€” and three parts of the system disagreed.

Fixed

Nothing moves on upgrade. An unset timezone means the server's own, which is what every existing channel already used. Set it per channel as you go; a channel with none behaves exactly as before.

Regression protection

v1.56.0 β€” SCTE-35 Completed, and a Regression Caught

New

Fixed

Regression protection

v1.55.0 β€” Ad Breaks Signalled in the HLS Playlist

v1.53.0 fixed SCTE-35 generation and v1.54.0 scheduled the breaks, but markers went out only in-band in the transport stream. Most SSAI systems read the playlist rather than parsing SCTE-35 out of the stream, so a break was invisible to them β€” which for HLS delivery meant the feature was not yet usable.

New

Regression protection

v1.54.0 β€” Ad Breaks Scheduled from the Rundown

v1.53.0 made SCTE-35 generation conform to the standard. This places the markers: breaks are defined per rundown item and the engine emits them as the item plays.

New

Safeguards

Signalled on both paths as of v1.55.0: in-band in the transport stream for SRT and MPEG-TS consumers, and in the HLS playlist for SSAI systems that read tags rather than parsing the stream.

Regression protection

v1.53.0 β€” SCTE-35 Conforms to the Standard

Fixed

Regression protection

Still to come: markers are generated correctly but nothing yet schedules them from the rundown. Placing breaks per item is the next step.

v1.52.0 β€” Import Safety

An import that binds the wrong asset or moves an item to the wrong day is the worst kind of failure: the rundown looks correct and the wrong thing airs, with nothing to report it. Three ways that could happen.

Fixed

Regression protection

gpunode 1.5.0 β€” Graceful Shutdown and TLS Integrity

Shipped alongside PlayoutGo v1.51.3. Update your nodes.

Fixed

Regression protection

gpunode 1.4.0 β€” Credentials and Honest Capability

Shipped alongside PlayoutGo v1.51.2. Update your nodes.

Fixed

# Recommended, so the token never appears in ps:
echo -n 'your-long-random-token' > /etc/gpunode.token
chmod 600 /etc/gpunode.token
./gpunode -listen 0.0.0.0:9099 -token-file /etc/gpunode.token \
          -work /var/lib/gpunode -slots 3 -allow <your-playout-host>

Regression protection

gpunode 1.4.0 β€” Resumable Artifact Collection

Performance and reliability

gpunode 1.3.0 β€” Streaming Uploads and a Quieter Disk

Shipped alongside PlayoutGo v1.51.1. Update your nodes.

Fixed

Regression protection

gpunode 1.3.0 β€” One Decode Per Ladder

Performance

v1.51.0 β€” Overlapping Runs and Boundary Losses

Fixed

Regression protection

v1.50.0 β€” Startup Order, Feed Identity and Quieter Failures

Fixed

Regression protection

v1.49.0 β€” Request Limits, Redirects, Exports and SRT Bounds

Fixed

Regression protection

v1.48.0 β€” Re-import Correctness

Re-import is the recovery path after data loss, so getting ownership or identity wrong quietly corrupts a multi-tenant library β€” and does so at exactly the moment an operator is least able to check. Five related defects lived here together.

Fixed

Regression protection

v1.47.0 β€” Dead Air, Config Integrity and Interface Robustness

Fixed

Regression protection

v1.46.0 β€” Boot, Config and Login Hardening

Fixed

Regression protection

v1.45.0 β€” Closing the HLS Proxy

Security

The proxy's existing protections are unchanged and were re-verified: only http and https, no private, loopback or link-local addresses, and the resolved address is re-checked at connection time to close DNS rebinding.

Regression protection

v1.44.0 β€” Audit Fixes, First Pass

Fixed

gpunode 1.2.0

Regression protection

v1.43.1 β€” Migration That Reaches Existing Installations

Fixed

Regression protection

v1.43.0 β€” Refusing an Incompatible Node

Fixed

Regression protection

v1.42.2 β€” A Job That Stalls Says So

Fixed

Regression protection

v1.42.1 β€” One View of a Node

Fixed

Regression protection

v1.42.0 β€” CDN Per Account, and Visible in the Interface

Fixed

New

Diagnostics

Regression protection

v1.41.1 β€” Buttons That Did Nothing

Fixed

Regression protection

v1.41.0 β€” Watch Folders in the Interface

New

v1.40.0 β€” Delivery Settings in the Interface

New

v1.39.0 β€” Encode Per Channel

New

Fixed

Regression protection

v1.38.4 β€” Tolerating a Warming GPU Driver

Fixed

If a node is persistently slow to answer, enable persistence mode on it: nvidia-smi -pm 1. Without it the driver unloads whenever the GPU is idle and every status query pays to bring it back.

v1.38.3 β€” Readable Node Errors

Fixed

Regression protection

v1.38.2 β€” Panels Confined to Their Own Screens

Fixed

Regression protection

v1.38.1 β€” Interface Responsiveness

Fixed

Regression protection

v1.38.0 β€” Trim Points, Repeating Days, Watch Folders

New

Security

Regression protection

v1.37.0 β€” Session Handling & Interface Audit

Fixed

Regression protection

v1.36.0 β€” CDN Delivery & Encode Fallback

New

Fixed

Regression protection

v1.35.0 β€” Encoded Renditions Are Actually Played

Fixed

New

Regression protection

v1.34.0 β€” Performance

Improved

Regression protection

v1.33.0 β€” Encoding Fully Managed from the Interface

New

Security

Regression protection

v1.32.0 β€” Encoding Modes & gpunode Integration

New

Fixed during implementation

Regression protection

v1.31.0 β€” App API for Client Applications

New

Fixed during implementation

Documentation

Regression protection

v1.31.0 β€” App API for Client Applications

New

Fixed during development

Regression protection

v1.30.0 β€” Honest Capacity Projections

Fixed

Regression protection

v1.29.0 β€” Load Figures That Understand Stuck I/O

Fixed

New

Regression protection

v1.28.0 β€” Density Benchmark & Idle-Host Capacity Fix

Fixed

New

Regression protection

v1.27.1 β€” Accurate Overlap Wording

Fixed

Regression protection

v1.27.0 β€” Multi-Clip Bin-Packed Gap Filler

New

Documentation

Regression protection

v1.26.0 β€” Overlaps as Visible as Gaps

Changed

Verified

v1.25.0 β€” Re-probe Progress Reporting

New

Regression protection

v1.24.0 β€” Unknown Durations Made Visible

Fixed

Verified, not changed

v1.23.0 β€” Generation and Re-probe for Files Missing Durations

Fixed

Regression protection

v1.22.0 β€” Stream URLs Follow the Request Scheme

Fixed

Regression protection

v1.21.0 β€” Dangling Gap-Filler References

Fixed

Audited, no defect found

Regression protection

v1.20.0 β€” Full API Parity in the Interface

New interface controls for existing API features

Fixed

Regression protection

v1.19.0 β€” Playlist Generation & Schedule Import

New

Fixed

Regression protection

v1.18.0 β€” Data-Race and Input-Handling Fixes

Fixed

Audited, no defect found

Regression protection

v1.17.0 β€” Atomic Channel Allowances

Fixed

Audited, no change needed

Regression protection

v1.16.0 β€” HTTPS on 443 with Automatic Certificates

New

Fixed

Verified (already present, re-tested end to end)

Regression protection

v1.15.0 β€” Resource Cleanup & No More Silent Failures

Fixed

Regression protection

v1.14.0 β€” Honest System Metrics

Interface

Fixed

Regression protection

v1.13.0 β€” Correct System Figures

Fixed

Regression protection

v1.12.0 β€” Channel Health at a Glance

New interface

Fixed

Regression protection

v1.11.0 β€” Correct Disk Attribution, Honest Capacity & SRT Diagnostics

Fixed

Regression protection

v1.10.0 β€” As-Run Logs & Data-Integrity Fixes

New: as-run logging

Bugs found and fixed

Production-readiness audit

v1.9.0 β€” Automatic Restart Resume & Accurate Disk Reporting

New: channels survive restarts

Fixed

Interface

Regression protection

v1.8.0 β€” System Monitoring, Capacity Planning & Invite Codes

New: system utilisation & capacity

New: invite codes & signup control

Friendlier interface

Bugs found and fixed during this release's testing

Regression protection

v1.7.0 β€” Security Hardening & Extensive In-App Documentation

Security release. Multi-user installations should upgrade: two paths allowed one tenant to broadcast another tenant's media.

Security fixes

Functional fixes

New: extensive in-app documentation

Friendlier interface

Regression protection

v1.6.1 β€” Critical UI Repair

Upgrade immediately from v1.4.0–v1.6.0. Those builds shipped a broken Rundown and Stats page. If your Rundown showed an empty channel selector and an empty Add Files panel, this release fixes it β€” no data was lost or corrupted; only the browser UI was affected.

Fixed

New: regression protection

v1.6.0 β€” Gap Filler & Schedule-Engine Fix Release

New

Bugs found by live testing and fixed

v1.5.0 β€” In-App Documentation & Field-Validation Release

New: documentation lives in the interface

Fixes

Field validation performed for this release

v1.4.0 β€” Rundown, Safari-grade HLS & Field-Fix Release

Everything below was verified with automated Go tests, node syntax checks, live-server API smoke tests, and end-to-end ffprobe/ffmpeg stream validation.

New: Rundown scheduling UI

Streaming correctness

Data & API fixes

Security

v1.3.1 β€” Critical Streaming Fix Release

v1.3.0 β€” Robustness & Leak-Fix Release

v1.2.0 β€” Hardening & Polish Release

v1.1.0 β€” Security & Correctness Release

v1.0.0 β€” Initial Release