π¬ 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.
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:
- HLS β native HTTP Live Streaming, served at
/hls/{slug}/index.m3u8, viewable in any browser or media player. - SRT β Secure Reliable Transport for low-latency contribution to broadcast encoders, Nimble Streamer, Flussonic, Wowza, etc.
- Both β simultaneous HLS monitoring preview and SRT contribution output.
- RTMP push β
rtmp_push_urlsends the same frames to YouTube, Facebook or Twitch (Pushing to YouTube). - SDI β a Blackmagic DeckLink card in the server plays the channel out of an SDI port (SDI output, 3.62.0).
- UDP / RTP β the transport stream to a multicast group or a unicast address, for head-ends, IRDs and multiplexers (UDP / RTP output, 3.63.0).
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
| Feature | Since | What it does |
|---|---|---|
| Channels | 1.0.0 | One live stream with its own rundown, output settings, loop flag and status |
| Output modes hls / srt / both | 1.0.0 | HLS always on; SRT runs alongside; an SRT failure never stops HLS |
| SRT caller and listener | 1.0.0 | Dials out to an ingest or accepts pulls; reconnects in the background |
| SRT stream-ID routing, shared ports | 1.4.0 | Channels share a port when passphrase, latency, buffer and bandwidth match |
| SRT clean join | 1.4.0 | New receivers start on an IDR with SPS/PPS; tables sent on connect |
| SRT latency, buffer, max bandwidth | 1.2.0 | Bounded settings; latency in microseconds in the URL |
| SRT AES passphrase | 1.2.0 | 10β79 characters, never returned by the API; explicit remove control |
| SRT encryption enforced in listener mode | 3.48.0 | Callers with a missing or wrong passphrase are refused |
| SRT diagnostics | 1.11.0 | Bind and settings clashes in the error log; paste-ready connect URL; refusal summaries |
| HLS output | 1.0.0 | 4 s keyframe-aligned segments, 6-segment window, atomic playlist, PROGRAM-DATE-TIME (3.52.0) |
| HLS master playlist | 3.54.0 | index.m3u8 is a master for a multi-language or captioned channel; main.m3u8 is the media playlist; CODECS and measured BANDWIDTH |
| Measured master playlists | 3.55.0 | RESOLUTION from the SPS; CODECS and BANDWIDTH from the segments in the window, on every ABR rung too |
| Canonical HLS paths | 1.5.0 | /hls/<slug>-<id>/ everywhere; old aliases still work |
| Loop playlist, re-shuffle on loop | 1.5.0 | Replays after the last item; optionally re-ordered on each pass (2.95.0) |
| Restart on output-setting change | 1.73.0 | A running channel restarts so a changed output, rate or DVB identity takes effect |
| Idempotent start | 2.83.0 | Starting a running channel answers 200 already_running |
| RTMP push | 3.28.0 | Pushes the channel to YouTube, Facebook or Twitch; tallest ABR rung (3.52.0) |
| UDP / RTP output | 3.63.0 | The 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 webhooks | 3.63.0 | Signed 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 output | 3.62.0 | A DeckLink SDI port per channel: HD and SD formats, field order, 2/8/16 embedded audio channels, restarted when it fails; administrators only |
| Diagnostics endpoint | 1.0.0 | /diag: item status, file presence, HLS directory, recent errors |
Rundown & scheduling
| Feature | Since | What it does |
|---|---|---|
| Scheduler and auto-start | 1.0.0 | 10 s tick; starts a stopped channel when an anchor is due |
| Schedule-aware restart | 1.0.0 | Resumes the right item at the right offset, honouring trims |
| Rundown | 1.4.0 | Playlist and schedule in one ordered list beside the library |
| Anchored and floating items | 1.4.0 | π exact air times; π items chain by duration and reflow live |
| Gap, overrun and overlap detection | 1.4.0 | GAP and OVERLAP rows, +over badges, footer counts |
| NOW line, countdown, day separators | 1.4.0 | Live position marker and totals |
| Bulk actions, Duplicate, Undo | 1.4.0 | Selection, move, clear times, remove; 10-level undo taken only after the server agrees |
| ON-AIR guard | 1.4.0 | Asks before touching the playing item; the change runs inside the question (3.52.0) |
| Keyboard reorder | 3.47.0 | Move up / move down per row, with the same guard and undo |
| Channel timezone | 1.57.0 | IANA zone per channel drives the rundown, grids, repeats and imports |
| Day and Week grids | 1.67.0 | Hourly grids; drag media onto an exact minute |
| Gap filler (bin-packed pool) | 1.6.0 | Clips fitted into gaps longest-first, no back-to-back repeats (1.27.0) |
| Black-and-silence slate | 1.65.0 | Generated H.264/AAC slate covers gaps on every output |
| Generate | 1.19.0 | Random rundown by count or duration, with seed and no-repeat |
| Schedule import | 1.19.0 | CSV, native XML, BXF subset, XMLTV; air times become anchors |
| Fill day | 2.70.0 | Spreads the library across a day as minute anchors |
| Trim points | 1.38.0 | In and out points per item without re-encoding |
| Repeating days | 1.38.0 | Copies a day to N days, weekdays or listed dates |
| Manual control | 2.51.0 | Take next, restart item, jump β also during scheduled waits |
| Media-delete protection | 1.73.0 | Deleting an on-air or scheduled file is refused, naming the channel |
Traffic
| Feature | Since | What it does |
|---|---|---|
| Traffic tab | 3.49.0 | Plans the broadcast day before air: logs, library, templates, release, load |
| RPX import and export | 3.49.0 | ProTrack 696-column records read and written byte for byte |
| Log integrity check | 3.49.0 | Full 24 h, quarter-hour grid, graphics inside programs, DST day flagged |
| Release gate | 3.49.0 | Only released logs load; errors need an acknowledged, audited reason |
| Load into Rundown | 3.49.0 | Dry run first; programs anchored, the rest chained and trimmed |
| House IDs and learning from a log | 3.49.0 | Match files by house code; derive the profile, library and a day template |
| Day scheduler and graphics rules | 3.49.0 | Builds days from templates with rotations, station IDs and rule-made graphics events |
| Series rotation | 3.53.0 | A block airs the same episode again, the next one, or cycles; learned from the logs |
| Hour spots | 3.53.0 | X{DOW}{HH}00S opens the break ending on each hour; learned from the log |
| Fixed spots | 3.53.0 | A promo, filler or ID at an exact second, inside whichever break holds it |
| Rotation over several days | 3.53.0 | rotation_days: promos that have not aired for longest come first |
| Flat-template anchors and filler programs | 3.53.0 | A flat block starts on its second; short programs fill the time before it |
| Clock-change days | 3.58.0 | Per channel: by elapsed time (asks at load), or by clock time β the skipped hour's events dropped, the repeated hour aired once |
| Dayparts per weekday | 3.58.0 | A 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 spots | 3.61.0 | X{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 daypart | 3.61.0 | ei_suffix: the E/I graphic is the daypart logo plus _E (ENG_8_E in business hours, ENG_6_E otherwise) |
| Promo airing rules | 3.61.0 | Per promo, filler or ID: days and times it may air, first and last day, at most so many a day; never relaxed |
| Series starting points | 3.61.0 | series_starts: from a date, a series resumes at the episode chosen; learned series blocks start over when the episodes run out |
| Autonomous channels | 3.61.0 | A channel whose schedules arrive complete (3ABN RUSS) is imported and loaded, never generated |
| RapidPlay X2000 fields | 3.62.0 | Every 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 starts | 3.62.0 | Up 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 requirements | 3.64.0 | Every requirement 3ABN gave the traffic system, its source and the test that proves it; a test keeps the table true |
| Station ID rotation | 3.62.0 | id_codes: several station IDs rotate, least recently aired first, keeping room for the break’s Up Next spot |
| Titles and clock-start channels | 3.62.0 | titles, clock_every_event, file_ext: CANADA’s titles and .txt files, CANADA’s and PRAIZ’s clock start on every event |
| Promo minimum gap | 3.59.0 | promo_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 editor | 3.53.0 | A table of blocks and fixed spots, coverage shown as you type |
| Traffic API | 3.49.0 | Logs, import, generate, profile, library, templates, release, load |
Live insertion & ad breaks
| Feature | Since | What it does |
|---|---|---|
| RTMP publishing point | 3.2.0 | rtmp://HOST/live/<key> per channel; rtmp_port 1935, -1 disables |
| Take live / Return | 3.2.0 | Cuts on the next keyframe mid-item (3.21.0); returns within 100 ms |
| Random stream keys | 3.48.0 | 192-bit live_ keys, New key, encrypted at rest |
| Feed-loss auto-return | 3.52.0 | No frame for 5 s: back to the schedule, audit live.lost |
| One encoder per key | 3.52.0 | A second publisher is refused, or replaces a stalled one |
| Live inserts in the as-run log | 3.52.0 | A row labelled LIVE β RTMP from <address> |
| SCTE-35 passthrough | 1.0.0 | Splice data in the source is carried through |
| Scheduled ad breaks | 1.54.0 | at_ms/duration_ms per item, after the trim-in |
| Conformant splice_insert | 1.53.0 | Correct lengths and CRC, break_duration with auto_return; CUEI and per-PID counters (3.52.0) |
| HLS ad signalling | 1.55.0 | CUE-OUT, CUE-OUT-CONT, CUE-IN, DATERANGE SCTE35-OUT |
| Ad-break editor and patterns | 2.75.0 | π’ per row; every N minutes; apply to every item (2.77.0) |
| Break now | 3.44.0 | An immediate avail of any length while on air, audited; says "queued" when it must wait (3.53.0) |
| An avail inside an avail | 3.53.0 | The open avail returns first; every cue-out has its own return |
Audio, captions, DVB & constant rate
| Feature | Since | What it does |
|---|---|---|
| Silent audio synthesis | 1.4.0 | Video-only files get a silent 48 kHz stereo AAC track |
| Codec checking | 1.80.0 | HEVC/VP9/AV1 refused by name; unsupported audio replaced with silence |
| Audio track inventory | 2.94.0 | audio_tracks with language, channels, codec, rate |
| Multi-language audio | 3.32.0 | Each language its own stream with an ISO 639 label (HLS 3.33.0) |
| Stable channel audio set | 3.40.0 | Language slots fixed per run; empty slots carry the main track |
| Audio routed by language | 3.52.0 | Each slot takes its language's track, not the track in that position |
| Every language for an early receiver | 3.53.0 | A receiver connected during the start-up slate gets the new streams without reconnecting |
| HLS audio renditions | 3.54.0 | Each 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 captions | 2.44.0 | A/53 captions detected (CC badge) and carried through remux and local encodes |
| Captions declared in HLS | 3.54.0 | CLOSED-CAPTIONS (CEA-608 CC1) in the master when the rundown carries A/53 captions |
| Constant-bitrate transport | 2.3.0 | cbr_bps pads the SRT stream to a fixed rate |
| CBR conformance test | 2.93.0 | Five-minute measurement with a verdict per criterion |
| DVB SDT / TDT / TOT / NIT | 2.5.0 | Service name and provider, time tables, network table (2.6.0) |
| DVB programme identity | 3.52.0 | PAT/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
| Feature | Since | What it does |
|---|---|---|
| Graphics mode | 2.45.0 | Normal or composited per channel; changeable only while stopped |
| L-shape compositing | 2.50.0 | A node frames the clean output with headline, ticker, colours and logo |
| HTML / URL graphics | 2.50.0 | Headless Chromium snapshots of your page, every snapshot_sec (default 300) |
| Fallback and recovery | 2.50.0 | FALLBACK to clean output; return at an item boundary; at start, on the first healthy check (3.53.0) |
| Live headline/ticker updates | 2.63.0 | Text edits reach air without rebuilding the session |
| Composite node pinning and placement | 2.59.0 | Pin a node, or the least-loaded Chromium-capable one |
| Graphics origin isolation | 3.47.0 | Sandboxed opaque origin; optional graphics_port |
| QR code overlay | 2.8.0 | Burned in at encode time, sized per rung; address validated on save (3.52.0), corner too (3.53.0) |
| Traffic bug layer | 3.49.0 | Rating, daypart logo and E/I cues on the generated graphics page |
| Traffic graphics on the built-in frame | 3.53.0 | The node is sent the cues on air as overlays, in the session and on each change |
| Uploaded logo in the L-shape | 3.53.0 | Used when logo_url is empty; served to the node at /graphics/{id}/logo |
| Demo watermark | 2.47.0 | Demo uploads capped at 720p with a watermark pill |
| Channel logos | 1.31.0 | PNG/JPEG per channel for apps |
Encoding
| Feature | Since | What it does |
|---|---|---|
| Encoding modes | 1.32.0 | Original files, single bitrate or an ABR ladder per channel |
| Remote GPU encode nodes | 1.32.0 | Least-loaded node with a free slot; content-addressed sources |
| Node management | 1.33.0 | Add, test, edit, disable, remove; tokens never returned |
| gpuff support and node roles | 3.0.0 | Capabilities over versions; encode / composite / both roles |
| This server's NVIDIA cards | 3.51.0 | Cards join the pool after an NVENC test; parked on faults |
| GPU decode and scale | 3.54.0 | Opt-in NVDEC and scale_cuda on the encoding card; the processor when the card cannot decode |
| Encode fallback policy | 1.36.0 | fail / local_gpu / local_cpu when no node is free |
| Encode All and Re-encode | 1.39.0 | Each distinct rundown file once; a waiting file is not queued twice (3.52.0) |
| Adaptive HLS variants | 1.64.0 | A master playlist over frame-aligned rungs; SRT and push carry the tallest (3.52.0); each rung measured (3.55.0) |
| ABR items in full | 3.54.0 | Breaks, Break now, every language on every rung, live takes, as-run rows; rungs keep airing between ABR items |
| Constant-bitrate encodes | 2.97.0 | cbr=1 single rendition for fixed-rate pipes |
| Encode queue | 1.93.0 | Survives restarts, fair across tenants, urgent work first, ETAs |
| Encode again | 3.9.0 | Requeues a failed or cancelled job with the same settings |
| Completion notices and alerts | 1.97.0 | Kept 12 h; alerts only when a person is needed |
Delivery & feeds
| Feature | Since | What it does |
|---|---|---|
| CDN base URL | 1.36.0 | Rewrites every viewer-facing URL |
| Per-account and per-channel CDN | 1.42.0 | Channel β account β global β origin |
| Connect YouTube | 3.31.0 | OAuth once; creates the broadcast and sets the ingest |
| HLS proxy | 1.1.0 | CORS proxy with an SSRF guard and a read-only scoped token |
| App API and app keys | 1.31.0 | Read-only JSON lineup, now/next, thumbnails; hashed keys |
| XMLTV EPG | 1.0.0 | Per-channel (public) and per-account feeds; loop projection (2.46.0) |
| M3U playlist | 1.0.0 | Your channels with HLS or SRT URLs |
| Feed tokens | 3.52.0 | Non-expiring EPG/M3U credential, revoked by a password change |
| Read-only scoped tokens | 3.52.0 | URL tokens can only read; a leaked preview URL cannot delete |
| Stream viewer | 1.0.0 | /viewer browser monitor |
Media & storage
| Feature | Since | What it does |
|---|---|---|
| Upload and probe | 1.0.0 | MP4/M4V; async probe; metadata stored |
| Conform on ingest | 3.60.0 | MOV, 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 |
| Loudness | 3.60.0 | Every file measured (BS.1770: integrated LUFS, true peak, range); with a target (β24 A/85, β23 R128) normalised at ingest; CSV report |
| Upload safeguards | 1.85.0 | Free-space reserve; plain-language rejection reasons |
| Preview from the Rundown | 2.58.0 | π plays a file or a rendition in the dock |
| Re-import and re-probe | 1.5.0 | Adopt files already on disk; re-probe broken ones |
| Watch folders and drop boxes | 1.38.0 | Polled folders import settled MP4s; label and append (3.19.0) |
| Collections | 3.19.0 | Labels, filter chips, bulk labelling, rename/merge |
| Tiered storage | 2.49.0 | Primary β mirror β S3, failover and failback, pre-warming |
| Disk quotas | 2.48.0 | Per-user quota on originals; 507 refusal |
| Doubled-name repair and media purge | 2.54.0 | Dry-run repair; delete a channel's copies then originals |
Accounts & security
| Feature | Since | What it does |
|---|---|---|
| Multi-tenant accounts and roles | 1.0.0 | Each user sees only their own channels, media, EPG and stats |
| User management | 1.7.0 | Create, edit, suspend, delete (files too) |
| Invite codes and registration control | 1.8.0 | Per-code limits, uses and expiry |
| Demo accounts | 2.47.0 | Watermarked, 720p, lifted by an admin |
| Suspension | 2.26.0 | Stops channels, blocks starts and uploads, deletes nothing |
| Session revocation | 1.68.0 | A deleted account, password or role change ends its tokens |
| Login and recovery limits | 1.2.0 | Per-IP and per-account windows; recovery off by default |
| Two-factor sign-in | 3.60.0 | Authenticator-app codes (TOTP) per account, recovery codes, replay refused, sessions signed out on change, admin reset |
| Per-install secrets | 1.78.0 | JWT secret and admin password generated on first run |
| Secrets at rest | 3.48.0 | AES-256-GCM keyring for passphrases, keys, node and YouTube tokens |
| Keyring off the host | 3.54.0 | From a command (KMS, vault), a descriptor, the environment or a systemd credential; fails closed |
| Content Security Policy | 2.0.1 | script-src 'self' on every page (3.51.0) |
| Audit trail | 2.31.0 | Who did what, from where; every action listed and checked against the code (3.54.0) |
| Every change audited | 3.55.0 | Channel edits (field names only), nodes, keys, invites, delivery settings, accounts, encodes, snapshots; a code check fails the build on an unaudited write |
Operations
| Feature | Since | What it does |
|---|---|---|
| As-run log | 1.10.0 | Tab, filters, CSV, API; per-item bytes (3.52.0), exact to the item (3.55.0) |
| Live stats and health chips | 1.0.0 | Stats tab, SSE, SRT and L-shape chips that follow their state (3.52.0) |
| System utilisation and capacity | 1.8.0 | CPU, memory, disk I/O, egress, headroom estimate |
| Auto-resume | 1.9.0 | Channels resume at their scheduled position after a restart |
| Prometheus metrics | 2.19.0 | Off until metrics_allow_from is set |
| Health check and watchdog | 3.38.0 | /healthz; systemd notify heartbeat (2.62.0) |
| Snapshots and restore | 2.29.0 | Every 6 h, newest 8, verified; restore with a pre-restore snapshot |
| Flight recorder | 2.59.0 | Ring of 30 s vitals; unclean shutdowns reported |
| Built-in HTTPS | 1.16.0 | Let's Encrypt on 443 with redirect from 80 |
| Deployment guide | 3.39.0 | /guide/deploy: users, layout, unit, ports, backups; rewritten around the bundle (3.57.0) |
| Deploy bundle and installer | 3.57.0 | Binary, unit, install.sh and notes in one archive; a fresh install, a checked upgrade that rolls itself back, --rollback |
| Hardened systemd unit | 3.57.0 | Notify 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 -version | 3.57.0 | The configuration checked for production without changing anything; exits 1 on an error |
| Process metrics | 3.57.0 | Goroutines, heap, resident memory, open files, threads and uptime in /metrics; transport and HLS bytes apart |
| Prometheus alert rules | 3.58.0 | playoutgo-alerts.yml in the bundle: thirteen alerts, every metric they name checked against /metrics, with promtool rule tests |
| install.sh --tls | 3.58.0 | A 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 own | 3.59.0 | tls.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-run | 3.58.0 | The 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 parsers | 3.56.0 | Every 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 tests | 3.56.0 | Features aired together to a real SRT receiver and checked as a stream monitor would: tables, continuity, clock, rate, picture and sound |
Interface
| Feature | Since | What it does |
|---|---|---|
| Web console and in-app manual | 1.0.0 | Embedded console; the Help tab embeds this manual |
| Tutorials and reference pages | 1.7.0 | Five tutorials, recipes, comparison, FAQ, glossary |
| Guided tour | 2.74.0 | A spotlight walkthrough offered once |
| Accessibility | 2.17.0 | Labels, real tabs, focus trap, AA contrast (3.47.0) |
| Console dialogs | 3.48.0 | Labelled dialogs replace every native prompt (3.51.0) |
| Floating preview dock | 2.53.0 | On-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
- A multicast network and a head-end, IRD or multiplexer for the UDP/RTP output (3.63.0): it is verified on the loopback interface and to a unicast receiver; IGMP/PIM routing and real receiving equipment are untested here.
- The composite node drawing
overlaysandlogo_urlon its built-in frame (3.53.0 sends both; see the node contract). Until the node does, traffic graphics reach air only on channels whose graphics are a page. - NVENC on real hardware: the local-GPU path, GPU decoding (NVDEC,
scale_cuda) included, is tested with stand-in tools and the software encoder. - NVENC filler data on the production ffmpeg build (the test ffmpeg has no such option).
- On-air graphics on a real GPU composite node, and gpuff's
mp4muxoutput. - A real YouTube OAuth client (tested against a stand-in Google).
- 3ABN's answers: acceptance of generated logs and airing numbers; the undocumented fields; weekend and other-channel dayparts; the DST convention; the hour-spot scheme; episode rules per series; which RUSS items are fillers.
- A real A/53 caption master to verify end to end, including the
CLOSED-CAPTIONSdeclaration on Apple players. - A key-management service of yours behind
secrets_key_command(tested with a stand-in command), and a TPM-sealed systemd credential (an encrypted credential under the host key is tested on a real systemd, 3.59.0; no TPM here). - Firefox and WebKit runs of the browser proofs, and Safari and tvOS for the audio menu (only Chromium is installed here).
- A certificate from Let’s Encrypt itself: no public DNS name reaches the build host. Issuance, the cache and renewal-free restarts are tested against Pebble, Let’s Encrypt’s own ACME test server, on the real ports 443 and 80 (3.59.0); the first start on a host whose DNS points at it is the last step.
- The first install on a production host, and moving plt.bozztv.com’s hand-made setup over. The installer is tested on a real systemd (version 255, as PID 1 of its own namespaces, 3.59.0) β install, confinement, notify and watchdog, restart, upgrade, rollback, boot β but with the distribution’s other services not running.
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.
- Subtitles: no SRT, WebVTT or SCC sidecar import, no WebVTT rendition in HLS, no DVB subtitles or teletext. A/53 captions in the video are carried.
- Live inputs: RTMP only β no SRT, UDP/multicast or HLS-pull ingest, and no scheduled live event (a take at a set time).
- Outputs: UDP/RTP multicast is built (3.63.0, UDP / RTP output) but has no FEC (SMPTE 2022-1), 2022-7 or SAP; no DASH, CMAF or LL-HLS; no HEVC, MPEG-2 or AC-3/E-AC-3 output.
- Protection: no HLS encryption or DRM β AES-128, SAMPLE-AES, Widevine or FairPlay (CPIX, or a key server such as wvgate).
- Catch-up and compliance: no timeshift or DVR window, and no air-check recording of the output.
- Ad signalling: SCTE-35
splice_insertonly β notime_signalwith segmentation descriptors, no SSAI/VAST hand-off, no SCTE-104. - Redundancy: no main/backup pair with sync and automatic switch-over; one SQLite database, its snapshots on the host only.
- Alerting from the server itself: webhooks are built (3.63.0, alert webhooks); no e-mail of its own β a relay or Alertmanager with the shipped Prometheus rules sends it.
- EAS/CAP emergency alert insertion.
- Monitoring: no multiviewer with audio and loudness meters on air (files are measured; output is not).
- Accounts: no SSO (OIDC, SAML); no operator or read-only role inside an account (admin and user only); two-factor cannot yet be required of administrators.
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
- πΊ 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.
- π 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.
- π 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.
- βΆ HLS Player β quick in-browser preview of any HLS URL (served through the built-in CORS proxy).
- π‘ 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. - π Stats β live throughput, viewer counts, play history, and the channel error log.
- π‘ 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"
}
| Key | Default | Description |
|---|---|---|
| port | 8700 | HTTP listen port for the console, API, HLS and feeds. |
| rtmp_port | 0 (= 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_email | admin@playout.local | The administrator's login, used on first run only. |
| admin_password | random | Generated (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_secret | random | Generated (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_path | playout.db | SQLite database file. |
| secrets_key_file | secrets.key beside the database | Keyring 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_lufs | 0 (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_lu | 0 (= 2) | How far from the target a file may be before it is normalised (A/85: Β±2 dB). |
| loudness_true_peak_dbtp | 0 (= β2) | The true-peak ceiling; a file over it is normalised. |
| conform_x264_preset | veryfast | The x264 preset conform uses when an upload’s video must be re-encoded to H.264 (conform, 3.60.0). |
| secrets_key_command | none | Take 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_dir | uploads | Originals, in user_<id>/ folders. |
| hls_dir | /dev/shm/playout_hls | HLS segments and the generated slate. Keep it on tmpfs. |
| encoded_dir | encoded | Renditions 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_dir | media-cache beside the database | Where files restored from S3 land. |
| require_invite_code | false | Registration needs an invite code (Invite codes). |
| max_upload_mb | 10240 | Largest 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_seconds | 0 (= 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_fallback | fail | When no node can take an encode: fail, local_gpu or local_cpu. |
| local_gpus | auto | This server's NVIDIA cards as encode capacity: auto, off, or card numbers such as "0,2" (Encoding on this server's GPUs). |
| local_gpu_slots | 0 (= 2) | Encodes each local card runs at once, 1β8. |
| local_gpu_decode | false | Local 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_port | 0 | A 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_headers | false | Believe 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_reset | false | Turns 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. |
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.
- Log in with the admin credentials from
config.json(defaultadmin@playout.local). Change the password from the π button in the header. - 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 - 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. - Fill the rundown. Open π Rundown, pick
demoin the channel selector, and click files in the right-hand Add Files panel to append them. - Start it. Back on Channels, press βΆ Start. Within ~10 seconds the card shows β LIVE.
- 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:
- β GAP β 00:14:00 β your first three items end at 18:46, so 14 minutes are unaccounted for. Options: add content, move the anchor earlier, or assign a gap filler to replace the built-in black slate with your own clips.
- β OVERLAP (red row) β content before an anchor runs past its air time, so the anchored item starts late by that amount. PlayoutGo never truncates a file: the item already playing always runs to completion. The delay does not accumulate β the next anchor is an absolute wall-clock time, so if there is slack before it the engine waits there and the schedule self-corrects. Shown as a full-width row, like a gap, because a late programme is at least as serious as dead air.
- +00:07 over (red, on the anchor) β your content runs past 19:00. The bulletin will start late. Options: remove or shorten an item, or move the anchor.
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.
- Create the channel with Output mode = Both (HLS for viewers, SRT for a downstream CDN or transcoder) and Loop playlist ticked.
- 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.
- 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.
- 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).
- 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.
- 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.
- As admin, open π‘ Admin and create a user with an email, a password of at least 8 characters, and role
user. - They log in and see only their own channels, media, rundowns, EPG feeds and stats. Admins see everything.
- 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.)
- 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.
- Each tenant's
/m3u?token=FEED_TOKENand/epg/my?token=FEED_TOKEN(from their EPG tab, orGET /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)
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.
| System | Shape | Where PlayoutGo differs |
|---|---|---|
| ffmpeg + concat/cron | DIY scripts | The 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 Studio | Live production switcher | OBS 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. |
| CasparCG | Broadcast graphics/playout | CasparCG 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 / Nimble | Streaming servers | Those 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 suites | They 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 platforms | Managed SaaS | Managed 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
- You want many unattended file-based channels per server, without paying to re-encode content that is already H.264/AAC.
- You need a genuine schedule (hard starts, gap/overrun visibility, mid-item restart resume) rather than a shuffle loop.
- You want a single self-contained binary (static with
CGO_ENABLED=0; ffmpeg optional, for the extras) and your content on your own disks. - You're feeding a downstream CDN/transcoder over SRT, or serving HLS directly.
Choose something else when
- You need live camera/studio switching or rich layered CG β OBS, vMix, CasparCG. (PlayoutGo does have an L-shape frame with headline, ticker and logo, composited by a node β L-shape graphics β and burned-in QR codes.)
- You need DRM, live transcoding or SDI output β a platform downstream (PlayoutGo serves HLS ABR from a ladder encoded ahead of time; SRT and RTMP carry one rendition).
- You need ad sales, traffic/billing integration or contractual support β a commercial playout suite.
- You need frame-accurate SDI compliance for a licensed broadcaster β dedicated broadcast hardware.
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
| Anchor | A rundown item pinned to an exact wall-clock air time (π). The engine waits β playing filler or padding β to hit it precisely. |
| Floating item | An item with no stored time (π); it airs immediately after the previous one and its displayed time recomputes whenever the rundown changes. |
| Gap / underrun | Unfilled 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). |
| Overrun | Content running past an anchor's start time, so the anchored item begins late. |
| GOP | Group of Pictures β the span from one keyframe to the next. Segment boundaries and clean stream joins can only happen on keyframes. |
| Keyframe / IDR | A frame decodable on its own, carrying SPS/PPS headers. A stream that starts anywhere else produces "non-existing PPS" errors. |
| Discontinuity | An HLS tag telling the player that timing/encoding parameters change at that point β emitted at every file transition. |
| Null packets | Padding 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 ID | The SRT identifier a caller sends to select a channel. Must match exactly; empty or wrong IDs are rejected. |
| Caller / Listener | SRT roles. Listener = PlayoutGo waits for clients to connect (pull). Caller = PlayoutGo dials out to your ingest (push). |
| FAST | Free Ad-supported Streaming TV β linear channels delivered over IP, the typical use case for this system. |
| As-run | The 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:
- An independent playlist (ordered queue of media files)
- Optional scheduled_at times on individual playlist items
- An output mode:
hls,srt, orboth - SRT connection parameters (used when output mode includes SRT)
- A loop_playlist flag β when true the playlist auto-resets when all items are played
Channel statuses
| Status | Meaning |
|---|---|
| stopped | Channel is idle, no output being generated |
| connecting | Channel has been started; waiting for SRT connection or first frame |
| running | Actively playing a media file and producing output |
| error | Stopped 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.
- Video: H.264 8-bit 4:2:0 airs as it is; HEVC, ProRes, MPEG-2, VP9, AV1 and 10-bit or 4:2:2 H.264 are re-encoded to H.264.
- Audio: AAC airs as it is; AC-3, E-AC-3, MP3, MPEG audio, PCM and the rest are converted to AAC β before 3.60.0 such a track aired as silence.
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:
- another container is re-wrapped to MP4, and the file renamed
.mp4; - video other than 8-bit 4:2:0 H.264 is re-encoded with x264 (
conform_x264_preset, CRF 18, a keyframe every two seconds), its A/53 captions carried into the new stream; H.264 that can air is copied untouched; - each audio track that is not AAC is converted to AAC at 48 kHz (192 kb/s stereo, 384 kb/s surround, at most 5.1); every track is kept, with its language;
- with a loudness target, a track outside it is normalised (the video copied).
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.
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:
- π Anchored β the item has an exact air time (
scheduled_atis set). The engine covers the wait β gap filler if the channel has some, otherwise the built-in black-and-silence slate β until that wall-clock moment and starts precisely on it. Anchored times display in bold yellow and are edited inline. - π Floating β no stored time. The item airs immediately after the previous one ends; its computed start is shown in grey. Reordering, adding, or removing items reflows all floating times instantly. Anchors never move.
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
- β unknown duration β a file whose length was never recorded is treated as zero-length, so every Start and Ends value after it, and any gap or overrun warning, becomes unreliable. The rundown flags these items and shows a banner; recover the metadata with Admin β Re-probe Broken Files.
- β GAP (amber row) β content before an anchor ends early. If the channel has a gap filler configured, the row names the clip that will loop there; otherwise it warns of dead air. Fix by assigning a filler, adding content, or moving the anchor earlier.
- +MM:SS over (red badge on an anchor) β content before the anchor runs past its start time. The engine will begin the anchored item late. Fix by removing/trimming content or moving the anchor later.
Orientation aids
- NOW line β a red rule at the current wall-clock position with a live countdown to the next item; the view auto-scrolls to it when the rundown loads.
- Day separators β automatic date headers when the schedule crosses midnight.
- Footer totals β item count, total content duration, anchor count, and the covered time span.
- Timezone label β the header shows which timezone all displayed times use: the channel's timezone (its
timezonesetting, an IANA name such asAmerica/New_York), falling back to the browser's. Stored values are UTC RFC-3339.
Operator tutorial: building a broadcast day
- Open Rundown, select the channel.
- Add content from the right-hand panel (search, click to append).
- Drag rows to order the day. All times reflow as you drag.
- Set the day start: pick a time in Schedule day from and press β© Set & flow. This anchors item 1 and lets everything else float.
- 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.
- 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
- Checkbox column with shift-click ranges; a bulk bar appears with Move Top / Move Bottom / β§ Duplicate / Clear times / Remove. Duplicate appends floating copies of the selection at the end β the quick way to repeat a block later in the day.
- β© Undo (up to 10 levels) reverts schedule and order mutations β cascades, clears, reorders, bulk moves. The stack resets when you switch channels.
- All bulk schedule writes go through one atomic API call β a failure changes nothing (no half-written rundowns).
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
- Keyboard reorder (3.47.0): β and β on each row move an item one place, with the same on-air question and undo as dragging; focus stays on the moved row.
- Channel timezone (1.57.0): each channel may set an IANA zone (
timezone, e.g.Europe/London); the rundown, the Day and Week grids, repeated days and imports all use it. - Fill the rest of today (2.70.0): spreads random items from your library across what is left of the day as minute anchors, and reports how many hours it covered (
POST /api/channels/{id}/schedule/fill-day). - Shuffle on every loop (2.95.0):
shuffle_on_loopre-orders a looping rundown each time it starts over. - Media-delete protection (1.73.0): deleting a file that is on air or scheduled is refused, naming the channel;
DELETE /api/files/{id}?force=1deletes it anyway.
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
- The stream never stops. Even with no filler configured, the engine plays a generated black-and-silence slate (10 s of 1280Γ720 H.264 with 48 kHz stereo AAC, made once with ffmpeg) on every output: HLS, SRT, the RTMP push and every ABR rung. Segments decode, SRT clients stay connected and the channel stays running. Viewers see black with silence, not a dropped stream. Null packets are used only when no slate can be generated (no ffmpeg on the server) β and for an empty rundown.
- With a filler configured, the engine fits your clips into the hole and plays them, covering whatever is left over with the slate.
- The anchor still starts on its exact second. Filler is only ever scheduled when a whole clip fits before the deadline, with a 300 ms safety margin, so covering a gap can never delay the programme that follows.
- Without ffmpeg there is no slate, and the gap is dead air: program tables, a running clock and padding. On SRT since 3.56.0 they are the programme’s own tables β the same map PID, stream PIDs and clock PID β so a receiver that tunes in during the gap decodes the programme that follows. Until then dead air used another layout under the same table version; a receiver that joined in the gap kept the wrong map and decoded nothing, for as long as it stayed connected. An avail still open when its item ended returns on time inside the gap.
- A live take during any gap goes to air at once (3.56.0, live insertion).
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:
| Gap | Single 2:00 slate | Pool of 2:00 + 1:00 + 0:20 + 0:10 |
|---|---|---|
| 3:20 | 2:00 played, 1:20 of black | 2:00 + 1:00 + 0:20 β nothing left |
| 0:50 | 0: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
- Upload your filler clips like any other media (short promos, idents, sponsor tags, a branded slate).
- Open the channel's settings and select them under Gap filler content β β-click or Ctrl-click for several.
- Save. The change takes effect at the next gap; no channel restart needed.
Choosing good filler
- Mix the lengths. A pool of 2:00 / 1:00 / 0:30 / 0:10 packs almost any hole exactly. Four clips of 2:00 pack no better than one.
- Include something short (10β20 s). The remainder covered by the slate is always shorter than your shortest clip, so a short clip keeps it short. (The log calls that remainder "null-padded"; it airs as the slate.)
- Match your channel's format β same resolution and frame rate as your programming, with real audio (silent is fine, but the track must exist; PlayoutGo adds a silent one automatically if it doesn't).
- Keep it evergreen. Filler plays at unpredictable times, so avoid anything with a date or "coming up next" reference.
How this compares with professional playout systems
An honest positioning of what PlayoutGo does and does not do here:
| Technique | PlayoutGo |
|---|---|
| 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.
- Only ready files are used. Anything still probing, or marked
error, is skipped β scheduling those produces dead air at transmission time. The result tells you how many were skipped and why. - No back-to-back repeats. When more items are requested than you have files, the library is reshuffled for each pass rather than repeating one fixed order, and the seam between passes is checked so the same file never plays twice in a row.
- Duration fill adds items until the requested length is reached, so the last item may overshoot slightly β the result reports the true total.
- Reproducible when you supply a
seedvia the API: the same seed and library always produce the same order, which is useful for testing.
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>
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:
- Status β what a channel is doing right now (running, stopped, error).
- Desired state β what you asked for. βΆ Start marks the channel as should be running; βΉ Stop clears it.
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?
- With a schedule β the engine walks your anchors, chains floating-item durations after them, compares against the wall clock, and seeks mid-file into the item that should be airing now. A channel anchored 25 minutes ago with three 10-minute items resumes 5 minutes into the third item.
- Without a schedule β playback continues from the next pending item.
- Empty rundown β the channel is left stopped with a note in its error log rather than spinning.
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.
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
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
- The last row with verdict
okand zero unhealthy channels is your measured density for that content. IO_BOUNDin the verdict means storage, not CPU, is your ceiling β move media to SSD/NVMe before buying cores.- Run it with representative content. A 640Γ360 test pattern will give a flattering and useless number; benchmark with the bitrate and resolution you actually air.
- Benchmark on an otherwise quiet host. If the machine is already loaded by other work, you are measuring that, not PlayoutGo.
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 / Ended | Wall-clock times playback actually began and finished. |
| Duration | Measured 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. |
| Sent | Bytes 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.) |
| Status | completed 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
Output Modes
hls_dir. No SRT connection attempted. Best for internet streaming, browser preview, and Plex/Emby/Jellyfin.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
- The URL is
rtmp://HOST[:PORT]/live/<key>.HOSTcomes fromadvertised_base_urlβ without it the dialog shows127.0.0.1, which only works for an encoder on this machine.PORTisrtmp_port(0 means 1935, which is left out of the URL). rtmp_port: -1turns the publishing point off. The dialog then shows no URL and says "RTMP publishing is turned off on this server (rtmp_port is -1 in config.json)"; a port that could not be opened, or a listener that closed, is reported the same way. A failed accept no longer ends the listener: it retries with a back-off (3.52.0).- New channels get a random key,
live_plus 48 hex characters (192 bits). A channel created before 3.48.0 may still accept the guessablech<id>; the dialog warns, and the server lists such channels at startup. New key issues a fresh one (refused while the feed is on air); update the encoder afterwards. Keys are stored encrypted (secrets at rest); a key this server's keyring cannot decrypt accepts nothing and the dialog says so. - A wrong key is refused at publish with "no channel is configured with this stream key".
What is accepted
- RTMP publish, simple handshake, version 3. Video must be H.264 (FLV codec 7) β anything else is reported as "the publisher is not sending H.264". Audio must be AAC; other audio is ignored. No HEVC or enhanced RTMP.
- The AAC is wrapped in ADTS on the way in, so every output decodes it (before 3.52.0 the raw frames reached air undecodable β silence). SPS/PPS are put in front of every keyframe.
- Long publishes stay in sync past 4 h 39 min, where RTMP timestamps need the extended field, including on continuation chunks (3.52.0).
- One encoder per key. A second publisher is refused with "another encoder is already publishing with this stream key" while the first sent anything in the last 3 s. If the first has gone quiet (an encoder reconnecting after a network blip), the newcomer replaces it and the stale connection is closed. Only the owning connection's media and disconnect count (3.52.0 β before, two encoders on one key were mixed into one feed).
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).
- Take (
POST β¦/live/take) is refused with 409 unless the feed is ready and the channel is on air. The cut happens on the feed's first keyframe: between items, or mid-item on a video keyframe of a file item β a single-rendition item or, since 3.54.0, an ABR item, whose every rung then carries the feed. During a gap β gap filler, the slate or dead air while the channel waits for its next scheduled item β the cut is immediate (3.56.0; until then it waited for the whole gap, however long). When the feed ends the channel goes back to its wait, and the scheduled item still airs at its time. An avail that is open when the feed is taken is closed at once with its return marker. The interrupted item is recorded as completed and the schedule resumes after it. - A take left on when a channel stops β stopped by an operator, or restarted for a settings change, while live was on air β is resumed at the next start only while its feed is still sending (a frame within 3 s); otherwise it is dropped and the channel starts on its schedule (3.57.0). Before, it was resumed regardless: with the publisher gone, the channel aired nothing for five seconds β only padding on a constant-rate channel β and logged a live as-run row and a
live.lostentry for an event that had ended. - While live airs, the feed's timeline continues the channel's, so there is no timestamp jump; the feed's single AAC track feeds every language slot.
- Return (
POST β¦/live/return) hands back to the schedule within 100 ms. - Feed lost. If nothing arrives for 5 s the channel returns to its schedule by itself and writes the audit entry
live.lost(3.52.0 β before, the channel sat on dead air until someone pressed Return). - As-run. A live insert is a row of its own, labelled
LIVE β RTMP from <address>, with statusliveand, when the feed was lost, the reason (3.52.0).
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
- In the Rundown, π’ on a row opens the break editor (mm:ss). A pattern β every N minutes, D seconds long, first at M β fills an item, and apply to every item lays the same structure across the rundown. Applying an empty list to every item removes every break; that asks first, and runs inside the question so a refusal is shown there (3.52.0).
- The API:
POST /api/playlist/item/{id}/breakswith{"ad_breaks":[{"at_ms":600000,"duration_ms":120000}]}. An empty list clears the item. At most 64 breaks;at_msβ₯ 0 and before the item's (trimmed) end;duration_ms1 msβ30 minutes. The answer says what will actually be signalled:{ad_breaks, requested, effective, item_duration_ms}. A break that starts inside the previous one is dropped and counted. at_msis measured from where the item starts as aired β after a trim-in point.- A resumed item (a restart mid-item) skips breaks before the resume point, and the rest fire at the right moment from there (3.52.0 β before, elapsed time restarted at zero, so a past break fired again).
On the SRT transport stream
- The program map declares the splice stream once a channel has breaks or a Break now: stream type 0x86 with a
cue_identifier_descriptor(0x8A, cue stream type 0), and the programme carries theCUEIregistration descriptor SCTE 35 Β§8.1 requires (3.52.0 β without it ffprobe and many splicers read the PID as unknown data). - The PID is above every elementary stream: 0x111 with one audio track, 0x112 with two languages, and so on.
- The cue-out is an immediate
splice_insertwithout_of_network_indicator=1and abreak_duration(auto_return=1). The matching return (out_of_network_indicator=0) goes out when the avail ends, on whatever is airing then β the same item, the next one, or a live feed (3.52.0 β an avail that outlasted its item never got its return). Each section has its own continuity counter (3.52.0 β every splice packet used to carry counter 0, so a receiver discarded the return as a duplicate).splice_event_idstarts at the engine's start time and counts up. - No section goes out before a program map declaring its PID has reached the receiver; a new receiver starts that check again. With no SRT receiver connected, breaks are signalled on time anyway β in
bothmode HLS viewers no longer wait for an SRT receiver to get their cues (3.52.0).
In HLS
#EXT-X-CUE-OUT:<seconds>and#EXT-X-DATERANGE:ID=β¦,START-DATE=β¦,PLANNED-DURATION=β¦,SCTE35-OUT=0xβ¦on the segment where the avail begins, carrying that avail's own section;#EXT-X-CUE-OUT-CONT:<elapsed>/<planned>while it runs;#EXT-X-CUE-INwhen it ends.- Every media segment carries
#EXT-X-PROGRAM-DATE-TIME, which RFC 8216 requires alongside DATERANGE (3.52.0). - HLS segments carry no in-band SCTE-35; the cue tags are the HLS signal.
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
- The probe records each file's tracks (
audio_tracks: language, codec, channels, sample rate);undcounts as untagged. The Media library shows a badge for multi-track files. - At the first file it plays, the channel fixes its set: the first item's first-track language is the main slot, then every new language found on the other tracks of the rundown, in rundown order. The set stays for the whole run, so a programme without Spanish does not make the Spanish stream disappear. Rundown edits take effect at the next start.
Routing each item into the slots 3.52.0
- Each slot takes the item's track tagged with its language.
- The main slot falls back to the item's first track.
- Untagged tracks fill the remaining slots in order.
- 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
- SRT: one AAC stream per slot (main on PID 0x101, then 0x102β¦), each with an ISO 639 language descriptor in the program map.
- HLS (3.54.0):
index.m3u8is a master playlist with anEXT-X-MEDIAaudio rendition per language β the main one muxed in the variant, each other one an audio-only playlistaudioN.m3u8β so a player offers an audio menu. Every variant segment still carries every slot. See the master playlist. - RTMP push: the main language only β RTMP carries one audio track.
- Encodes keep every track (3.52.0): local encodes and demo watermarking map all audio (
-map 0:a?), where they used to keep only the first. Local encodes re-encode audio to AAC stereo; a remote node decides for itself.
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
- CEA-608/708 captions carried in the video as A/53 SEI pass through untouched: playout copies every NAL unit, so captions in a file β or in a live feed β reach HLS and SRT. The probe sets
has_captionsand the Media library shows a CC badge; it is informational and changes nothing. - Every encode on this server carries them forward explicitly (
-a53cc 1): local GPU and CPU encodes, and demo watermarking (3.52.0 added it there). For a remote node, carrying them is up to the node. - PlayoutGo does not author captions. Since 3.54.0 the HLS master declares them when any item in the rundown has
has_captions:#EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc",NAME="CC1",INSTREAM-ID="CC1"andCLOSED-CAPTIONS="cc"on every variant, an ABR channel's rungs included. Apple players show a captions menu only for a declared track. Like the languages, this is fixed when the channel starts.
DVB Service Information 2.5.0
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).
| Field | Default | Rule |
|---|---|---|
| service_id | channel id + 1 (since 3.53.0 also for a channel created with service information; it used to get 1) | 1β65535 |
| transport_stream_id | 1 | 0β65535 |
| original_network_id | 1 | 0β65535 |
| service_name | the channel name | required; trimmed to 200 bytes |
| provider_name | PlayoutGo | trimmed to 40 bytes |
| service_type | 1 (digital TV) | 1, 2, 10, 17, 22 or 25 |
| country, offset_minutes | blank, 0 | three letters; Β±840 minutes |
| network_id, network_name | β | set both to emit a NIT |
What goes out
| Table | PID | Every |
|---|---|---|
| SDT (actual) | 0x11 | 500 ms β one service, its name and provider |
| TDT + TOT | 0x14 | 25 s β UTC time, and the local offset for the country |
| NIT (actual) | 0x10 | 8 s β only when a network name is set |
- The PAT's
transport_stream_idand the programme number in the PAT and PMT are the service's own numbers (3.52.0 β they used to say programme 1 in transport stream 0, so a receiver could not join the SDT's name to the programme it was watching). - The clock: the programme's video packets carry the PCR, on the PID the PMT names; while no programme flows (a gap, a receiver waiting for its first keyframe) PCR-only packets on that PID keep it running every 30 ms, continuing its continuity counter (3.52.0 β they used to go on the audio PID).
- The tables go out on their schedule while the programme airs (3.52.0 β they used to be sent only in gaps and while a receiver waited for a keyframe, so after its first second a receiver saw no SDT at all).
- No EIT (programme guide) β the guide is XMLTV (XMLTV).
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.
qr_url: empty, or anhttp:///https://address of at most 666 bytes (QR version 20 at error correction M). It is checked when the channel is saved and refused withqr_url: β¦(3.52.0 β it used to be checked only when an encode rendered it, so a bad address saved fine and every encode went out without a code).qr_corner:top-left,top-right,bottom-leftorbottom-right(default; alsotl,tr,bl,br, any case), stored in full. Anything else is refused withqr_corner must be β¦(3.53.0 β it used to be stored and silently meant bottom-right; a corner stored that way before keeps meaning bottom-right when the channel is saved for another reason). Inset 3% of the picture height from both edges.- Size: 11% of each rendition's picture height β about 119 px on 1080p, 79 px on 720p β raised so the shortest rung still gets at least 64 px, capped at 25%. A channel whose tallest rendition is under 480 lines gets no code (it would not scan); the log says so.
- Only encoded output carries it. A channel playing original files has nowhere to burn it in, so Re-encode refuses (choose single bitrate or ABR first). Live feeds, the slate and filler that is not in the rundown carry none.
- Re-encode (
POST /api/channels/{id}/reencode) queues each ready file in the rundown once, however often it is scheduled. A file already waiting in the queue for this channel is not queued again β it reads the channel's current code when it starts (3.52.0 β pressing Re-encode twice queued everything twice). A file encoding right now is queued again, because that run started with the old code. The answer givesqueued,skipped,already_queuedand an ETA. - Saving a new address changes nothing on air by itself; each item shows the new code the next time it starts after its re-encode finished.
Graphics & Branding β What Goes on the Picture
| What | How it gets there | Where |
|---|---|---|
| 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 fails | L-shape graphics |
| QR code | Burned in at encode time | QR codes |
| Rating, daypart logo and E/I bugs from a traffic log | A 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" watermark | Burned into every upload of a demo account, at most 720p | Demo 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 |
- The built-in frame is drawn by the node from two colours β
bg_color(default#101820) andaccent_color(default#2ea0ff) β sent asbar_backgroundandbar_accent(3.52.0: they were never sent before). Headline and ticker edits reach air live, without rebuilding the session. - A page (
htmlup to 64 KB, or aurl) is rendered by a node with headless Chromium and re-captured everysnapshot_secβ default 300, at least 30 (3.52.0: the default sent was 5 seconds). Graphics and logo URLs must resolve to public addresses, or to hosts ingraphics_allowed_hosts. - The channel card shows β£ L-SHAPE while viewers get the composited picture, FALLBACK β clean output when the node is not delivering, resuming at boundary when it has recovered, and composited when the channel is stopped (3.52.0: a stopped channel was shown as FALLBACK, and the chips did not update until something else on the card changed).
- Traffic graphics on the built-in frame (3.53.0): the node is sent the cues on air as
overlaysβ code, image URL, corner, width and margin, the same objectscues.jsongives a page β in the session spec, and again through/v1/composite/{id}/updatewhenever the set changes (checked twice a second; an empty list clears them). Every text update carries them too. Until 3.53.0 they never reached air on the built-in frame. - The logo (3.53.0):
logo_urlwhen set, otherwise the logo uploaded for the channel, served at/graphics/{id}/logowith a version that changes with the file. Uploading a new logo rebuilds a running session with it. Until 3.53.0 the upload was never used by the L-shape.
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).
{"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.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.{"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.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.
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.
{"used_bytes", "quota_bytes",
"suspended"}. quota_bytes of 0 means unlimited. This is what draws the bar
above the upload area.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.
{"demo": true|false}. Returns the updated
user. Audited as user.demo.applied / user.demo.lifted.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...
?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
| Token | Where it goes | Lifetime | Opens |
|---|---|---|---|
| account (JWT) | Authorization header | 72 h; /api/auth/refresh renews; a password or role change ends it | every API route the account may use |
scoped (pβ¦) | ?token= of a player, preview or EventSource URL | 6 h | GET/HEAD on /api/hls-proxy, /api/files/, /api/stream/stats* |
feed (fβ¦) | ?token= of an XMLTV or M3U subscription | no expiry; changing the account's password revokes it | /epg, /epg/my, /epg/{id}, /m3u β nothing else |
{"token","expires_at"}./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.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.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.current_password and new_password. Changes persist across restarts.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.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.
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.
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
- In the Google Cloud console: a project, the YouTube Data API v3 enabled, and an OAuth client of type Web application.
- Add the redirect URI the
statusendpoint reports β it is<advertised_base_url>/api/youtube/callback. Google rejects anything not listed there, so it must match exactly. - Put the client id and secret in
config.jsonasgoogle_client_idandgoogle_client_secret.
access_type=offline and prompt=consent β without both, Google returns
no refresh token on a repeat authorisation and the connection silently lasts one hour.state value this server
issued, which expires in ten minutes and is accepted once.title,
description and privacy; privacy defaults to unlisted
because a broadcast created by mistake should not be announced to a channel’s
subscribers.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."}
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
- 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. - 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.
- Start the channel. The push connects, and retries by itself with a widening delay if the service drops it.
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
- A DeckLink card and Blackmagic’s Desktop Video driver.
- ffmpeg built with
--enable-decklinkagainst Blackmagic’s Desktop Video SDK. Distribution packages are not built that way.GET /api/sdi/devicessays whether this server’s ffmpeg can drive a card, and why not when it cannot.
Setting it up
- As an administrator, open the channel dialog: SDI output (Blackmagic DeckLink). The
DeckLink output box suggests the cards ffmpeg sees (as
ffmpeg -sinks decklinknames them:DeckLink Duo (1)). - 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.
- Choose the embedded audio: 2, 8 or 16 channels. The channel’s main audio is on channels 1 and 2; the others are silent.
- 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.
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).{"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.decklink_supported, the DeckLink outputs ffmpeg sees (devices), the
formats, and why when there is nothing to choose.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
- As an administrator, open the channel dialog: UDP / RTP output.
- 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). - 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). - 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). - Pacing:
-1spreads 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;0sends each frame’s packets at once. Paced, a queue holds two seconds; a stream faster than the pacing rate drops datagrams and counts them. - Set it On and press Apply UDP settings. On air, it starts at once.
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).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
| Event | When |
|---|---|
channel.down | A channel’s engine stopped unexpectedly (it restarts itself), or gave up after five restarts in five minutes. |
channel.up | A channel reported down is on air again. |
channel.error | A line in a channel’s error log; the same text is sent once in ten minutes. |
output.down | An 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.up | That output is delivering again. |
media.unplayable | A file was found unplayable and marked for attention. |
dead_air | A gap aired as dead air β no filler and no slate; once in ten minutes per channel. |
test | The 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
- A 2xx answer is delivered. Anything else, or no answer within 15 seconds, is retried after 10 s, 30 s, 2 min, 10 min and 30 min; after that the delivery is marked failed. Each webhook keeps its deliveries, with the last code and error, for seven days.
- Redirects are not followed. The URL must be http or https, with no user name or password in it. Link-local addresses (cloud metadata at 169.254.169.254 among them), multicast and the unspecified address are refused β when the webhook is saved, and again when each connection is made, after DNS. Private and loopback addresses are allowed: a relay or Alertmanager usually lives there.
- The audit trail names the URL without its query, where a relay’s token often sits.
{"url","description","events","channels","enabled"}. The answer carries the
secret, once. Audited.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.Watching a Running Server
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.
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.
| Channels | Memory | CPU |
|---|---|---|
| 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.
seconds or
duration_ms; thirty seconds if neither is given. Works on any item, including one
with no scheduled breaks.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.
curl -s $BASE/api/collections -H "Authorization: Bearer $TOK"
β {"collections":[{"name":"News","files":42},{"name":"Idents","files":8}]}
collection removes the label.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"}
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)"}
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
| Status | Means | What to do |
|---|---|---|
400 | The request was understood and is wrong β a missing field, an impossible value. | The body names the field. Fix it and resend. |
401 | No token, or it has expired. | Log in again, or call
/api/auth/refresh before the 72 hours are up. |
403 | Authenticated, but not yours. | Channels belong to accounts; an operator cannot touch another tenant's. |
404 | No such thing, or it has been cleared. | For a job, it has already been removed from the list β start it again. |
405 | Right 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.) |
409 | The 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.) |
202 | Accepted 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
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.name. Optional: description, output_mode, srt_mode, srt_host, srt_port, srt_stream_id, srt_passphrase, srt_latency, loop_playlist.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.scheduled_at set.{"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.stopped. The channel's original start time is preserved.Files API
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.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.conforming, or with why there is nothing to do; 409 while the file is on air.
Audited as media.conform.file_id, file, track, language,
integrated_lufs, true_peak_dbtp, lra_lu, in_spec. An administrator’s covers every account.short_desc and long_desc.Playlist API
{"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.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.{"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.{"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.{"count": 20}.{"trim_in_ms":2000,"trim_out_ms":8000} (see Trim points).{"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}.Schedule API
scheduled_at within the given time window. Defaults to today through 7 days from now.Stats API
/api/hls-proxy/token as ?token=.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.
/api/epg/feed-token) or an account token. /epg with a token is an index page linking every feed.?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
?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.
| Action | Written when | Subject |
|---|---|---|
| On air | ||
channel.start | A channel was started from the console or the API. | channel |
channel.stop | A channel was stopped. | channel |
channel.delete | A channel was deleted. | channel |
channel.create | A channel was created (3.55.0). | channel |
channel.udp | A channel’s UDP/RTP output was set: on, with its destination, TTL, DSCP and pacing, or off (3.63.0). | channel |
webhook.create | An alert webhook was added: its URL (without the query) and events (3.63.0). | server |
webhook.update | An alert webhook was changed or turned on or off. | server |
webhook.delete | An alert webhook was removed. | server |
webhook.rotate | An alert webhook was given a new signing secret. | server |
webhook.test | A test event was sent to an alert webhook. | server |
channel.sdi | A channel’s SDI output was set: on, with the DeckLink output, format and audio channels, or off (3.62.0). | channel |
channel.update | A 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.logo | A logo was uploaded for the channel (3.55.0). | channel |
channel.manual | Manual control: take next, jump to an item, or restart the airing item. The detail says which. | channel |
channel.live.rekey | The channel's RTMP stream key was replaced (New key). The old key no longer publishes. | channel |
live.taken | A live feed was taken to air in place of the schedule. | channel |
live.returned | An operator handed the channel back to its schedule. | channel |
live.lost | The feed sent nothing for 5 s and the channel returned to its schedule by itself. | channel |
scte35.insert | Break now went out at once, with its length. | channel |
scte35.queued | Break now had to wait. The detail says why (filler, the slate, a live feed, between items). | channel |
youtube.golive | A channel's YouTube broadcast was started. | channel |
| Rundown | ||
schedule.item_added | An item was added to a rundown. | channel |
schedule.item_removed | An item was removed from a rundown. | channel |
schedule.reordered | A rundown was reordered. | channel |
schedule.item_trimmed | An item's trim in or trim out was changed. | channel |
schedule.ad_breaks_set | An item's ad breaks were set or cleared. | item |
schedule.fill_random | A rundown was filled at random from the library. | channel |
schedule.generated | A rundown was generated from the library (Generate), with how many items and whether it replaced the rundown (3.55.0). | channel |
schedule.imported | A schedule file (CSV, BXF, XMLTV) was imported, with what was added, anchored and skipped (3.55.0). | channel |
schedule.repeated | A day was repeated onto other days (3.55.0). | channel |
schedule.shuffled | A rundown was shuffled (3.55.0). | channel |
schedule.cleared | A rundown was cleared, with how many items it held (3.55.0). | channel |
schedule.item_updated | An item’s air time or position was changed (3.55.0). | channel |
schedule.bulk_timed | Many items were given or cleared an air time at once (3.55.0). | channel |
| Traffic | ||
traffic.import | A traffic log was imported. The detail gives its source, air date and event count. | channel |
traffic.generate | A day was generated from a template, with the number of problems found. | channel |
traffic.learn | A profile, library or template was learned from a log. | channel |
traffic.profile | A traffic profile was saved. | channel |
traffic.template | A template was saved. | channel |
traffic.template.delete | A template was deleted. | channel |
traffic.library.program | A program was saved to the traffic library. | channel |
traffic.library.interstitial | An interstitial was saved to the traffic library. | channel |
traffic.library.delete | A library entry was deleted. | channel |
traffic.assign | A house ID was assigned to a media file. | channel |
traffic.release | A log was released. | channel |
traffic.release.acknowledged | A log was released despite integrity errors. The detail carries the reason given. | channel |
traffic.load | A released log was loaded into the rundown, with what it replaced. | channel |
traffic.delete | A log was deleted. | channel |
| Media and encoding | ||
media.delete | A media file was deleted. | file |
media.purge | Encodes or originals were purged from a channel's media, with the space freed. | channel |
media.collection | Files were moved into a collection or out of one. | file, or a file count |
media.collection.rename | A collection was renamed. | collection |
media.fix_doubled_names | Doubled original file names were repaired. | media library |
media.update | A file’s descriptions, which the guide shows, were edited (3.55.0). | file |
media.reimport | Files 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.reprobe | Files were marked for a fresh probe (3.55.0). | media library |
encode.all | Encode All was pressed. The detail gives the queued, already encoded and not ready counts, and the profile. | channel |
encode.retry | A failed encode was queued again. | file |
encode.start | One file was sent to encode, or queued (3.55.0). | file |
encode.cancel | A running encode was cancelled (3.55.0). | file |
encode.dequeue | A queued encode was removed from the queue (3.55.0). | file |
encode.reencode | A channel’s files were queued to re-encode, for example with a new QR code (3.55.0). | channel |
node.add | An encoding node was added (3.55.0). | node URL |
node.update | An 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.remove | An encoding node was removed (3.55.0). | node |
rendition.repaired | One copy of a rendition failed its checksum and was replaced from the verified copy. | rendition |
rendition.corrupt_both | Both copies of a rendition failed their checksum. Neither was copied over the other. | rendition |
watch.created | A watch folder was added. | path |
watch.removed | A watch folder was removed. | folder |
watch.paused | A watch folder was paused. | folder |
watch.resumed | A watch folder was resumed. | folder |
watch.appended | A file dropped into a watch folder was imported and added to its channel's rundown. | file |
| Storage and backups | ||
storage.config | The storage tiers (mirror, S3, failover cache) were changed. | tiers |
storage.sweep | A storage sweep was run by hand. | manual |
storage.failover | The primary media directory stopped answering write probes. Reads and ingest continue from the next tier. | primary |
storage.failback | The primary storage came back. | primary |
storage.mirror_down | The mirror stopped answering write probes. | mirror |
storage.prewarmed | An item airing soon was staged from S3 into the failover cache. | file |
backup.verified | A scheduled snapshot passed its full integrity check and was kept. | snapshot |
backup.verify_failed | A scheduled snapshot failed its integrity check. | nightly |
backup.downloaded | A snapshot was downloaded. | snapshot |
backup.created | A snapshot was taken by hand, and whether it verified (3.55.0). | snapshot |
backup.restore.begin | A restore started. | snapshot |
backup.restore.refused | A restore was refused because another restore owns the database. | snapshot |
backup.restore.failed | A restore failed. The detail carries the error. | snapshot |
backup.restore.done | A restore finished. The detail says what was restored. | snapshot |
| Graphics | ||
graphics.composited | Composited output is being served: from the first healthy check, or again at an item boundary after a fallback. | channel |
graphics.fallback | The composite node's output went stale, so viewers get the clean programme. | channel |
graphics.no_node | No node can composite this channel's graphics, so clean output is served. | channel |
graphics.live_update | A headline or ticker was updated on air. | channel |
| Accounts and settings | ||
user.signup.demo | A demo account signed up. Its uploads are watermarked. | |
user.signup | An account signed up, openly or with an invite code — never the code (3.55.0). | |
user.create | An administrator created an account (3.55.0). | |
user.update | An account’s e-mail, role or channel limit was changed, with the old and new values (3.55.0). | |
user.delete | An account was deleted, with its channels and files (3.55.0). | |
auth.2fa.setup | An account started enrolling an authenticator app (3.60.0). | |
auth.2fa.enable | Two-factor sign-in was turned on; the account’s other sessions end (3.60.0). | |
auth.2fa.disable | Two-factor sign-in was turned off by the account (3.60.0). | |
auth.2fa.recovery_codes | New recovery codes were made; the old ones stop working (3.60.0). | |
auth.2fa.recovery_used | A recovery code was spent, at sign-in or to change two-factor; the detail says how many are left (3.60.0). | |
user.2fa.reset | An administrator removed an account’s two-factor sign-in (3.60.0). | |
user.password_changed | An account changed its own password; its other sessions end (3.55.0). | |
auth.reset | The administrator’s credentials were reset with the recovery secret, the one way in without a session (3.55.0). | |
invite.create | An invite code was created, with its limits — never the code (3.55.0). | invite |
invite.update | An invite code was revoked or re-enabled (3.55.0). | invite |
invite.delete | An invite code was deleted (3.55.0). | invite |
appkey.create | An application key was created; the detail carries its prefix, never the key (3.55.0). | name |
appkey.revoke | An application key was revoked (3.55.0). | key |
user.quota | A user's storage quota was set. | |
user.suspend | An account was suspended, with the reason. | |
user.restore | A suspended account was restored. | |
user.demo.applied | An account was made a demo account. | |
user.demo.lifted | An account stopped being a demo account. | |
youtube.connected | A YouTube account was connected. | account |
youtube.disconnected | The YouTube account was disconnected. | — |
settings.local_gpus | Which of this server's cards encode was changed. The detail gives the old value. | new value |
settings.local_gpu_decode | GPU decoding and scaling for local encodes was turned on or off (3.54.0). | on or off |
settings.local_gpu_slots | Encodes per card was changed, with the old value (3.55.0). | new value |
settings.cdn_url | The server’s CDN base was changed, with the old one (3.55.0). | new base |
settings.encode_fallback | The encode fallback policy was changed, with the old one (3.55.0). | new policy |
settings.require_invite_code | Signup was opened or closed to accounts without an invite code (3.55.0). | on or off |
settings.user_cdn | An account’s own CDN base was set (3.55.0). | |
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.
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.{"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.?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.{"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.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.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./api/admin/watch/{id} to remove it. Media dropped
into a watch folder is imported and probed automatically./api/admin/invites/{id} to revoke it.{"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.All admin endpoints require both authentication and the admin role.
admin or user), max_channels limit and disk_quota_bytes (0 = unlimited for both). There is no password field, and you cannot demote yourself.uploads/user_<id>/ and its renditions). Returns freed_bytes and anything not_removed. You cannot delete your own account.total_users, total_channels, running, total_files.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.)GET /api/admin/probe-status. 409 if a re-probe is already running. (Admin β Re-probe Broken Files.)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"
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
| Parameter | Default | Description |
|---|---|---|
| srt_mode | caller | caller (dial out) or listener (accept connections) |
| srt_host | localhost | Target host (caller mode) or bind address (listener mode) |
| srt_port | 9000 | UDP port number |
| srt_stream_id | SRT stream ID for multiplexed ingest servers | |
| srt_passphrase | AES encryption passphrase (10β79 chars) | |
| srt_latency | 200 | Target latency in milliseconds (ARQ buffer) |
| srt_max_bw | -1 | Maximum 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.m3u8 | The media playlist: video, every language muxed in (the main one first), the cue tags. Always written. |
index.m3u8 | A 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β¦.ts | Extra 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
- The main language has no
URI: RFC 8216 Β§4.3.4.1 β its audio is in the variant's segments. CODECSis read from the SPS and the ADTS header, not assumed;BANDWIDTHis the measured peak of the window's segments plus the largest rendition segment;RESOLUTION(3.55.0) is the picture the SPS describes. All three are measured over the segments in the window β see below.- Dead air (3.55.0): when nothing can fill a gap β no gap filler and no slate (ffmpeg absent) β the variant carries tables and a clock, and each rendition gets its own segment of the same: its one audio stream declared, on the rendition's PIDs, no frames. Until 3.55.0 the rendition named the variant's segment for that stretch, whose tables declare video and the main language. Gap filler and the slate air as samples, like an item, and always reached every rendition.
- The presentation is fixed when the channel starts, from its rundown, so
index.m3u8is a master from the first segment and never changes kind under a player. Rundown edits take effect at the next start. - Every URL that worked before still works: a single-language channel without captions is unchanged, and a player given
index.m3u8handles a master and a media playlist alike. A tool that needs a media playlist readsmain.m3u8β the composite node is given it (an ffmpeg-based reader given a master opens every variant it lists). - A language tag that is not a valid BCP 47 / ISO 639 code (empty, or with a quote) is declared
und, and two tracks with one language get distinct names, as RFC 8216 requires.
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:
RESOLUTIONβ the displayed picture from the SPS of each segment's first keyframe: coded size less the cropping, field-coded pictures counted as frames, the cropping unit of the chroma format (4:2:0, 4:2:2, 4:4:4), and every profile whose SPS carries a chroma format read as such, the multiview and depth profiles included. The largest in the window; omitted while nothing has been measured, as RFC 8216 allows.CODECSβ the most demandingavc1profile and level in the window and the AAC object type. Until 3.55.0 the channel's very first keyframe decided it for the whole run, so a channel that opened on the slate (Baseline, small) declared the slate while airing High-profile HD.BANDWIDTHβ the window's peak segment rate, plus the largest audio rendition a player fetches on top.
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.
index.m3u8?v=2), or open the URL in QuickTime Player (File β Open Location).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:
- In Plex: Settings β Live TV & DVR β Add EPG Source β enter
http://your-server:8700/epg/{channelID} - In Emby/Jellyfin: Dashboard β Live TV β Add β XMLTV β enter the same URL
- Programme data comes from the playlist items'
short_desc/long_descandscheduled_atfields
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
What is refused
- An out point at or before the in point.
- An in point at or past the end of the file.
- Any combination leaving nothing to play β the item would vanish from air while still occupying a row in the rundown.
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:
6 | the next six days |
weekdays 4 | the next four weekdays, skipping the weekend |
2026-08-12,2026-08-13 | exactly 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 }
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.
path | Directory to watch. Must be readable at startup or it is skipped with a log line. |
user_id | Which account owns the imported media. |
move | true 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
| Mode | What it does | Choose 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 bitrate | Conforms 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 ladder | Several 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. |
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
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.
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.
- PlayoutGo picks the least-loaded node that has a free slot, preferring nodes with a real GPU β a node that has fallen back to CPU encoding is slower than the playout host itself.
- Sources are content-addressed: if the node already holds a file β because VODOTT encoded it, or because you re-ran a job β nothing is uploaded and no GPU time is spent.
- Renditions are collected to
encoded_dirand the node's copy is released.
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.
| Key | Values |
|---|---|
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_slots | Encodes 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_decode | false (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.
- A file the card cannot decode (a codec or profile NVDEC lacks, an ffmpeg whose
scale_cudacannot convert formats) does not fail the job: that rung is encoded again with the processor decoding, and so is the rest of the ladder; the encode stays on the card. The job's message and the log say so. The card is not parked for it. - A rung that carries the channel's QR code decodes on the processor: the code is drawn by the processor's overlay filter, and bringing the frames back from the card for it is the copy GPU decoding exists to avoid. The job says decode on this server's processor: the QR code is drawn there.
- Card numbers. Every ffmpeg run that names a card β the test encode and each rung β runs
with
CUDA_DEVICE_ORDER=PCI_BUS_IDunless you set it yourself, so card N isnvidia-smi's card N. CUDA numbers cards fastest-first by default, and on a server with two models the same number could name two different cards. - The job's encoder line says where it decoded: decode and scale on the card, or on the processor.
Changes are audited as
settings.local_gpu_decode.
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.
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:
| Setting | Behaviour |
|---|---|
faildefault | 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_gpu | Encode here, but only if this server has a working GPU encoder. If it does not, stop β rather than quietly dropping to software. |
local_cpu | Encode 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.
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:
- The variant set exists from the start. When the rundown has a fully encoded ladder, the
set is built when the channel starts and
index.m3u8is its master from the first segment. Every rung carries whatever airs outside ABR items β a single-rendition item, gap filler, the slate, a live feed β so a player on any rung never stalls. (A channel whose ladder finishes encoding while it airs builds the set at its first ABR item;index.m3u8then becomes the master and stays one.) - Breaks and Break now go out during an ABR item exactly as during a single-rendition item, on SRT and as cue tags in every rung's playlist, on the same segment of each.
- Every language on every rung, each rung's tracks routed by language. The master names one audio group for every variant β the top rung's renditions β so a player that changes rung keeps its language.
- Captions are declared on every variant when the rundown carries them.
- A live take or Take next cuts in on the next keyframe, and the feed reaches every rung.
- As-run and the rundown: the item gets an as-run row and is marked on air, like any item.
main.m3u8on an ABR channel is a continuous single-variant stream: the top rung during ABR items, everything else between them.- The rungs stay aligned in time. Each rung is read up to the first (tallest) rung's decode time, so a rung with more samples per second than the tallest β another audio track, or audio at a higher sample rate β no longer falls behind it (until 3.54.0 one sample was read from each rung per step, and such a rung lost the end of every item and placed its cues late).
What actually goes to air
Once a file has been encoded, the channel plays the rendition rather than the original. Selection is deliberately forgiving:
- Original files β always the uploaded file, even if renditions exist.
- Single bitrate β the rendition closest to the configured height.
- ABR β every rung, as an HLS variant set behind a master playlist, once the whole ladder is encoded; until then the nearest single rendition. SRT and the RTMP push carry the tallest rung.
- Anything missing β a file that has not been encoded yet, or a rendition deleted from disk, falls back to the original. An unencoded file must never leave a hole in the schedule.
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
- No realtime transcoding. Encoding is offline and per-file. A live input would need a realtime encoder, which is a different feature.
- SRT has no ABR. SRT carries a single MPEG-TS; there is no ladder inside it. While an ABR item airs as a variant set, SRT and the push carry its tallest rung (from 3.52.0 β before that they carried nothing for such items), with its breaks and every language (3.54.0).
- Existing rundowns are not rewritten. Setting a mode configures how new material is prepared; it does not retroactively re-encode a library.
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:
| Channel | Its own CDN field, in the channel dialog. Wins over everything. |
| Account | Admin β 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. |
| Global | Admin β Delivery & Encoding. |
| Nothing set | Served 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.
/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_β¦
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
}
]
}
| Field | Use it for |
|---|---|
stream_hls | What you hand to the player. Absent if the channel is SRT-only. |
stream_srt | Only 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. |
live | Whether the channel is on air right now. Grey out or hide the tile when false. |
progress_pct | Draw a progress bar on the now-playing tile. |
width/height/fps | Of 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
- Thumbnail is a live frame extracted from the channel's most recent segment, scaled to 640 px wide β so a grid shows what is genuinely on air. Regenerated at most once every 10 seconds per channel, then served from cache, so a busy grid cannot overload the server.
- Requires ffmpeg on the server. Without it, the endpoint falls back to the channel logo, then returns
404with the reason in the body. Apps should treat 404 as "use your own placeholder", not as an error. - Logo is a static image you upload per channel (PNG or JPEG, max 8 MB):
curl -X POST "$URL/api/channels/1/logo" -H "Authorization: Bearer $JWT" -F "file=@logo.png"
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
- Roku (SceneGraph) β feed
stream_hlsto a Video node withstreamFormat = "hls". Set poster fromthumbnail_url. Roku caches images aggressively; append a cache-buster that is not namedtore(for example&_=+timestamp) when you want a fresh frame βtandeare the URL's signature. - tvOS / AVPlayer and Android / ExoPlayer β HLS is native; no extra work beyond passing the URL.
- Web β Safari plays HLS natively; elsewhere use hls.js.
- HTTPS β tvOS (ATS) and modern Android require it. Enable built-in TLS; stream URLs then follow the request scheme automatically.
- CORS is permitted on these endpoints, so a browser app can call them directly.
8 Β· Errors and etiquette
401 | Missing or invalid key β it may have been revoked. Do not retry in a loop. |
403 | The key does not own that channel. |
404 | Channel or thumbnail unavailable. The body explains which. |
| Polling | 10 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. |
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
/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).
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
- Create users from the Admin tab (or let people self-register if you keep registration open). Each user sees only their own channels, media, rundowns, EPG and stats.
- Roles:
admin(sees and manages everything, /m3u and EPG return all channels) anduser(scoped to their own resources). - Edit: change email, role, channel limit and disk quota from the user list. There is no administrator password reset β users change their own (π, or
POST /api/auth/change-password). - Suspend: stops the account's channels and refuses starts and uploads, deleting nothing; lifting it brings the account back exactly as it was. It does not block sign-in, and administrators cannot be suspended (Account lifecycle).
- Deleting a user stops their channels and removes the account, its channels, playlist items and media records, and its files on disk β
uploads/user_<id>/and its encoded renditions. The response reportsfreed_bytesand anythingnot_removed. Channel logos underuploads/logos/stay. The dialog asks you to type the email first.
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
- Re-import (Admin β Scan & Re-import Missing Files, POST /api/admin/reimport) β scans the uploads directory and registers any files present on disk but missing from the database (e.g. copied in over SFTP). New records probe automatically.
- Fix statuses (Admin β Re-probe Broken Files, POST /api/admin/fixstatus) β re-probes files stuck in processing, marked error, or ready with no duration, refreshing their metadata and status. It does not touch channels.
- Admin stats β totals: users, channels, channels running, media files. Per-user usage is in the user list and disk quotas.
Operational practices
- Backups: the database is snapshotted automatically every six hours (newest eight kept in
backup_dir, each verified); Admin β Backups lists, downloads and restores them. Media lives in theuploads/tree β copy it, or use tiered storage. Back up the keyring (secrets_key_file) separately. HLS segments are ephemeral and never need backup. - Upgrades: stop the binary, swap it, start. The schema migrates automatically; rundowns and anchors survive. Then run the Testing Guide acceptance pass.
- Logs: the process logs to stdout β run under systemd or redirect to a file. Watch for auto-restart lines (a file failing repeatedly) and throttled SRT reject lines (a misconfigured puller hammering a port).
- tmpfs: keep
hls_diron /dev/shm; it regenerates on start.
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
| CPU | Host-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. |
| Memory | Used 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/O | Read/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 output | Aggregate egress across running engines, measured from bytes actually sent β the honest figure for capacity planning. |
| Network | Host-wide interface throughput (excluding loopback and container bridges). |
| Disks | Usage 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:
- By CPU β remaining headroom up to a 75% planning ceiling, divided by measured per-channel CPU cost.
- By RAM β remaining headroom up to an 80% ceiling, divided by per-channel memory.
- By tmpfs β free space in the HLS directory versus the rolling segment window at the current bitrate.
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.
Tuning suggestions
Below the metrics, PlayoutGo lists advisories generated from the current snapshot β colour-coded by severity, each with a concrete action. Typical guidance:
- Keep
hls_diron tmpfs (/dev/shm/playout_hls). Segments are rewritten every few seconds per channel; on disk this is pure wear and I/O for no benefit. You'll be told if it isn't. - Put media on SSD/NVMe. If disk utilisation is high while CPU is low, media reads are your bottleneck.
- Watch load-vs-CPU divergence. High load average with low CPU means processes waiting on I/O, not compute.
- Avoid swap. Any meaningful swap usage on a streaming host risks stalls; add RAM or shed channels.
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:
- Channels allowed β the per-user limit applied at signup (
0= unlimited). Enforced whenever that user tries to create a channel; they see "channel limit reached (n/m)" at the ceiling. - Uses β
1for a personal single-use invite (recommended), a higher number for a team,0for unlimited. - Expiry β optional; blank or 0 means it never expires.
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.
Lifecycle
- Revoke stops a code being used but keeps the record (and does not affect accounts already created with it). Re-enable reverses it.
- Delete removes the record entirely; again, existing accounts are unaffected.
- Status is shown per code: active, used up, expired or revoked.
- Redemption is atomic β two people racing to use the last remaining use of a code cannot both succeed.
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
}
}
domains | Required. 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_dir | Where 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_http | Binds 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. |
staging | Uses 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
- The domain's DNS A/AAAA record must already point at this server before first start β issuance happens on the first request for that hostname.
- Ports 80 and 443 reachable from the internet. Let's Encrypt validates from outside; a firewall that only allows your office will fail issuance.
- Binding ports below 1024 needs
CAP_NET_BIND_SERVICE. Under the shipped unit, grant it in a drop-in βsudo systemctl edit playoutgo, then[Service]andAmbientCapabilities=CAP_NET_BIND_SERVICEβ or install withinstall.sh --tls, which writes one (the installer).setcapon the binary does nothing there: the unit’sNoNewPrivileges=yesdrops file capabilities. Without systemd, run as root orsudo setcap 'cap_net_bind_service=+ep' /opt/playoutgo/playout. PlayoutGo prints this hint if the bind fails.
/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.
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.
- The keyring is
secrets.keybeside the database, or whereversecrets_key_filepoints β or, since 3.54.0, it comes from off the host and is never written to disk (below). The file is created on first start with mode 0600 and a header explaining itself. The first key line is the current key; further lines are older keys kept only to decrypt values sealed under them, which are re-sealed under the current key at startup. - Back it up separately from database snapshots and keep it with the off-box copies. That separation is the protection: a snapshot alone is useless to whoever takes it.
- Restoring onto another host: append the original host’s key line below the
first line of the new host’s keyring and restart. A snapshot restored without its key still
starts, but the secrets it holds are reported as locked: the channel dialog and
locked_secretsname them, a channel with a locked SRT passphrase refuses to start rather than send unencrypted, a channel with a locked stream key accepts no publish (never the guessable default) and reportskey_locked, and a locked node token showstoken_locked. Saving unrelated settings never discards a locked value; re-entering or clearing it does. - Rotation: put a new 64-hex-digit key on the first line, keep the old one below it, restart. Every value is re-sealed and the log says how many.
- Upgrading from 3.47.0 or earlier encrypts every plaintext secret on first
start, hashes any application key still held in plaintext, rewrites the database once so the old
plaintext does not survive in SQLite’s free pages, and from then on runs with
secure_deleteso deleted rows are zeroed. Snapshots taken by earlier releases still hold plaintext; startup names them. Delete them or move them somewhere as safe as the keyring. - YouTube refresh tokens were encrypted under a key derived from the JWT secret, so rotating the JWT secret — the advice for a leaked archive — made every connected YouTube account unusable. They now use the keyring; the JWT secret can be rotated freely.
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:
| Source | How |
|---|---|
secrets_key_command | In 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_FD | A 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_KEY | The 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 file | secrets_key_file, or secrets.key beside the database β as before, and the default. |
- The content is the keyring format: one 64-hex-digit key per line, the current key first, older keys
after it for decryption;
#lines are comments. Rotation and restoring a snapshot from another host work as with the file: the key source supplies more than one line. - Fails closed. A configured source that fails, supplies nothing, or supplies anything that is not a keyring stops the server from starting, naming the source and the reason. It never falls back to the file and never generates a key β a fresh key would start the server with every secret locked, and a fallback would put the key back on the disk it was moved off.
- The keyring is read once per process and kept in memory: a descriptor can be read only once, the variable is gone after the first read, and a restore reopens the database without calling the key service again.
- When an off-host source is used,
secrets_key_fileis ignored (the log says so), and the log names the source and the current key id: keyring from secrets_key_command (playoutgo-key): current key 1a2b3c4d, 2 key(s); nothing written to disk. - Only
config.jsoncan setsecrets_key_command: it runs a program, and no API endpoint accepts it β a test fails the build if one ever decodes it.
# 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.
- A code is good for its 30-second step and one either side, and never twice.
- Wrong codes count like wrong passwords, so the usual limits lock the account out after ten in 15 minutes.
- The secret is sealed under the server’s keyring, like every other secret at rest.
- Turning it on or off, or an administrator resetting it, signs out every other session of the account; the console keeps the one that made the change.
- Turning it off, or making new recovery codes, takes the password and a current code (or a recovery code).
- An account whose owner has lost both the app and the codes is reset by an administrator (Admin β the user,
or
DELETE /api/admin/users/{id}/2fa); it then signs in with its password and should enrol again. - Every step is audited:
auth.2fa.setup,auth.2fa.enable,auth.2fa.disable,auth.2fa.recovery_codes,auth.2fa.recovery_used,user.2fa.reset.
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.
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
- SRT passphrases are never returned by the API β channel create/update/list/stats responses expose only
srt_passphrase_set: true|false. To remove encryption intentionally, send{"clear_srt_passphrase": true}on channel PUT; a blank passphrase field means "keep existing". - /m3u requires a token and returns only the requesting user's channels (admins see all); unauthenticated requests get 401. The EPG tab's copy link carries your feed token (EPG and M3U only, no expiry, revoked by a password change) β never your login token.
- Partial channel updates are safe β PUT merges onto the stored record, so sending only
{"name":"x"}no longer zeroes SRT host/port/latency/loop settings. - HLS proxy pins DNS-validated IPs at dial time and re-checks the allowlist on every redirect hop (DNS-rebinding and redirect-SSRF are closed).
- SRT listener requires an exact stream-ID match; empty/unknown IDs are rejected. The first rejection is logged at once and the rest are summarised every 5 s; since 3.50.0 the summary is printed when the window closes, so a burst that stops is still reported.
| Finding | Severity | Fix |
|---|---|---|
| HLS path traversal via /hls//etc/passwd | π΄ Critical | filepath.Clean + HasPrefix check ensures resolved path stays inside hls_dir |
| SSRF via /api/hls-proxy | π΄ Critical | Resolves hostname to IP; blocks all RFC-1918/loopback/link-local ranges; optional allowlist |
| Proxy URL encoding breaks signed segments | π‘ High | url.Values.Encode() used instead of string concatenation |
| Any user reads all channels' live stats | π‘ High | Stats endpoints now filter to caller's own channels (admin sees all) |
| Playlist position silently ignored on PUT | π Medium | UpdatePlaylistItemPosition() implemented and wired to the handler |
| Duplicate positions on AddToPlaylist | π Medium | Existing items at β₯ position are shifted up in the same transaction |
| Reorder leaves stale positions | π Medium | Omitted items are appended at end; all items always get a valid position |
| Wrong item played with future schedule | π Medium | Engine stops at a future-scheduled item; no unscheduled item may jump past it |
| started_at overwritten on stop/error | π Medium | started_at is only set on "running"/"connecting" transitions |
| Unauthenticated admin reset (brute-force) | π‘ High | Off 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 | π‘ High | EnsureAdmin() no longer overwrites existing admin credentials |
| Config file world-readable (0644) | π‘ High | SaveConfig() now writes with 0600 (owner read/write only) |
| JWTs in query strings leak to logs | π Medium | API 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) | π΄ Critical | outputMu RWMutex added; all accesses use getOutput()/clearOutput() helpers |
| Login endpoint brute-forceable | π‘ High | 20 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 | π‘ High | role validated to exactly "admin" or "user" |
| Admin can self-demote via API | π Medium | Self-demotion rejected with clear error message |
| JWT in M3U link URL (EPG page) | π Medium | The 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) | π Medium | Preview streams in a <video> with the six-hour read-only scoped token, never the account token |
| output_mode / srt_mode accept arbitrary strings | π’ Low | Validated against allowed enum values on create and update |
| SRT passphrase length not validated | π’ Low | Enforced 10β79 char requirement per SRT protocol spec |
| multiplayout: any file type uploadable | π‘ High | Extension whitelist (MP4/M4V) + MaxBytesReader before ParseMultipartForm |
| multiplayout: login brute-forceable | π‘ High | Per-IP failure counter; 20 consecutive failures = block |
| multiplayout: undefined variable n in ensureSRTDial | π΄ Critical | Pre-existing build error fixed; network var correctly stays "srt" |
| MP4 parser OOM via crafted sample count | π΄ Critical | Capped at 10M samples (β« 90 hours of video) in three box parsers |
| SRT connection leak on cancel/timeout | π‘ High | Drain goroutine closes any stale connection before returning |
| Scheduler goroutine leaked on server shutdown | π Medium | Context-aware Run(); cancelled by server shutdown context |
| XSS in stats grid (current_file unescaped) | π Medium | X() escaper applied; file names can contain HTML characters |
| XSS in admin user table (role unescaped) | π Medium | X() applied in badge and meta; editUser uses data-* attributes |
| Missing 405 on GET-only endpoints | π’ Low | Method guard added to five read-only handlers |
| HLS tmp file not cleaned on rename failure | π’ Low | os.Remove(tmp) called on both WriteFile and Rename error paths |
| writePES infinite loop β OOM crash | π΄ Critical | TS stuffing recalculated payload-first; remaining always advances β₯1 byte per iteration |
| SRT rejection log floods 100+ lines/second | π Medium | Batched: one log line per 5s with rejection count |
| Playout panic kills entire server | π΄ Critical | recover() in goroutine; one channel crash no longer kills the process |
| HLS takes 4s minimum before first segment | π Medium | A 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 | π Medium | PSI injected immediately on SRT connect via srtNewConn atomic flag |
Production checklist
- Sign in with the generated
admin_passwordand change it in the interface (editing config.json later has no effect). Replacejwt_secretonly if it is an old published default or shorter than 32 characters. - Serve HTTPS: enable built-in TLS (443, with port 80 for certificate challenges and redirects), or terminate TLS at nginx/Caddy and set
trust_proxy_headers. Never serve the console over plain HTTP. - Leave
allow_password_resetoff (the endpoint then answers 404). If you enable it for a recovery, also firewall/api/auth/resetand turn it off afterwards. - Set
hls_proxy_allowed_hostsif you know which external HLS servers you need to proxy - Set
hls_dirto tmpfs (/dev/shm) for performance - Run
playout -check(above) after every change toconfig.json; the unit runs it before every start. Since 3.57.0 an emptyjwt_secretβconfig.sample.jsonships one β is generated and written back: before, it signed every session with an empty key, which anyone can compute. - Run as a non-root user. The Go memory limit defaults to 4 GiB; set
GOMEMLIMITin the unit to change it - Keep the automatic database snapshots, back up
uploads/, and back up the keyring separately
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
| Where | What |
|---|---|
/opt/playoutgo/playout | The 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_hls | HLS segments, on tmpfs. |
/etc/systemd/system/playoutgo.service | The 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
- Fresh install: creates the
playoutuser (added tovideofor the GPU when that group exists), installs the binary, writesconfig.jsonwith absolute paths, a 64-characterjwt_secretand a first administrator password, checks it, enables and starts the unit, and waits for/healthz. It prints the password.--port,--url(advertised_base_url) and--hls-dirapply here only; an existingconfig.jsonis never rewritten. - Upgrade: the new binary checks the existing configuration first, and on an error nothing is changed β the running server is not touched. Then the service stops; the database, keyring and
config.jsonare copied tobackups/pre-upgrade-<version>-<time>/(the newest three copies are kept); the binary is replaced, the old one kept asplayout.prev; the unit is replaced with the bundle’s; the service starts and must answer/healthzwithin 60 s (--waitchanges that). If it does not, the upgrade rolls itself back β old binary, the unit it ran with, and the copied database β and says so, keeping the new binary asplayout.failed. - The unit is the installer’s. Local settings β
ReadWritePaths=,AmbientCapabilities=,Environment=β go in a drop-in (sudo systemctl edit playoutgo), which no upgrade or rollback touches. The installer recognises every unit a release has shipped by its checksum; a unit with local changes is not replaced β the upgrade is refused before anything changes, and says how to move them into a drop-in. --rollbackputsplayout.prevback, and the unit it ran with, and restarts. It does not restore the database, which may hold days of work; if the earlier version refuses it, the output names the copy taken before the upgrade. A binary from before 3.57.0 has no-check, so for one the installer adds a drop-in that skips the unit’sExecStartPre, and removes it on the next upgrade.--tls DOMAINS(3.58.0, a fresh install only): built-in HTTPS βtlsenabled for the comma-separated domains, the certificates cached in/var/lib/playoutgo/certs,advertised_base_urlhttps://the first domain unless--urlsays otherwise,--tls-emailfor Let’s Encrypt,--tls-portand--tls-http-port(443 and 80). For a port below 1024 it writes the drop-inplayoutgo.service.d/10-install-sh-tls.confgrantingCAP_NET_BIND_SERVICE.--portthen serves/healthzand/metricson 127.0.0.1 (loopback), which is where the installer checks the server β it asks the binary (playout -healthz-url) rather than reading the port out of the JSON. On an installed host--tlsis refused before anything changes: settlsinconfig.jsonand the capability in a drop-in.--tls-acme-directory URLand--tls-acme-ca FILE(3.59.0, with--tls): certificates from an ACME CA of your own instead of Let’s Encrypt; the bundle is copied to/var/lib/playoutgo/acme-ca.pem. Checked before anything is written: anhttps://URL, a readable file holding a certificate.- A start that fails (3.59.0): on a real systemd,
systemctl startwaits for the service and fails when it does not come up. Until 3.59.0 that failure ended the installer before its health check, so a broken upgrade was left in place with the service down instead of being rolled back. Now the health check decides, and a failed start is rolled back like any other. --no-systemdstarts and stops the server directly (a pid file in the state directory), for a host without systemd; thereplayout.logis rotated at each start once it passes 50 MiB, the three newest kept (3.58.0).--root DIRstages an install under a directory;--systemctlnames a stand-insystemctl, for testing.- An existing install from another layout β a server already answering on the port, or a hand-made
playout.serviceβ is refused rather than run beside;INSTALL.mdlists the eight steps to move it over once. A binary from before 3.57.0 takes no arguments, so asked for-versionit would start a second server on the live database: the installer never runs one, and recognises a 3.57+ binary by its usage text.
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:
- Hard-refresh first (β§βR / Ctrl+Shift+R) β the UI is embedded in the binary, so an upgraded server with a cached old page is a common cause.
- If you are on v1.4.0βv1.6.0, upgrade to v1.6.1: those builds had a genuine missing-function bug affecting Rundown and Stats.
- Developers: run
go test -run TestUI ./...β the shipped UI static tests detect undefined functions and missing element IDs.
SRT does not connect but HLS works
Work through these in order β they cover almost every case:
- Include the stream ID. The listener matches it exactly; a URL with no
streamidis 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" - 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.
- Confirm something is listening:
ss -lunp | grep 9000on the server, and check the log for "[SRT] Listener on :9000". If it is absent, no channel currently owns that port. - Remember SRT is single-consumer β one client per channel at a time. Disconnect VLC before testing with ffprobe.
- 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).
| Target | What it feeds |
|---|---|
FuzzAMF0Decode, FuzzRTMPMessages | The RTMP publishing point: AMF0 commands and chunked messages, with a published, taken feed so the media paths run. |
FuzzMP4Reader, FuzzMP4Fields | The MP4 demuxer: bytes, and real files with their box sizes, counts, timescales, durations and offsets rewritten. |
FuzzH264AAC | SPS, SEI and caption probing, codec strings, AVC and ES configuration, ADTS. |
FuzzRPXRoundTrip, FuzzRPXFields | Traffic logs, byte by byte and field by field, through import and export. |
FuzzScheduleImport, FuzzAdBreaks, FuzzChannelSettings | CSV and XML imports and date formats, break lists, every channel setting a client can send (and the DVB tables built from them). |
FuzzSessionToken, FuzzKeyring | Session tokens; the keyring file and sealed values. |
FuzzTSMuxerPES, FuzzSCTE35Section, FuzzDeadAirRendition, FuzzPMTRewriter | The 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
/api/encode/queue/{id} cancels one.?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.file_id, an encode profile, and optionally
channel_id β which is what lets the job find that channel's QR overlay.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.GET /api/encode/nodes under jobs[];
there is no per-job GET./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.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
- 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.
- 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.
- 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. - 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.
- 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.
- 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.
- Grid templates (ENG, INTL) are blocks on the quarter-hour grid: start, length, program (or a series and an episode rule), optional breaks between its parts, optional station ID at the end.
- Flat templates (RUSS) are programs back to back from midnight; a block can be anchored to an exact time.
- Breaks are filled exactly from active promos and fillers. Where the same break length repeats, rotation keeps the same promo from airing in consecutive breaks. A break no combination fills is left open and named, and the generated log cannot be released until it is fixed.
- Airing numbers: ProTrack gives every airing of a program its own number in columns 88β94, shared by all of that airing's parts. Generated days continue the sequence above the highest number seen.
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:
- same β the series' most recent airing again: today's, or the days before's (TDYC airs at 08:00, 15:00, 20:00 and 23:00 in the ENG sample, one episode four times).
- next β the episode after the most recent airing, in episode order (CR 1622 at 11:00, 1623 at 11:30). When the library has no later episode the last one airs again and the generation notes say so β add the next episode, or use cycle.
- cycle β as next, but after the last episode the series starts over at the first, also with a note.
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:
- Rating (TVG, TVPG, TVY7 β the TV parental guideline): 15 seconds at the start of every part, including parts after a break.
- Logo: from 1:30 into each part until 2:00 before it ends, and not at all on a part too short for that. Which logo depends on the daypart: in the English sample ENG_6 from 00:00 to 08:00 and from 17:00, ENG_8 from 08:00 to 17:00 β so 3ABN Today carries ENG_8 at 08:00 and ENG_6 at 20:00.
- E/I label (ENG_8_E, on its own output): from 1:30 into each part to its end, replacing the logo, on children's programs (every one is rated TV-Y7).
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:
- By elapsed time (the default, as before): events are placed that many seconds after local midnight, so clock times after the change are an hour off and the day ends an hour early (25-hour day) or an hour into the next (23-hour day). Loading such a day asks for an acknowledgement first.
- By clock time (
dst_modewallclock): every event at its clock time, no acknowledgement asked. When the clocks go forward, events that start in the hour that does not exist (02:00–03:00 in the United States) are not loaded, the plan lists them, and the first event after them is anchored so it does not follow whatever aired before the skipped hour. When they go back, the hour that repeats airs the log’s events once, in its first occurrence, and the channel’s filler covers the second; the plan says so.
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:
- Acceptance. A generated log can be imported to the player.
- Event ID, columns 86β105, is generated by the scheduler β by PlayoutGo. Airing numbers are ours.
The field is read and written across all 20 columns (88β105 for the number; before 3.61.0 columns 95β105 were
refused as unknown). Promos, fillers and IDs with no library number (
number_every_event) can each be given an event ID from the same counter as the airings, kept from then on, and named in the day’s notes. - Fixed fields follow 3ABN’s RapidPlay X2000 protocol. Columns 693β696 are the discrepancy code (0000), which also shows a reader that every line is the full length. They stay profile settings, copied from the channel’s log.
- Logos.
_6carries the local 618 number,_8the 800 number, shown only in business hours, 08:00β17:00 Monday to Thursday. So ENG uses ENG_8 from 08:00 to 17:00 Monday to Thursday and ENG_6 at every other time, Friday to Sunday all day: dayparts per day of the week. An E/I program carries_8_Ein business hours and_6_Eotherwise. The profile’s E/I suffix (ei_suffix, learned as_Efrom the ENG log) makes the E/I graphic the daypart logo plus the suffix; blank keeps each program’s own E/I code. - Clock-change days. Every schedule runs 00:00:00;00 to 23:59:59;29 β 24 hours. Timecodes written
with
;before the frames (drop-frame notation) are read and written back as they came; a file mixing:and;is refused. The profile’s Timecodes setting writes generated logs either way. - Up Next spots. Sunday is 01. A code like X051000S is “Up Next” for the program starting
Thursday at 10:00 β the day of the week and the start time of the next program β always 15 seconds, a
promo. The pattern takes
{MM}:X{DOW}{HH}{MM}Sis learned (on the hour it reads as before). A break before a program opens with that program’s Up Next spot when the library has one; a missing spot is reported only for the hours, where every sample had one. X050000S, an ID in the sample, “may have been mis-marked”: learning adds an Up Next spot marked ID as a promo, and says so. - Series. Whether a day starts with a new episode depends on the program: each block’s rule
(
sameornext/cycle) says it. When the episodes run out, 3ABN usually starts over: learned blocks nowcycle(they werenext). To pick a starting point, the profile’s series starting points (series_starts: series, episode, first day) make that series’ first series block air the chosen episode from that day, once, the series continuing from it. - RUSS is autonomous: its schedules arrive timed and complete. A channel marked autonomous is imported, released and loaded, never generated; Learn makes no template for it and marks no fillers.
- List numbers are channel numbers, telling which player the schedule is for: 0001 ENG, 0003 INTL, 0005 RUSS.
- Promos are rotated at the will of administration, with limits and specific rules on what can air
on which channel at what time of day. Each promo, filler or ID can carry airing rules: the days and
times it may air (
Mon-Thu 08:00-17:00; Sat-Sun), the first and last day, and at most so many a day. The filler leaves an item out of any break outside its rules, and never relaxes them; a break that cannot be filled within them is left open and named in the notes. Rotation andpromo_min_gap_secswork as before inside the rules.
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:
- Every documented column is read, kept and written back. The house code is the Material ID, columns 6β35 (up to 30 characters; 3.61.0 read 6β85); 36β85 is the media locator. The output is the Video Source, 139β158 (3.61.0: 139β151). The title (269β318) is a field of its own. The optional fields β media locator, backup video, audio and backup audio sources, comment, end method, cache flag, end action, machine mode, EOM, linked event ID, switcher levels, antenna, receiver, satellite, transponder, aural subs, record sources, record material and locator β are kept with the event and exported unchanged. Only the reserved columns, 367 and 379β406, must be blank: data there is refused. All 19 logs 3ABN sent import and export byte for byte.
- Record types. Column 1 is
C(event),B(break event β played as an ordinary event; the document gives it no other meaning),-(comment) orH(header, which may carry parameters). Comment and header lines are kept in place and written back. The first line need not be ProTrack’sLog for:header; without one, the log’s date is its first event’s. - Event types (331β332):
PRprimary,COcomment β listed, never aired, not part of the day’s timing βOSsecondary timed from its primary’s start,OEsecondary timed from its end (read as that long before the end). A secondary is one by its event type; FCC type SI is not needed. FCC types CA (spot, also when blank) and PS (public service) are accepted; a blank type is stored as CA and written back blank. Learning adds spots and PSAs to the library with those types; they air only as fixed spots, never to fill a break. - What 3ABN’s codes mean.
S\(323β325) is a slow fade-cut. C and F (326β327) are the start method: clock start, and follow the event before.PRNEMPL(331β337) is a primary, normal (audio and video) event whose duration is set by hand (M), that plays (PL);OSVKPPLa video-key secondary timed by the play list; RUSS’sPRNEPPLthe same with the play list’s duration. - What PlayoutGo cannot do as the player would is a warning on the log, never a refusal: a manual (M) or external (Xn) start starts when the event before ends; a record, dead-roll or switch-only start action plays the material; a playback rate other than 1 plays at normal speed. A traffic start time left blank on a follow event is taken from where the event before ends, and written back blank.
- SOM (341β351) is where loading cues the file: the rundown item starts that far in (frames ignored; the frame rate is not in the file), and ends the log’s duration later.
- Gaps before clock starts. A clock-start event “starts when TOD >= Event Start Time”.
3ABN’s weekend logs leave 1β4 seconds before many clock-start programs, and two end 1β3 seconds before
midnight. A gap of up to 10 seconds before a clock-start event, or at the end of the day, is a
warning (
clock_gap,end_short), not an error; the channel’s filler covers it. Longer gaps, and any gap before a follow event, stay errors. A generated day still fills each break exactly when the promos allow; when no combination fits, the break may end up to 10 seconds early, before the next clock start β after its station ID β as ProTrack’s do. - Clock start on every event (
clock_every_event, learned). CANADA and PRAIZ mark every event C, including a program’s later parts. A part continues its program when it has the same code with a later part letter (TDYQA250016A, then TDYQA250016B at 12:55:34), or the same code off the quarter-hour grid (LAT’s DLDL000155A at 04:00:00 and 04:14:19). Generated logs for such a channel write C on every event. - Titles (
titles, learned). CANADA’s logs carry each program’s title on its first part. Learning takes the title into the library β a program still titled with its house code takes the log’s; one an operator titled keeps its title β and generated logs write it there. - File extension (
file_ext, learned): CANADA’s logs are.txt, and its generated logs export asCANADA20261024.txt. - Station ID rotation (
id_codes, learned when a log has more than one). DARE rotates six IDs (IDDD210001Sβ006S), PRO four of 15 and 30 seconds. Where a template block ends with an ID, the one aired least recently goes in, among those that leave room for the break’s Up Next spot. - Logos. A secondary at a program’s start is its rating only when it lasts 60 seconds or less: CANADA’s logo, CAN_W, runs from the start of each program to 3:30 before its end, and was taken for a rating. The weekend logs confirm 3ABN’s rule: ENG_6 all day on Saturday and Sunday.
- Up Next spots are learned per channel: X (ENG), IDX (INTL), CX (CANADA), DDX (DARE), LX (LAT), PX (PRO); CHILD and PRAIZ have none. Station IDs are part of the rotation history, like promos.
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)
| ID | Requirement | Source | Status | Tests |
|---|---|---|---|---|
| FMT-01 | Read 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) | LOG | Tested | TestRPXRoundTripsByteForByteTestImportedLogExportsByteForByteTestRPXEveryRealLogRoundTrips |
| FMT-02 | CRLF line endings; a file converted to LF in transit is read and exported as CRLF | LOG | Tested | TestRPXAcceptsConvertedLineEndings |
| FMT-03 | Refuse what is not understood rather than drop it: start copies that disagree, short records, unknown event types, bad timecodes, another day's date, non-ASCII | LOG | Tested | TestRPXRefusesWhatItDoesNotUnderstand |
| FMT-04 | Only the reserved columns, 367 and 379β406, must be blank; every documented column is accepted | DOC | Tested | TestRPXReservedColumnsAreRefused |
| FMT-05 | Material ID (house code) in columns 6β35, at most 30 characters; media locator 36β85 | DOC | Tested | TestRPXWriterRefusesFieldsThatDoNotFitTestRPXEveryDocumentedFieldRoundTrips |
| FMT-06 | Event ID in columns 86β105, all 20 columns: library (PG/FI) and number | ANS-2 | Tested | TestRPXEventIDColumns86To105 |
| FMT-07 | Video Source (the output) in columns 139β158 | DOC | Tested | TestRPXWriterRefusesFieldsThatDoNotFitTestRPXEveryRealLogRoundTrips |
| FMT-08 | Title in columns 269β318 as a field of its own (CANADA's logs carry titles) | DOC, WKD | Tested | TestTitlesAreLearnedAndWrittenTestRPXEveryRealLogRoundTrips |
| FMT-09 | Optional fields (locator, backup sources, comment, end method, EOM, switcher levels, record sources, β¦) kept and written back unchanged | DOC | Tested | TestRPXEveryDocumentedFieldRoundTrips |
| FMT-10 | Record types C, B, '-' and H; comment and header lines kept in place (B has no documented meaning: played as an ordinary event) | DOC | Tested | TestRPXEveryDocumentedFieldRoundTrips |
| FMT-11 | Event 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) | DOC | Tested | TestRPXEveryDocumentedFieldRoundTrips |
| FMT-12 | FCC types CA (also when blank) and PS accepted | DOC, WKD | Tested | TestRPXEveryDocumentedFieldRoundTripsTestDocumentedSampleIsLearned |
| FMT-13 | Start method C (clock) or F (follow) in columns 326β327; a follow event with no start time starts where the one before ends | LOG, DOC | Tested | TestRPXEveryDocumentedFieldRoundTrips |
| FMT-14 | A log with no ProTrack header line takes its date from its first event | DOC | Tested | TestRPXReservedColumnsAreRefused |
| FMT-15 | What 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) | DOC | Tested | TestRPXEveryDocumentedFieldRoundTripsTestIntegrityCheckCatchesEveryKind |
| FMT-16 | Drop-frame timecodes (HH:MM:SS;FF) read, kept and written back; a file mixing : and ; is refused | ANS-5 | Tested | TestRPXDropFrameTimecodes |
| FMT-17 | Generated 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-5 | Tested | TestGeneratedLogsUseTheProfilesTimecodes |
| FMT-18 | Fixed 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-3 | Tested | TestProfileIsLearnedFromTheLogTestGeneratedLogsUseTheProfilesTimecodes |
| FMT-19 | Export file name per channel and day, with the channel's extension (CANADA20261024.txt) | WKD | Tested | TestWeekendLogsAreLearnedTestTrafficReleaseGateAndExport |
| FMT-20 | SOM (columns 341β351) is where the file is cued when the day is loaded (frames ignored: the frame rate is not in the file) | DOC | Tested | TestSOMCuesTheFileOnLoad |
Integrity check and release
| ID | Requirement | Source | Status | Tests |
|---|---|---|---|---|
| INT-01 | The day runs 00:00:00 to 24:00:00 with no gap or overlap; the one real gap in ENG (02:59:59) is found | LOG | Tested | TestIntegrityCheckFindsTheEnglishGapTestIntegrityCheckCatchesEachProblem |
| INT-02 | Up 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) | WKD | Tested Answer awaited | TestClockStartGapTolerance |
| INT-03 | On 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) | LOG | Tested | TestIntegrityCheckCatchesEachProblemTestIntegrityCheckCatchesEveryKind |
| INT-04 | Graphics 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) | LOG | Tested | TestIntegrityCheckCatchesEachProblemTestIntegrityCheckCatchesEveryKind |
| INT-05 | The 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-9 | Tested | TestIntegrityCheckCatchesEveryKind |
| INT-06 | Every program carries its rating graphic; an event with no duration is an error (first tested in 3.64.0) | LOG | Tested | TestIntegrityCheckCatchesEveryKind |
| INT-07 | A 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-5 | Tested | TestClockChangeWarningFollowsThePlacementTestIntegrityCheckCatchesEachProblem |
| INT-08 | Only 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 released | LOG | Tested | TestTrafficReleaseGateAndExportTestGeneratedLogCannotBeReleasedWithErrors |
| INT-09 | Every kind of problem the check can report has a test that makes it fire (a permanent check added in 3.64.0) | LOG | Tested | TestEveryIntegrityKindIsTested |
Loading a day and playing it
| ID | Requirement | Source | Status | Tests |
|---|---|---|---|---|
| LOD-01 | Load 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 loaded | LOG | Tested | TestTrafficLoadsADayIntoTheRundown |
| LOD-02 | A 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) | LOG | Tested | TestMediaIsMatchedByHouseIDOrFileNameTestTrafficLoadsADayIntoTheRundown |
| LOD-03 | A channel restarted mid-day resumes the right item at the right offset, trims included | OPS | Tested | TestRestartPositionHonoursTrims |
| LOD-04 | Clock-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-5 | Tested Answer awaited | TestWallClockAtOnClockChangesTestWallClockPlacementOnClockChangesTestClockChangeDaysAreGeneratedAndLoadByTheClockTestWallClockAtTakesTheFirstOccurrenceEverywhere |
| LOD-05 | A channel set to place by elapsed time asks for an acknowledgement before loading a clock-change day | LOG | Tested | TestTrafficLoadWaitsForADSTDecision |
| LOD-06 | RUSS is autonomous: imported, released and loaded, never generated | ANS-8 | Tested | TestAutonomousChannel |
| LOD-07 | SRT receivers keep getting the stream through a slate gap before an anchored item | OPS | Tested | TestSlateGapReachesSRTReceivers |
Learning from 3ABN's logs
| ID | Requirement | Source | Status | Tests |
|---|---|---|---|---|
| LRN-01 | The channel profile learned from its first log: list number, log name, outputs, control codes, graphics timings, station ID, dayparts | LOG, ANS-9 | Tested | TestProfileIsLearnedFromTheLog |
| LRN-02 | The library learned: programs with their parts and breaks, promos, fillers and IDs; a day template, assigned to a weekday | LOG | Tested | TestTrafficLearnAndGenerateThroughTheAPITestGeneratorRebuildsTheSampleDays |
| LRN-03 | Dayparts per day of the week, learned from that day's log; a weekend log does not change the weekdays | WKD | Tested | TestWeekdayDaypartsAreLearnedAndUsedTestLearningASaturdayUsesItsOwnDayparts |
| LRN-04 | Clock start on every event (CANADA, PRAIZ) learned and written | WKD | Tested | TestWeekendLogsAreLearnedTestTitlesAreLearnedAndWritten |
| LRN-05 | A later part continues its program (TDYQA250016B; LAT's DLDL000155A off the grid) and is not counted as an airing | WKD | Tested | TestWeekendLogsAreLearnedTestHistoryCountsAiringsNotPartsTestGeneratorRebuildsTheWeekendDays |
| LRN-06 | Program titles learned; an operator's own title kept | WKD | Tested | TestTitlesAreLearnedAndWritten |
| LRN-07 | Station ID rotation learned (DARE six, PRO four) | WKD | Tested | TestWeekendLogsAreLearned |
| LRN-08 | Each 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) | WKD | Tested Answer awaited | TestWeekendLogsAreLearned |
| LRN-09 | A logo starting with its program is not taken for a rating (CANADA's CAN_W) | WKD | Tested | TestWeekendLogsAreLearnedTestGeneratorRebuildsTheWeekendDays |
| LRN-10 | Spots (CA) and PSAs (PS) learned into the library; they air only as fixed spots | DOC | Tested | TestDocumentedSampleIsLearned |
| LRN-11 | A flat log (RUSS): programs on the hour anchored; one-part programs of 5 minutes or less learned as fillers | LOG | Tested | TestFlatAnchorsHoldTheClock |
Building days
| ID | Requirement | Source | Status | Tests |
|---|---|---|---|---|
| GEN-01 | Each sample day rebuilt from what was learned: every program, part, start time, graphic and closing ID where ProTrack put it | LOG | Tested | TestGeneratorRebuildsTheSampleDays |
| GEN-02 | Each weekend day of the eight channels rebuilt the same way, with the same Up Next spots and rotating IDs | WKD | Tested | TestGeneratorRebuildsTheWeekendDays |
| GEN-03 | Breaks filled to the second from active promos and fillers; a break nothing fills is left open and named | LOG | Tested | TestFillerIsExactAndRotates |
| GEN-04 | When 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) | WKD | Tested Answer awaited | TestGeneratorRebuildsTheWeekendDays |
| GEN-05 | Rotation: no promo in consecutive breaks; rotation_days looks back up to 14 days of logs | LOG | Tested | TestFillerIsExactAndRotatesTestRotationDaysLooksFurtherBack |
| GEN-06 | Promo minimum gap (promo_min_gap_secs), across midnight (administration's promo rules not received) | ANS-10 | Tested Answer awaited | TestPromoMinGapKeepsAPromoOutOfTheHourTestPromoMinGapIsRelaxedOnlyWhenABreakCannotBeFilledTestPromoMinGapThroughTheGenerator |
| GEN-07 | Promo 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-10 | Tested Answer awaited | TestAiringRulesTestAiringRulesThroughTheAPI |
| GEN-08 | Airing numbers: one per airing, shared by its parts, never reused by another day or another item | LOG, ANS-2 | Tested Confirm in a test import | TestTrafficLearnAndGenerateThroughTheAPITestNumberEveryEvent |
| GEN-09 | Promos, 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-2 | Tested Confirm in a test import | TestNumberEveryEvent |
| GEN-10 | Series: 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-7 | Tested Answer awaited | TestSeriesAreLearnedAndAdvanceTestSeriesInTheRussianAndInternationalDays |
| GEN-11 | A series starts over when its episodes run out (learned blocks cycle) | ANS-7 | Tested | TestSeriesAreLearnedAndAdvance |
| GEN-12 | Series starting points: a chosen episode from a chosen day (no starting points received) | ANS-7 | Tested Answer awaited | TestSeriesStartingPoint |
| GEN-13 | Up Next spots X{DOW}{HH}{MM}S, Sunday = 01, 15 seconds, a promo, opening the break before the program they name | ANS-6 | Tested | TestUpNextSpotsNameTheNextProgramTestHourSpotsAreLearnedAndOnlyOpenTheirBreaksTestProfileValidates361 |
| GEN-14 | An Up Next spot marked ID (X050000S) is learned as a promo, and the notes say so | ANS-6 | Tested | TestUpNextSpotsNameTheNextProgram |
| GEN-15 | Fixed spots at an exact second, inside the break that holds them, never filling other breaks | LOG | Tested | TestFixedSpotsAirAtTheirTimeTestAFixedSpotNeverFillsOtherBreaks |
| GEN-16 | A flat day holds its anchors; time before one is filled from fillers; an overrun is reported | LOG | Tested | TestFlatAnchorsHoldTheClock |
| GEN-17 | Station IDs rotate, the least recently aired first, leaving room for the break's Up Next spot | WKD | Tested | TestGeneratorRebuildsTheWeekendDays |
| GEN-18 | CANADA's generated logs carry titles and a clock start on every event | WKD | Tested | TestTitlesAreLearnedAndWritten |
| GEN-19 | The 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-5 | Tested Answer awaited | TestClockChangeDaysAreGeneratedAndLoadByTheClock |
| GEN-20 | A 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-1 | Tested Confirm in a test import | TestRPXEveryRealLogRoundTripsTestTrafficLearnAndGenerateThroughTheAPI |
Graphics
| ID | Requirement | Source | Status | Tests |
|---|---|---|---|---|
| GFX-01 | Rating for 15 seconds at the start of every part (all 197 graphics events in the samples) | LOG | Tested | TestSecondaryRulesReproduceTheSamples |
| GFX-02 | Logo from 1:30 into each part to 2:00 before its end, chosen by daypart | LOG | Tested | TestSecondaryRulesReproduceTheSamples |
| GFX-03 | E/I label from 1:30 to the end of each part of a children's program, on its own output | LOG | Tested | TestSecondaryRulesReproduceTheSamples |
| 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-4 | Tested Answer awaited | TestEIFollowsTheDaypartTestWeekdayDaypartsAreLearnedAndUsed |
| GFX-05 | E/I follows the daypart: _8_E in business hours, _6_E otherwise | ANS-4 | Tested | TestEIFollowsTheDaypart |
| GFX-06 | Graphics 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) | OPS | Partly tested | TestCueStateFollowsTheSecondaryTimingTestCuesEndpointReportsTheItemOnAirTestBugLayerInBrowserTestBuiltInFrameCarriesTheTrafficGraphics |
Waiting on 3ABN
- Does ProTrack (the player) import PlayoutGo's generated logs? The test packs of 3.58.0, 3.61.0 and 3.62.0 are ready.
- Does the player accept PlayoutGo's event IDs on Up Next spots, promos, fillers and IDs?
- How does the player air a clock-change night (1 November 2026)?
- Some hours of the weekend logs have no Up Next spot: on purpose?
- PRO's PX071400S airs at 13:29 but names 14:00.
- LAT's DLDL000155A airs twice: at 04:00:00 and 04:14:19.
- Do the channels other than ENG have business-hours _8 logos?
- Does the player accept a few seconds before a clock start?
- Does ProTrack ever write B, H, '-' or CO records?
- Which programs repeat and which air new episodes; any starting points?
- Administration's promo rules: which promo may air on which channel, and when.
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
{"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.{"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).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)./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.β¦/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}].{"code","file_id"} β gives one of the owner's files a house ID; any other file
holding it loses it.issues and the media match per house
code. DELETE removes a log that is not released.{"acknowledge","note"} β 409 with the issues when the log has integrity
errors (imported logs: unless acknowledged with a reason; generated logs: always).{"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.Content-Disposition carries its name).{"profile","library","template","template_name","weekday"} β learns from the log.
Existing library entries are never changed.logo_url is empty (3.53.0). Unauthenticated like the graphics page β the node fetches it β and
404 when none is uploaded.Application Keys
/api/appkeys/{id} revokes one, which takes effect
immediately rather than when a cache expires./api/app/channels/{id} for one channel. Read-only by design: a key
embedded in a shipped application cannot change anything.Playback, As-Run and Metrics
/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.playoutgo_channel_pcr_late_total above zero is a feed that will fail an operator's ingest
test β the figure worth alerting on.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
- The 3ABN requirements table (
requirements3abn.go), shown in the manual: 73 requirements, where each came from, what is left for 3ABN, and the tests. A test fails when a row names a test that does not exist, claims a test it lacks, or leaves one of 3ABN’s answers out. - Every kind of problem the log check reports has a test, and a test fails when a new kind is added without one. Eight had none: the wrong channel’s log (list number, outputs), a program with no rating, an event with no duration, a graphic before its program, on a promo or naming no output, a program and its breaks not filling whole quarter hours, times with frames, and the record and playback-rate warnings.
- Tested for the first time: media matched by house ID (Assign file) over the file name, two candidate files, a file not ready, a short file; generated logs in drop-frame, with 3ABN’s fixed fields on every record; the two clock-change Sundays (1 November 2026, 14 March 2027) generated, released and loaded by the clock.
Fixed
- 3ABN’s channels were told a clock-change day would air “an hour off”. The log’s
warning always described placing events by elapsed time; 3ABN’s channels place them by clock time
(
dst_modewallclock). It now says what loading will do on that channel: on 1 November, that the hour from 01:00 to 02:00 happens twice and its events air once; on 14 March, that the events from 02:00 to 03:00 are not loaded and the next is anchored. The hours come from the channel’s time zone. - An alert raised as a channel stopped could outlive the database. A webhook notification (3.63.0) read the server’s database and address when its goroutine ran, not when it was raised: at shutdown it could run after the database had closed, and between tests the race detector caught it reading settings another test was replacing. It now takes both when raised, and shutdown waits up to 5 seconds for those in flight before the database closes.
Verification
- Suite: 774 tests and 16 fuzz targets (main), 10 tests (gpupool); 10 of the tests are new, two of them permanent checks (every requirement traced, every integrity kind tested). The full suite passes, and passes under the race detector with no race reported.
- Mutations: 34 mutations of this release’s changes, each putting one defect back — the clock-change warning, each integrity check, media matching, drop-frame and fixed fields on generated logs, the requirements table and its manual section — all caught by a test, none by a build failure. The first run left one survivor: an event in the repeated hour placed in its second occurrence. Go’s clock picks the first occurrence in American zones but the second in London and Sydney, and the tests used only American ones; a test across four zones now catches it. Two more put back the webhook race above, and are caught.
- Real binary: 3ABN’s clock-change weekend pack built with the 3.64.0 binary through its API — eight channels, Saturday 31 October and Sunday 1 November 2026: no integrity error, every record 696 characters, every file released and read back into a fresh channel, every Sunday log 24 hours of the clock with the warning that the hour from 01:00 to 02:00 happens twice and its events air once.
Known gaps
- Waiting on 3ABN (13 requirements): a test import of a generated pack; event IDs on Up Next spots; how the player airs the clock-change night; the Up Next gaps in the weekend logs, PRO’s PX071400S, LAT’s repeated DLDL000155A; business-hours logos on the other channels; a few seconds before a clock start; B, H, '-' and CO records; the series rules and starting points; administration’s promo rules.
- Traffic graphics on the built-in frame are sent to the node but drawn only by a gpuff release that draws
overlays(GFX-06, partly tested). - Carried from 3.63.0: no FEC, 2022-7 or SAP on the UDP output; multicast beyond this server and IPv6 destinations untested; webhooks send no e-mail. SDI untested on a DeckLink card, main language only, no captions, timecode, key/fill or genlock.
- Carried from earlier: conform on this server’s processor only; subtitle and data streams dropped by conform; live inserts not loudness-managed; two-factor cannot be required; the features not built; no Let’s Encrypt certificate itself; the real systemd without the distribution’s services; no TPM; gomedia not fuzzed; no real-binary run for 3.56.0’s own changes.
- Needs the operator: a multicast network and a head-end or IRD; a DeckLink card; NVENC and NVDEC on real
cards; NVENC filler on the production ffmpeg; a gpuff release that draws
overlaysand the logo; gpuffmp4mux; a real YouTube OAuth client; a real A/53 master and the captions menu on Apple players; a key-management service; Firefox, WebKit, Safari and tvOS runs.
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
- UDP / RTP output: per channel, multicast or unicast, IPv4 or IPv6; raw UDP or RTP (payload type 33); TTL, egress interface and DSCP; paced at the constant bitrate or a set rate; the channel dialog, the API, the status, four metrics and two alert rules. It carries the same transport stream as SRT β service tables, SCTE-35, constant-rate padding.
- SRT joins a running stream when a UDP output runs: a receiver connecting is held to the next keyframe and given the program map, and the multicast is not restarted.
- Alert webhooks: channel down/up, output down/up (SRT, UDP, SDI), unplayable files, dead air,
channel errors; signed with HMAC-SHA256; queued and retried; a delivery log; Send test and New secret; Admin βΈ Alert
Webhooks and
/api/admin/webhooks; administrators only, audited. - SDI in the metrics:
playoutgo_channel_sdi_upandplayoutgo_channel_sdi_restarts_total, and aPlayoutGoSDIDownalert rule.
Fixed while building this release
- An SRT receiver joining could be given a video packet as its PAT: the table cache took any
packet starting a payload with
00 00 01β every PES start β for table 0, and the receiver saw a continuity break on the video PID. The PAT is now taken only from PID 0 and the program map only from the PID the PAT names, noted packet by packet while a receiver joins (found by a stricter join test). - Short datagrams on a healthy stream: a write more than 50 ms after the last sent what was waiting as a short datagram, so ordinary muxer bursts split 1.7% of a 4 Mb/s constant-rate stream (found on the real binary). Packets now wait for a full datagram, and go out short only after a quarter of a second.
- RTP timestamps were the time a datagram was queued, not sent: behind a paced queue that had backed up, the 90 kHz clock ran 2.7 s behind the datagrams (found on the real binary, on a host starved of CPU). It is now stamped at transmission, as RFC 3551 has it.
Verification
- Suite: 764 tests and 16 fuzz targets (main), 10 tests (gpupool); 22 of the tests are new. The full suite passes, and passes under the race detector with no race reported.
- Mutations: 53 mutations of this release’s changes, each putting one defect back β among them the three bugs fixed above. All are caught by a test, none by a build failure. The first run had three that did not compile (rewritten) and seven survivors, which new tests now catch: the join filter packet by packet, the RTP clock, short datagrams, pacing after a stall and past the queue, retry spacing and a disabled webhook. One more pointed at the wrong test and was rerun against the right one.
- Real engine: the 3.63.0 binary through its own API (
real363.py), 18 of 18 checks. A 4 Mb/s constant-rate channel sent RTP to a local receiver for 15 s: about 5,800 datagrams, every one seven packets, one SSRC, no sequence gap, the 90 kHz clock moving with the wall clock (15.38 s on both), each second 3.95β3.98 Mb/s. ffmpeg joined its SRT listener midway: the RTP stream had no continuity error and one program-map version, and what ffmpeg received decoded, with the 440 Hz tone. A webhook received Send test, signed with the secret shown once; a channel whose SRT receiver was a closed port raisedoutput.downforsrt.
Known gaps
- No FEC (SMPTE 2022-1) and no seamless protection switching (SMPTE 2022-7) on the UDP output; no SAP/SDP announcement of the multicast.
- Multicast beyond this server is the network’s: verified on the loopback interface and to a unicast receiver; IGMP/PIM routing and a real head-end are untested here. IPv6 destinations are untested too: the build host has no IPv6, so the hop limit and traffic class are set by code that has not run.
- Webhooks carry no e-mail: an e-mail relay or chat service takes the webhook; the server sends no mail itself. Delivery is at least once β a receiver should use the event ID to drop a repeat.
- Carried from 3.62.0: SDI untested on a DeckLink card, main language only, no captions, timecode, key/fill or genlock; the meaning of break records; 3ABN’s open questions (pack README).
- Carried from earlier: conform on this server’s processor only; subtitle and data streams dropped by conform; live inserts not loudness-managed; two-factor cannot be required; the features not built; no Let’s Encrypt certificate itself; the real systemd without the distribution’s services; no TPM; gomedia not fuzzed; no real-binary run for 3.56.0’s own changes.
- Needs the operator: a multicast network and a head-end or IRD; a DeckLink card; NVENC and NVDEC on
real cards; NVENC filler on the production ffmpeg; a gpuff release that draws
overlaysand the logo; gpuffmp4mux; a real YouTube OAuth client; a real A/53 master and the captions menu on Apple players; a key-management service; Firefox, WebKit, Safari and tvOS runs.
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
- SDI output: per channel, through ffmpeg’s DeckLink output β HD and SD formats with their
field order, 4:2:2, 2/8/16 embedded audio channels, restarted when ffmpeg exits;
/api/channels/{id}/sdiand/api/sdi/devices; administrators only. - The RapidPlay X2000 field model: every documented column kept and written back; H, '-' and B records; CO and OE events; FCC types CA and PS; follow events without a start time; the SOM cues the file at load.
- Gaps of up to 10 seconds before a clock start, and at the end of the day, are warnings, as ProTrack’s own logs have them; a generated break may end that early when nothing fills it exactly.
- Clock start on every event (CANADA, PRAIZ), titles (CANADA),
.txtexports (CANADA), station ID rotation (DARE, PRO) β each learned from the log. - The console: the log shows titles and marks comment, break, manual and external events; the profile has the new settings and names the columns by the document.
Fixed
- CANADA’s logs were refused (“columns 152β318 hold HELP IN DAILY LIVING”): the title column was not known.
- Every weekend log showed integrity errors for the 1β4 seconds ProTrack leaves before clock-start programs, and two for ending 1β3 seconds before midnight; an imported log needed an acknowledgement to release.
- CANADA’s logo was learned as its rating graphic: a secondary starting with its program was always a rating.
- On CANADA and PRAIZ every later program part started a new program, so their days were learned as flat, not grid, and their programs lost their later parts.
- A break that could not be filled exactly was left open in front of the station ID, which then failed the integrity check; the ID now follows the break, and a few seconds may be left before the clock start.
- A toast followed by another was replaced at once (after Learn the counts were overwritten by the notes), and an earlier toast’s timer hid a later one early. Toasts now queue: each stays at least 1.5 seconds when another waits, and its own full time otherwise.
- A log opened just after Generate could blank to the bare list: Generate’s refresh of the Logs view, returning after the Open, replaced the log being shown. A refresh overtaken by a newer one now stops.
Verification
- Suite: 742 tests and 16 fuzz targets (main) and 10 tests (gpupool), 18 of the tests new; the full suite passes, and passes under the race detector with no race reported. All 19 of 3ABN’s logs round-trip through the parser, the stored events and the API; each weekend day is learned and rebuilt by the generator with its programs and graphics where ProTrack put them.
- Mutations: 50 mutations of this release’s changes, each putting one defect back, all caught by a test (one by the test timing out on a blocked engine), none by a build failure. In the first run five did not compile and were rewritten, and one survived β a follow event with no start time placed at the start of the day; the documented-fields test now has a follow event with both start times blank.
- Real binary: the 3ABN weekend pack built with the 3.62.0 binary through its API β eight channels, Saturday 24 and Sunday 25 October: no integrity error, 7 warnings of a short gap before a clock start in 4 files, every record 696 characters, every file read back into a fresh channel. The test guide’s console steps for all eight channels in Chromium, 92 of 92, its 16 exports byte-identical to the pack; the test guide itself, 81 of 81. SDI on a real engine with the stand-in for the card: 1920Γ1080 4:2:2 at 29.97 frames a second and 48 kHz PCM carrying the programme’s tone.
Known gaps
- SDI output is untested on a DeckLink card. It was verified with a stand-in for the card: the real
ffmpeg ran the server’s exact command line with only the final
-f decklinkdevice replaced by files β 1920Γ1080 4:2:2 at 29.97 frames a second and 48 kHz PCM carrying the programme’s tone. That a card accepts each format, the field order on the wire, and the audio on the right SDI channels need a server with a card and a DeckLink-enabled ffmpeg. - SDI carries the main language only, and no captions in the ancillary data, no timecode, no key/fill and no genlock setting.
- The meaning of a break record (B) is not in the document: B records play as ordinary events. OE secondaries are read as an offset before the program’s end; 3ABN’s logs have none.
- From 3ABN, still to come: why some hours of the weekend logs have no Up Next spot, PRO’s PX071400S at 13:29, LAT’s repeated DLDL000155A, whether other channels have business-hours logos, whether the player accepts a few seconds before a clock start, administration’s promo rules, the series rules, the clock-change night, and whether the player accepts PlayoutGo’s event IDs on Up Next spots.
- Carried from 3.61.0: conform on this server’s processor only; subtitle and data streams dropped by conform; live inserts not loudness-managed; two-factor cannot be required; the features not built; no Let’s Encrypt certificate itself; the real systemd without the distribution’s services; no TPM; gomedia not fuzzed; no real-binary run for 3.56.0’s own changes.
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg; a gpuff
release that draws
overlaysand the logo; gpuffmp4mux; a real YouTube OAuth client; a real A/53 master and the captions menu on Apple players; a key-management service; Firefox, WebKit, Safari and tvOS runs; a DeckLink card.
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
- Event IDs are ours: columns 86β105 read and written in full;
number_every_eventgives every promo, filler and ID without a library number an event ID from the airing counter. - E/I follows the daypart (
ei_suffix): ENG_8_E in business hours, ENG_6_E otherwise, learned from the log. - Up Next spots: the pattern takes
{MM}(X{DOW}{HH}{MM}S, learned), and a spot can announce a program at any start time. - Promo airing rules: days and times, first and last day, at most per day β set in the promo’s dialog, kept by the filler without exception.
- Series starting points (
series_starts); learned series blocks start over when the episodes run out. - Autonomous channels: import and load only (3ABN RUSS).
- Drop-frame timecodes (
HH:MM:SS;FF) read, kept and written.
Fixed
- An Up Next spot marked ID in a log was learned as an ID (X050000S in the ENG sample); it is a promo, and learning now adds it as one.
- A ProTrack log with an event ID longer than seven digits was refused as data in unknown columns (95β105).
Verification
- Suite: 724 tests and 16 fuzz targets (main) and 10 tests (gpupool), 11 of the tests new; the full suite passes, and passes under the race detector with no race reported. The traffic tests learn from 3ABN’s three sample logs and check each answer on generated days: Friday’s logos, E/I by daypart, Up Next before a half-hour program, a starting point, airing rules, an autonomous channel, event IDs, drop-frame files round-tripped.
- Mutations: 29 mutations of this release’s changes, each putting one defect back, all caught by a test, none by a build failure: the event-ID columns, drop-frame reading, writing and storage, E/I by daypart and its learning, Up Next minutes and types, each airing rule, series starting points, the autonomous refusals, event IDs and their counter, learned cycling, the profile checks, the manual and the console. The first run left one survivor, the E/I suffix’s length check; a test case now catches it.
- Real binary: the 3ABN test pack rebuilt with the 3.61.0 binary through its API β ENG and INTL for 15, 16 and 21 October: no integrity error, every record 696 characters, every file read back into a fresh channel, Friday ENG_6 all day with ENG_6_E on the E/I programs, every Up Next spot a promo with an event ID; generating RUSS refused. The test guide’s steps done in the console in Chromium, 47 of 47, its six exports byte-identical to the pack; the test guide itself in Chromium, 46 of 46.
Known gaps
- The RapidPlay X2000 document and the Saturday and Sunday logs 3ABN attached did not arrive. The fixed columns other than the discrepancy code are still copied, not understood; the weekend dayparts come from 3ABN’s rule, not from a weekend log.
- 3ABN’s actual promo rules are not entered: the rules can be set per promo, but which promo may air where and when is administration’s list, not yet received.
- How the player places a 24-hour schedule on a clock-change day was not answered; 3ABN’s channels are set to place by clock time.
- Event IDs PlayoutGo gives promos without a library number are its own; whether the player needs ProTrack’s library numbers for them is still to be seen in a test import.
- Carried from 3.60.0: conform on this server’s processor only; subtitle and data streams dropped by conform; live inserts not loudness-managed; two-factor cannot be required; the features not built; no Let’s Encrypt certificate itself; the real systemd without the distribution’s services; no TPM; gomedia not fuzzed; no real-binary run for 3.56.0’s own changes.
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg; a gpuff
release that draws
overlaysand the logo; gpuffmp4mux; a real YouTube OAuth client; a real A/53 master and the captions menu on Apple players; a key-management service; Firefox, WebKit, Safari and tvOS runs.
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
- Conform on ingest (conform): uploads, watch folders and re-imports take MOV, MXF, MPEG-TS, MKV and WebM; video other than 8-bit 4:2:0 H.264 is re-encoded (captions kept), audio other than AAC converted, every language kept, the original replaced atomically after the result is checked. A file on air is never replaced. β Conform converts a file already in the library.
- Loudness (loudness): integrated loudness, true peak and range per audio
track, for every file, shown in the library and the API and as
/api/media/loudness.csv;loudness_target_lufs,loudness_tolerance_luandloudness_true_peak_dbtpnormalise at ingest. - Two-factor sign-in (two-factor): TOTP per account, recovery codes, replay refused, wrong codes throttled like passwords, the secret sealed, other sessions signed out on every change, an administrator’s reset, all audited.
Fixed
- Audio other than AAC aired as silence in “Original files” mode: AC-3, E-AC-3, MP3, PCM. It is now converted at ingest. (The engine’s silence remains for a file that could not be converted.)
- MOV, MXF, MPEG-TS and MKV uploads were refused, and HEVC, ProRes, MPEG-2 or 10-bit H.264 failed the probe; they are now converted.
Verification
- Suite: 713 tests and 16 fuzz targets (main) and 10 tests (gpupool), 15 of the tests new; the full suite passes, and passes under the race detector with no race reported. The conform and loudness tests convert real files with ffmpeg: a MOV with PCM, HEVC with two AC-3 tracks, 4:2:2 H.264 with A/53 captions, and a programme 5 dB too loud.
- Mutations: 36 mutations of this release’s changes, each putting one defect back, all caught by the test written for it: conform’s decisions, the conversion (captions, languages, the original left behind, a file on air), the record of what was done, loudness (tolerance, ceiling, defaults, normalising, the figures afterwards, the report), two-factor (skew, replay, the used step, recovery codes, sign-in, throttling, sessions signed out, the password and code asked for, the sealed secret), the console’s handler list and the manual.
- Real binary: the 3.60.0 binary over HTTP, 22 of 22: an AVI refused naming what is taken; a MOV with PCM 5 dB too loud re-wrapped, converted and normalised to β24 LUFS; HEVC with two AC-3 tracks stored as H.264 and two AAC tracks, English and Spanish; that programme on air with sound in its HLS segments (it aired mute before); Conform refused on air; the CSV report; two-factor enrolment, sign-in, replay refused, a recovery code once, the old session signed out, an administrator’s reset, all audited with no secret in the trail. In Chromium, 11 of 11: the code field at sign-in, the Media card’s conform note and loudness, π Measure from the card, turning two-factor on with the server’s QR code and staying signed in, with no policy violation. The 3.55.0 real-binary checks: API 21/21 and Chromium 9/9.
- The installer on a real systemd: the 3.59.0 harness on the 3.60.0 bundle, 38 of 38 over two boots.
Known gaps
- Conform runs on this server’s processor, one file at a time; a video re-encode of a long master takes about real time or longer. It does not use the encoding nodes or the cards.
- Subtitle and data streams are dropped by conform, SCTE-35 in a transport-stream upload included.
- Live inserts and the slate are not loudness-measured or normalised; only files are.
- Two-factor cannot be required of administrators or of every account yet; it is each account’s choice.
- The features in the roadmap’s list are not built.
- Carried from 3.59.0: no certificate from Let’s Encrypt itself (tested against Pebble); the real systemd ran without the distribution’s other services; no TPM; 3ABN’s answers and the ProTrack import.
- Carried from 3.56.0: the on-air muxer (gomedia) is not fuzzed; no real-binary run for 3.56.0’s own changes.
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg; a gpuff
release that draws
overlaysand the logo; gpuffmp4mux; a real YouTube OAuth client; a real A/53 master and the captions menu on Apple players; a key-management service behindsecrets_key_command; Firefox, WebKit, Safari and tvOS runs.
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
- An ACME CA of your own (HTTPS):
tls.acme_directoryandtls.acme_ca_file, for a host on an internal network with its own CA (step-ca, a corporate PKI); checked when the configuration loads and byplayout -check.install.sh --tls-acme-directoryand--tls-acme-cawrite both. - Promo minimum gap (traffic):
promo_min_gap_secskeeps two airings of a promo that far apart, across midnight too; a break that cannot be filled within it is filled anyway and named in the notes. Off by default.
Fixed
- A broken upgrade on a real systemd host was left in place, with the service down. A real
systemctl startfails when the service does not come up; underset -ethat endedinstall.shbefore the health check that decides on a rollback. The stand-insystemctlthe tests used returned success, so this was never seen. Now the health check decides, and the stand-in fails a start the way systemd does, so the installer tests catch this. - The server warned at every start after an upgrade that it could not make
backups/last-upgradeowner-only: the installer wrote it as root, mode 0644, where theplayoutuser cannot change it. It is now written 0600 and owned by the service user. - Installer tests could leave a server running when they failed (found after the 3.58.0 mutation runs). Each now stops every process running from its work directory.
Verification
- Suite: 698 tests and 16 fuzz targets (main) and 10 tests (gpupool), 12 of the tests new; the full suite passes, and passes under the race detector with no race reported.
- Mutations: 28 mutations of this release’s changes, each putting one defect back, all caught by the test written for it: the ACME settings, their checks and the certificate manager, the promo gap, the installer’s start and its upgrade record, its ACME options, the test reaper, and the manual. The start that fails is caught by the stand-in
systemctl, which now fails a start the way systemd does. - The installer on a real systemd (the shipped bundle, the real
systemctl): 38 of 38 checks, two boots. A fresh install with--tlsand the test CA: enabled, notify-ready,-checkbefore the start, theplayoutuser with onlyCAP_NET_BIND_SERVICE, and the confinement seen from inside the service. The certificate issued on 443 and 80 and reused after a restart and an upgrade. A restart after kill -9 with the as-run row closed; the watchdog; the keyring from an encrypted credential; an upgrade,--rollback, and a broken version rolled back by itself. After a power-off and a boot, the server up with no start command, the certificate from its cache, and the row open at power-off closed as stopped. Nothing written to the build host. - Real binary: the 3.55.0 real-binary checks against the 3.59.0 binary: API 21/21 and Chromium 9/9.
Known gaps
- No certificate from Let’s Encrypt itself yet. No public DNS name reaches the build host; issuance is tested against Pebble. The first start on a host whose DNS points at it is the last step.
- The real systemd ran without the distribution’s other services (their links hidden, so a boot starts only journald, the test CA and PlayoutGo). The first install on a production host, and moving plt.bozztv.com over, remain.
- No TPM here: the encrypted credential is tested with the host key, not
--with-key=tpm2. - 3ABN’s answers decide the traffic defaults,
promo_min_gap_secsamong them; the ProTrack import of the test pack is untested. - Carried from 3.56.0: the on-air muxer (gomedia) is not fuzzed; no real-binary run for 3.56.0’s own changes.
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg; a
gpuff release that draws
overlaysand the logo; gpuffmp4mux; a real YouTube OAuth client; a real A/53 master and the captions menu on Apple players; a key-management service behindsecrets_key_command; Firefox, WebKit, Safari and tvOS runs.
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
- Crash-safe as-run (as-run): the airing row is stamped every 5 s, whatever is airing β a file, an ABR item, a live insert β and a row the process left open (kill -9, a crash, a power cut) is closed by the next start at its last stamp. Until now it ended where it started: the item on air recorded no airtime at all. 3.57.0 closed a clean stop at the moment of the stop; this covers the rest.
- Alert rules (alert rules):
playoutgo-alerts.ymlin the bundle, thirteen Prometheus alerts for the conditions the manual says to watch, every metric checked against what/metricsexports, withpromtoolrule tests. install.sh --tls DOMAINS(the installer): a fresh install with built-in HTTPS β the TLS settings,advertised_base_urlonhttps://, and the drop-in granting the capability to bind 443 and 80.--tls-email,--tls-port,--tls-http-port.playout -healthz-url(the check): where the configured server answers/healthzon this host. The installer asks the binary instead of reading the port out of the JSON, where the TLS block’s ownportcould come first.- Log rotation without systemd: with
--no-systemd,playout.logis rotated at each start once over 50 MiB, three kept. - Traffic: clock-change days (clock-change days): per channel, by elapsed time (as before) or by clock time β the skipped hour’s events not loaded and the first after them anchored, the repeated hour aired once.
- Traffic: dayparts per day of the week (weekday dayparts): learned from that day’s log, used by generation and by learning; a weekend log no longer replaces the dayparts every other day uses.
Fixed
- An upgrade on a host with built-in TLS always rolled itself back. With
tlson, the server listened on the TLS ports only andconfig.json’sportanswered nothing; the installer checkshttp://127.0.0.1:<port>/healthzafter every start, so the new version never passed, and the rollback failed the same check. That is the setup INSTALL.md’s first step recommends.portnow serves/healthzand/metricsβ only those β on 127.0.0.1 in TLS mode (loopback). - The capability advice did not work under the 3.57.0 unit. The manual and the server’s
bind-failure hint said to
setcapthe binary (at the pre-3.57 path); the unit’sNoNewPrivileges=yesdrops file capabilities. Both now name the drop-in.
Verification
- Suite: 686 tests and 16 fuzz targets (main) and 10 tests (gpupool), 14 of the tests new; the full suite passes, and passes under the race detector with no race. The alert-rule test ran with
promtool2.53.2. - Mutations: 29 mutations of this release’s changes, each putting one defect back, and all 29 caught by the test written for it: the heartbeat and recovery, the loopback listener,
-healthz-url, the installer’s TLS path and log rotation, the alert rules (one throughpromtool’s rule tests), the bundle, both traffic options, and the manual. - A crash, on the real binary: two channels on air, the server killed with SIGKILL 22.5 s into a 60-second item and started again: each row
interrupted, recording 19.99 s of the 22.5 s aired β the loss inside one heartbeat β where 3.57.0 recorded none; both channels back on air. - The installer: against the real binary, a fresh install with
--tlsand then an upgrade that passes its health check on the loopback listener β with thetlsblock first inconfig.json, where reading the first port out of the JSON finds the TLS one β rotating a 60 MiB log on the way;--tlson an installed host refused, nothing changed; under the stand-insystemctl, the capability drop-in written and loaded before the start. The 3.57.0 cycle and systemd-path tests pass unchanged;shellcheckreports nothing. - The 3ABN test pack: six RPX days generated by the real binary from the three 14 October samples β ENG, INTL and RUSS for Thursday 15 and Wednesday 21 October β each without integrity errors, released, in 696-character records, and read back into a fresh channel without an error.
- Soak: 15 minutes, the real binary, the five channels of 3.57.0’s hour: 46 goroutines throughout (41, and one heartbeat per channel), 22–23 open files, 8–9 threads, 26.8 MB resident after warm-up and 26.7 MB at the end, the constant-rate channel at 2.9966–3.0013 Mb/s in every 30-second window; SIGTERM closed all five airing items as
stopped. Shorter than 3.57.0’s hour: what changed on the running path is one goroutine per channel and one write every 5 s. - Real binary, API and browser: the 3.55.0 real-binary checks against the 3.58.0 binary: API 21/21 and Chromium 9/9.
Known gaps
- Built-in TLS has not issued a real certificate here. No public DNS name reaches this host: the tests run the server with TLS on and check the loopback listener, the installer’s upgrade through it, and the capability drop-in, not Let’s Encrypt. The first start on a real host, with the domain’s DNS pointing at it, is that test.
- The installer has not run on a real systemd host (from 3.57.0): its systemd path, the TLS
drop-in included, is tested against a stand-in
systemctl. - 3ABN’s answers decide the traffic defaults. Clock-change days and weekday dayparts are options; which is right, and whether ProTrack accepts generated logs, waits for 3ABN. The test pack is ready for a ProTrack import.
- From 3.56.0: the on-air muxer (gomedia, a library) is not fuzzed; no real-binary run was written for 3.56.0’s own changes (the combination tests cover them).
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg;
graphics on a real GPU node (the node drawing
overlaysand the logo); gpuffmp4mux; a real YouTube OAuth client; a real A/53 master and the captions menu on Apple players; a key-management service behindsecrets_key_commandand a TPM-sealed credential; Firefox, WebKit, Safari and tvOS runs.
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
- The deploy bundle (deployment):
playoutgo-3.57.0-linux-amd64.tar.gzholds the binary,playoutgo.service,install.sh,INSTALL.md,config.sample.jsonandVERSION.make-release.shbuilds it from the same tree as the source archive, with aSHA256SUMSfile; a test checks its contents and modes, and that its binary reports the versionVERSIONnames. - The installer (install.sh): a fresh install β user, layout,
config.jsonwith absolute paths and generated secrets, the unit enabled and started,/healthzwaited for; an upgrade that has the new binary check the configuration before anything is touched, copies the database, keyring and configuration, swaps the binary and the unit, and rolls itself back β binary, unit and database β if the new version does not answer within 60 s; and--rollback. The unit is the installer’s, recognised by checksum: local settings go in a drop-in, and an upgrade refuses to replace a unit with local changes. A binary from before 3.57.0, put back by a rollback, is never run with-check. - A hardened unit (the unit): notify and watchdog, its own user, the file
system read-only except the state directory and
/dev, no new privileges, kernel, clock, namespaces and address families restricted, 65,536 files, the configuration checked before every start β and GPU devices still reachable. playout -checkand-version(the check): the configuration checked for production without changing anything β errors for what would fail or could be attacked, warnings for what to decide β exiting 1 on an error. Until now the binary took no arguments at all.- Process metrics (monitoring): goroutines, heap, resident
memory, open files, threads and uptime in
/metrics. /guide/deploy, the deployment section and the 24/7 tutorial are rewritten around the bundle and the installer; the three unit files they showed did not agree with one another.
Fixed
- An empty
jwt_secretsigned every session with an empty key.config.sample.jsonships the value empty and says to copy it toconfig.json, promising one would be generated. It was β but only whenconfig.jsondid not exist; an existing file’s empty value replaced the generated one, and anyone can compute a token signed with an empty key, for any account. An emptyjwt_secretoradmin_passwordis now generated and written back, keeping the file’s other keys and comments; found by running the new check against the sample. - A live take left on when a channel stopped was resumed with no feed. The next start took the
stale take to air: five seconds of nothing β only padding on a constant-rate channel β a live as-run row and a
live.lostentry for an event that had ended. It is now resumed only while the feed is sending. Found by following up 3.56.0’s unexplained constant-rate test failure, which was this. playoutgo_channel_bytes_sent_totalincluded HLS segment bytes, under a description that says transport stream: a constant-rate channel read about 15% above its rate. It is now the transport stream alone, andplayoutgo_channel_hls_bytes_totalcounts the HLS segments. Found by the soak.- An item on air when a channel stopped got no airtime in the as-run log. The engine left the
row open when it was stopped mid-item, and a shutdown did not wait for the engines, so the process exited first.
The next start closed each row as
interrupted, ending where it started: after every clean restart or upgrade, the items that had been on air read as zero seconds, and after an operator’s Stop the row readplayinguntil the process restarted. The row is now closed asstoppedat the moment of the stop, with its bytes, and a shutdown waits for the channels (up to 10 s). Found by the soak’s restart check. - A constant-rate channel went out about 1% under its configured rate: 1.2% at 3 Mb/s, nearly 4% at 1 Mb/s. Every 20 ms the padding was rounded down to whole packets and the rest of the budget dropped, half a packet on average. What is left of a packet is now owed to the next interval, and the budget keeps its fractions of a bit and of a byte, so over a minute the stream is exact to within one packet. The clock derived from the byte count, used while a new receiver waits for a keyframe, ran slow by the same amount. Found by the soak, once the figure above was the transport stream alone; the pacer’s test had allowed ±10%.
Verification
- Suite: 672 tests and 16 fuzz targets (main) and 10 tests (gpupool), 22 of the tests new; the full suite passes, and passes under the race detector with no race.
- Mutations: 37 mutations of this release’s changes, each putting one defect back, and all 37 caught by the test written for it: the stale take, the byte metrics, the empty secrets, the check, the installer’s cycle and its systemd path, the unit, the bundle, the pacer, the shutdown and the manual.
- The installer: the whole cycle against the real binary with
--no-systemdβ a fresh install, an upgrade, a manual rollback, a binary that will not serve (rolled back by itself), a failing check (refused before anything changes), a fresh install onto a port a server answers on (refused), an upgrade from a binary older than 3.57.0 (never run); and the systemd path against a stand-insystemctlβ the unit loaded before every start, replaced by an upgrade with the operator’s drop-in kept, put back by a rollback, a unit with local changes refused, a rolled-back pre-3.57 binary never asked to-check, the newest three pre-upgrade copies kept, fresh installs refused over a hand-made unit.shellcheckreports nothing; the unit passessystemd-analyze verify. - Soak: one hour, the real binary, five channels on air β single-language HLS, two languages, ad breaks, a 3 Mb/s constant-rate DVB channel over SRT to a real receiver, and an ABR ladder encoded on this server: 41 goroutines throughout, 18–24 open files, 9 threads, 24–30 MB resident (26.7 MB in the first ten minutes after warm-up, 26.4 MB in the last), heap 5.6 → 4.8 MB; every HLS channel’s newest segment under 4.2 s old at every sample; the constant-rate channel at 2.9967–3.0029 Mb/s in every 30-second window; 6% of one core. SIGTERM stopped it at once, and every channel came back writing segments within 10 s. A ten-minute run of the final build, after the shutdown fix, passed the same checks and two more: every airing item closed as
stoppedat the signal, and nothing left for the next start. Three earlier runs β of 35 and 18 minutes, cut off by host restarts, and of 36 minutes, stopped to take the pacer fix β held as flat. - Real binary, API and browser: the 3.55.0 real-binary checks against the 3.57.0 binary: API 21/21 and Chromium 9/9.
Known gaps
- The installer has not run on a real systemd host, since none runs where it is built. Its
systemd path is tested against a stand-in
systemctlthat runs the unit as last loaded: the order of reloads, enables, starts and stops; the unit replaced, saved and put back; drop-ins kept; a unit with local changes refused; a binary from before 3.57.0 never run with-check. The whole cycle also runs with--no-systemd, and the unit passessystemd-analyze verify. Only a real host shows the start at boot, the confinement against the server’s real file use, and the watchdog restarting a wedged process. - From 3.56.0: the on-air muxer (gomedia, a library) is not fuzzed; no real-binary run was written for 3.56.0’s own changes (the combination tests cover them).
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg;
graphics on a real GPU node (the node drawing
overlaysand the logo); gpuffmp4mux; a real YouTube OAuth client; 3ABN's answers; a real A/53 master and the captions menu on Apple players; a key-management service behindsecrets_key_commandand a TPM-sealed credential; Firefox, WebKit, Safari and tvOS runs.
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
- A few bytes to the RTMP port could stop the server. An AMF0 strict array declares its element count, and the decoder allocated for whatever count it was told: six bytes asked for twelve gigabytes. Objects nested without a limit, so a 15 MB message could recurse millions deep. Both are fatal errors, not panics β the connection's recover cannot catch them β and both arrive before any stream key is checked. A count is now held to the bytes that follow it, and nesting to 32 levels.
- A video-only file could stop the server. Its synthesized silent track was sized from the file's own timing: a frame claiming 232 ticks at a timescale of one asked for eight terabytes of sample table. It is now held to the sample budget every real track has (about six hours of silence).
- A corrupt audio configuration took its channel off air. An MPEG-4 descriptor length read without its four-byte limit overflowed to a negative number and the parser indexed before its slice: a panic when the file aired.
- A sixteen-byte SPS stalled the engine for 17 seconds (four billion entries in the picture-order cycle, which the standard limits to 255), on the live feed's configuration and on every keyframe the HLS segmenter measures, with its lock held.
- The program-map rewriter panicked on a map, or a PAT, whose inner lengths ran past its end. Such a table is now passed through untouched.
- The engine's own muxer (used for its tables, gap packets and splice sections; the programme goes out through gomedia's) wrote 0xFF into the flags byte of its stuffing β which declares a PCR, an OPCR, a discontinuity and random access β left zeros after a short first video payload, gave a negative time a clock that did not match its PES timestamp, and wrapped an audio PES longer than 65,535 bytes.
Fixed — found by airing features together
- A receiver that joined during dead air never decoded the programme. Dead air (a gap no slate can cover) went out in the engine's own layout β the map on PID 0x100, video on 0x101 β and the programme in gomedia's, with the map on 0x200 and video on 0x100, both under table version 0. A receiver that tuned in during the gap kept the gap's map and read the programme's video as tables: no picture, no sound, for as long as it stayed connected. Dead air on SRT now sends the programme muxer's own tables, through the same map rewriter, with its clock on the programme's PCR PID; table continuity carries across the switch.
- A live take during a gap waited for the gap to end. The slate, gap filler and dead air gave way only to Take next and Jump; a take while the channel waited forty minutes for its next item went to air forty minutes later. It is now immediate, and the channel returns to its wait when the feed ends.
- A constant-rate stream burst on connect. The pacer paid the time before a receiver connected as one burst of padding: about 1.7 times the rate in the first second. It now restarts on connect.
- An avail that outlasted its item did not return during dead air β the return was checked only on samples, and dead air has none, so a splicer stayed in the avail until the next item. It now returns on time inside the gap.
- DVB channels had two clocks in a gap. The service emitter's PCR went out beside dead air's on the same PID with a different value, and it wrapped every 318 seconds (a 33-bit mask on the 27 MHz count). Its clock now goes out only while a new receiver waits for a keyframe; it wraps at the full range; and on a constant-rate stream it is synchronised to the programme when a receiver connects. Synchronised only at the item's start, it counted nothing while no receiver was connected, so a receiver joining six seconds into an item got a clock six seconds behind, which jumped at the first keyframe.
New — the tests
- 16 fuzz targets (list), two of them structure-aware β real MP4
files and traffic logs edited field by field, so mutation reaches the tables and the round trip rather than
stopping at the first header. Each checks the property its feature promises as well as survival, and fails an
input that takes over a second. Every failing input found is kept under
testdata/fuzz/and runs with the suite; small real media for the seeds is intestdata/mp4/. - 9 combination tests to a real SRT receiver, read by an independent stream reader and decoded by ffmpeg.
Verification
- Suite: 650 tests and 16 fuzz targets (main) and 10 tests (gpupool); 20 tests and all 16 fuzz targets are new. The full suite passes, and passes under the race detector with no race reported.
- Fuzzing: each target ran for one to five minutes during the round. Five found failing
inputs β 8 in all, each fixed β and they are kept under
testdata/fuzz/, where they run with the suite. - Mutations: 22 reintroduced defects, one per fix. 21 were caught first time. The miss β the DVB emitter's clock without the programme's bytes β passed because the test read only the clock that followed the first programme map. Read in full, the clock showed a real defect, a receiver joining mid-programme given a clock six seconds behind (the last of the Fixed items above). It is fixed, and the stronger test fails on the code before the fix, with or without that mutation, and when the fix is removed.
- Real binary, API: 21/21 β the 3.55.0 real-binary checks, run against the 3.56.0 binary: measured masters, the audit trail, exact as-run bytes, dead air in the renditions.
- Real binary, browser (Chromium): 9/9 β the 3.55.0 browser checks, run against the 3.56.0 binary. No real-binary run was written for this release's own changes; the combination tests cover them against a real SRT receiver. Not run: Firefox, WebKit, Safari, tvOS.
Known gaps
- The on-air muxer is not fuzzed. The programme leaves through gomedia's muxer, a library; this round's targets cover PlayoutGo's own code.
- No real-binary run for this release's own changes. The 3.55.0 real-binary checks were run against this binary; the 3.56.0 changes are covered by the combination tests against a real SRT receiver.
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg;
graphics on a real GPU node (the node drawing
overlaysand the logo); gpuffmp4mux; a real YouTube OAuth client; 3ABN's answers; a real A/53 master and the captions menu on Apple players; a key-management service behindsecrets_key_commandand a TPM-sealed credential; Firefox, WebKit, Safari and tvOS runs.
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
- Audio renditions through dead air (the master playlist). When no gap filler and no slate can cover a gap, each extra language's rendition now gets its own segment for that stretch β its one audio stream declared on the rendition's PIDs, the same clock, no frames β instead of naming the variant's segment, whose tables declare video and the main language.
RESOLUTIONon every variant (what the master states), read from the SPS: cropping, field coding and the chroma format's crop unit applied. Measured on real encodes: 320Γ240 Baseline, 1920Γ1080 (coded 1088), 854Γ480 (coded 864), 720Γ576 interlaced, 640Γ360 in 4:2:2 and 4:4:4.- Measured over the window:
CODECSis the most demanding profile and level in the segments still listed,RESOLUTIONthe largest. Until now the channel's first keyframe decidedCODECSfor the whole run β a channel that opened on the slate declared the slate. - The ABR master measured per rung: each rung's
BANDWIDTH(its peak, plus the top rung's largest audio rendition with extra languages),CODECSandRESOLUTIONcome from its own segments, and the master is rewritten when they change. The profile's figures stand in until a rung has a segment.
New — every change audited
(audited actions, the coverage check)
- 37 new actions, 109 in all. Channels:
channel.create,channel.update(the fields that changed, by name β never their values: a channel holds its SRT passphrase β compared as stored before and after, so a save of the console's whole form names only what it changed),channel.logo. Encoding nodes:node.add,node.update(whether the token was replaced, never the token),node.remove. App keys:appkey.create(the prefix, never the key),appkey.revoke. Invite codes:invite.create,invite.update,invite.delete(never the code), andsettings.require_invite_code. Delivery:settings.cdn_url,settings.encode_fallback,settings.local_gpu_slots(each with the old value),settings.user_cdn. Accounts:user.signup,user.create,user.update(e-mail, role and limit, old and new),user.delete,user.password_changed,auth.reset. Encoding:encode.start,encode.cancel,encode.dequeue,encode.reencode. The rundown:schedule.cleared,schedule.shuffled,schedule.bulk_timed,schedule.generated,schedule.imported,schedule.repeated,schedule.item_updated. Media:media.update,media.reimport,media.reprobe. Andbackup.createdfor a snapshot taken by hand. - The coverage check:
TestEveryStateChangeIsAuditedreads every request handler, and every branch of its switch on its own, and fails when one writes β to the database,config.json, a file, the encode queue or the engines, directly or through the package's functions β without callingrecordAuditthere. Four paths are listed with the reason they are not recorded (uploads, the audit trail itself, the QR preview, the app grid's thumbnail). Its own test proves it catches a new unaudited handler and an unaudited branch beside an audited one. - Through the real handlers, a test creates and edits a channel, an invite, a node, an app key, the CDN and an account, and reads every row back: the right actor and subject, and none of the eight secret values used anywhere in the trail.
Fixed
- As-run bytes moved between items. HLS bytes were counted when a segment closed, and the segment holding an item's last seconds closed during the next item: the first row of a run was short by up to a segment and every later row carried its predecessor's tail. Bytes are now counted as they are muxed. Measured: three airings of one 8 s clip record 187,060 bytes each. The output bitrate graph is smoother for the same reason.
- The wait for an anchored item sent bare null packets when no slate could be made β no tables, no clock, HLS segments with no stream in them. It now sends the same dead air as any other gap: tables, a running clock and padding.
- An SPS of the multiview and depth profiles (134, 135, 138, 139) was misread from the
chroma format on: they were missing from the list of profiles that carry it. This affected an RTMP feed's
picture size as well as the new
RESOLUTION. - 3.54.0's note that gap filler reached an audio rendition as the main language was wrong: filler and the slate air as samples and always reached every rendition. Only dead air named the variant's segment, now fixed (above).
- A test of the CDN opt-out depended on running after a test that replaced the database; it now opens its own.
Verification
- Suite: 630 tests (main) and 10 (gpupool), 14 of them new. The full suite passes, and passes under the race detector with no race reported.
- Mutations: 42 reintroduced defects, one per change and per fix. 40 were caught first time. The two misses: a dead-air rendition whose clock packets were flagged but empty passed because the test counted PCR packets instead of reading their values; and bare null packets on the anchored-wait path passed because the capture as a whole held the following item's tables and clock. Both tests now read the clock's values, segment by segment, and catch them.
- Real binary, API: 21/21 with real ffmpeg and ffprobe over HTTP β the measured
RESOLUTION,CODECSandBANDWIDTHof a two-language master, and of an ABR master encoded here (480Γ360 and 320Γ240, where the profile would have said 640Γ360 and 426Γ240); every audited change with its actor and subject, and none of the seven secret values used anywhere in the trail; three airings of one clip with the same bytes; and a second instance with no ffmpeg airing dead air with each rendition's own segments, then the item's Spanish. This run found that a channel sent back as the API returns it was recorded as an encode-profile change; the comparison now reads the profile, graphics and DVB settings by what they mean, and a test covers it. - Real binary, browser (Chromium): 9/9 β hls.js reads the masters' levels at their measured sizes and bitrates; a channel created and edited in the console's dialog, which sends every field, is recorded as
changed: description, and an unchanged save assaved with no change; Admin's audit list shows both; the manual's new sections render; no policy violation, page error or native dialog. Not run: Firefox, WebKit, Safari, tvOS; NVENC and NVDEC on a real card.
Known gaps
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg;
graphics on a real GPU node (the node drawing
overlaysand the logo); gpuffmp4mux; a real YouTube OAuth client; 3ABN's answers; a real A/53 master and the captions menu on Apple players; a key-management service behindsecrets_key_commandand a TPM-sealed credential; Firefox, WebKit, Safari and tvOS runs.
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
- A master playlist (the master playlist). For a channel with
more than one language or with captions,
index.m3u8is a master: oneEXT-X-MEDIAaudio rendition per language and one variant,main.m3u8. Until now the media playlist carried theEXT-X-MEDIAlines, which RFC 8216 allows only in a master, so players that follow it showed no audio menu. - Audio renditions: each extra language is an audio-only playlist
(
audioN.m3u8, segmentsaN-segβ¦.ts), cut with the variant. The main language is muxed in the variant and has no URI; the variant still carries every language. Measured: the Spanish rendition of a three-language channel carries 660 Hz and only that, and ffprobe's HLS client opening the master labels itspa. - Captions declared:
CLOSED-CAPTIONS(CEA-608 CC1) when any rundown item carries A/53 captions. CODECSread from the SPS and the ADTS header, andBANDWIDTHmeasured from the segments.- Compatibility: a single-language channel without captions is byte-for-byte unchanged,
and every URL works as before.
main.m3u8, the media playlist, is new on every channel.
New — ABR items in full
- Breaks and Break now go out during an ABR item, on SRT and on the same segment of every rung's playlist. Break now answers going out now during one; it used to queue.
- Every language on every rung, each routed by language; the master's audio group is the top rung's renditions, so a player keeps its language when it changes rung.
- Live takes and Take next cut in on the next keyframe. The feed reaches every variant playlist; before, the take waited for the item to end and the feed never reached the rungs. Measured: the feed on air 0.7 s after the take, its 1 kHz tone and white picture in both rungs.
- The rungs never stall: the variant set is built when the channel starts, and the rungs carry single-rendition items, filler, the slate and live feeds between ABR items.
- An ABR item now gets an as-run row and is marked on air.
main.m3u8on an ABR channel is a continuous single-variant stream (the top rung during ABR items), and the composite node is given it rather than the master.
New — encoding and security
- GPU decoding and scaling (decoding on the card):
local_gpu_decode, or Decode and scale on the card in Admin, adds-hwaccel cudaandscale_cudato local NVENC encodes. A file the card cannot decode is decoded by the processor instead, without failing the job or parking the card; a rung with the channel's QR code always decodes on the processor. Audited assettings.local_gpu_decode. - The keyring off the host (where the key comes from):
secrets_key_command(config.json only),PLAYOUTGO_SECRETS_KEY_FD,PLAYOUTGO_SECRETS_KEY(removed from the environment when read) or the systemd credentialplayoutgo-secrets-key. A configured source that fails stops the start; nothing is written to disk and no key is generated. Read once per process. - The manual's list of audited actions (Admin API): all 72, with
when each is written.
TestEveryAuditedActionIsDocumentedfails the build when an action is recorded but not listed, listed but recorded nowhere, or computed without saying which actions it can be.
Fixed
- ABR rungs drifted apart in time. One sample was read from each rung per step, so a rung
with more samples per second than the tallest β another audio track, or audio at a higher sample rate β fell
behind it: it lost the end of every item and placed its cues late, with different
DATERANGEids from the other rungs. Rungs are now read up to the tallest rung's decode time; the test requires the same audio coverage and the same cues on the same segments of every rung, with a 96 kHz rung among them. - Card numbers could name different cards. nvidia-smi numbers cards in PCI order and CUDA,
which
-gpuuses, fastest first. Every ffmpeg run that names a card now hasCUDA_DEVICE_ORDER=PCI_BUS_IDunless the operator set it. - A language tag containing a quote broke the
EXT-X-MEDIAattribute list; such a tag, or an empty one, is nowund. - A server with no encoding node waited two hours before encoding here. With
encode_fallbackset tolocal_cpuorlocal_gpuand no node configured, an empty node list read as "every node at capacity", so each job sat queued β 1st in line for a slot until the two-hour queue limit. With no node configured the fallback now applies at once. Found by this release's real-binary run. - The thumbnail and the CBR test now look only at a channel's variant segments, not its audio renditions' (no picture to take a frame from; a few kilobits that would read as the rate collapsing).
Verification
- Suite: 616 tests (main) and 10 (gpupool), 29 of them new. The full suite passes, and passes under the race detector with no race reported.
- Mutations: 47 reintroduced defects, one per change and per fix. 46 were caught first time. The miss (ABR rungs read one sample per step) passed because every rung in the test had as many samples per second as the first; the test now has a 96 kHz rung and compares audio coverage and cues per segment, and catches it.
- Real binary, API: 21/21 with real ffmpeg and ffprobe over HTTP and RTMP β the master and the Spanish rendition's 660 Hz; a single-language channel unchanged; a captioned upload declared CC1; an ABR ladder encoded here, with both avails, Break now and a live take on both rungs in both languages; GPU decoding audited; and a second instance taking its keyring from
secrets_key_commandand from a descriptor, and refusing to start when the command fails. - Real binary, browser (Chromium): 8/8 β hls.js reads the two-language, ABR and captioned masters as a player's menus would (audio tracks, levels in one audio group, the CC1 track); GPU decoding saved from Admin survives a reload; the audited-actions table renders; no policy violation, page error or native dialog. Browser proofs: local GPUs 17/17 (3 new) and every earlier proof. Not run: Firefox, WebKit, Safari, tvOS; NVENC and NVDEC on a real card.
Known gaps
- Gap filler in an audio rendition: while pre-muxed gap filler airs, an extra language's rendition names the variant's segment, whose first audio stream is the main language. Corrected in 3.55.0: this was true of dead air only; filler and the slate reached every rendition. Dead air was fixed in 3.55.0.
- The ABR master states
CODECSandBANDWIDTHfrom the encode profile, not measured from the rungs. - The audit trail does not record channel creation and edits, encoding nodes, app keys, invite codes, or the CDN URL, encode fallback and encodes per card.
- As-run HLS bytes are counted when a segment closes.
- Needs the operator: NVENC and NVDEC on real cards; NVENC filler on the production ffmpeg;
graphics on a real GPU node (the node drawing
overlaysand the logo); gpuffmp4mux; a real YouTube OAuth client; 3ABN's answers; a real A/53 master and the captions menu on Apple players; a key-management service behindsecrets_key_commandand a TPM-sealed credential; Firefox, WebKit, Safari and tvOS runs.
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
- Series rotation (series). A template block names a
series and a rule β
same,nextorcycleβ and the program is chosen when the day is built, from what the series aired last: on the days before (92 days of logs) and earlier the same day. A program's series is read from its house code (CR001622S β CR) unless set. Learning a template marks the blocks that follow their series in the log; the samples give CR next at 11:30, TDYC same three times, TTK same then next, RUP_BDV next three times β and not the numbered RUSS bumpers. With the day before in the database, a series' first airing is learned too. - Hour spots (hour and fixed spots). The profile's
hour_spotpattern βX{DOW}{HH}00Sfor ENG,IDX{DOW}{HH}00Sfor INTL, learned from the log β names the spot that opens each break ending on the hour. A generated ENG day now reproduces all 24, including the ID-type X050000S at 23:58:30, which the generator could not produce before. Codes matching the pattern no longer fill other breaks: until now the 24 spots were ordinary promos to the filler and could land in any break. - Fixed spots:
fixed_spotson a template airs a promo, filler or ID at an exact second, inside whichever break holds it. rotation_days(rotation): rotation reads that many days of logs back, and the promos that have not aired for longest come first. 0 or 1 is the behaviour before.- Flat anchors and filler programs (anchors): a flat block can start at an exact time; the time before it is filled exactly from promos, fillers and programs marked as fillers, and an overrun is reported and refused by the integrity check. Learning a flat log anchors programs that start on the hour (RUSS: 03:00, 06:00, 12:00, 18:00) and marks one-part programs of five minutes or less as fillers.
- The template editor (editor): a table of blocks β program, series, episode rule, breaks, ID, length or anchor β and fixed spots, with the day's coverage shown as you type and every problem named before anything is sent. Leaving with unsaved changes asks first. It replaces one line of text per block.
- The library shows each program's series and filler flag; the program dialog edits them; the profile
edits the hour-spot pattern and
rotation_days.
New — graphics
- Traffic graphics on the built-in frame (graphics &
branding). A composited channel on the node's own frame now sends the node the rating, daypart-logo and
E/I cues on air as
overlaysβ in the session spec, through/v1/composite/{id}/updateon every change (checked twice a second), and with every text update. Before, they were drawn only by the graphics page, which the built-in frame does not use, so they never reached air there. Drawing them needs a node that supports the field; see Known gaps. - The uploaded logo in the L-shape: with no
logo_url, the logo uploaded for the channel is used, served at/graphics/{id}/logoand named in the session spec and the graphics page. Uploading a new one rebuilds a running session with it.
Fixed
- A receiver that connected during the start-up slate never got the extra languages
without reconnecting: its stream was built before the first item fixed the language set. The stream is now
rebuilt when the set changes, under a new program-map version so the receiver re-reads it. Measured at a real
SRT receiver: the map goes from one audio stream to
eng, spa, fra, and an independent decoder hears 440, 660 and 880 Hz on the three streams. - Composited output waited for the first item boundary at start. It now serves on the session's first healthy check; recovery after a fallback still waits for a boundary.
- Break now said "going out now" when it was waiting β during gap filler, the slate, a live
feed, an ABR item, or between items. It now answers 202 queued with the reason, the console shows a
notice, and it is audited as
scte35.queued. - An avail started inside an open one replaced its end, so the first cue-out never got a
return. The open avail now returns first; on the wire four splices (out, in, out, in), and in HLS
CUE-INbefore the newCUE-OUTon the same segment. qr_cornerwas not validated; anything unknown silently meant bottom-right. It is now refused unless it is one of the four corners (or tl/tr/bl/br), and stored in full. A corner stored before keeps meaning bottom-right when the channel is saved for another reason.- A DVB service created without a
service_idgot 1 β the channel had no id yet. It is now numbered after the insert: channel id + 1, as an update always did. - The program-map version is bumped only when the map actually changes, and never back to the number a receiver already holds.
Verification
- Suite: 587 tests (main) and 10 (gpupool); the full suite passes, and passes under the race detector with no race reported. 19 tests are new, among them every sample day rebuilt with its hour spots, and a real SRT receiver that connected during the slate.
- Mutations: 46 reintroduced defects across the scheduler, the template editor, the graphics, the engine, the console and this manual. 45 were caught first time; the miss β a fixed spot's code allowed to fill other breaks, which the sample day happened not to need β now has its own test, which catches it.
- Real binary: 18/18 through the API against real ffmpeg, ffprobe and SRT and a recording stand-in composite node (a generated Wednesday with all 24 hour spots, the DVB service numbered on the wire, Break now queued then sent, the logo fetched by the node's address); 11/11 in Chromium (the template editor on the learned ENG day, the queued notice).
- Browser proofs (Chromium): traffic 50/50, Break now 8/8, and every earlier proof: dialogs 206/206, strict policy 38/38 and 21/21, fixes 17/17, accessibility 19/19, secret state 13/13, local GPUs 14/14, dialogs 14/14, bug layer 6/6.
- Not run: Firefox and WebKit (not installed); a real GPU composite node, so the drawing of
overlaysandlogo_urlon the built-in frame is verified only as far as what is sent to a recording stand-in node.
Known gaps
- Traffic graphics and the logo on the built-in frame need the node. PlayoutGo sends
overlaysandlogo_url; a node that does not draw them ignores them. Until gpuff draws them, traffic graphics reach air only on channels whose graphics are a page. (Needs the operator: a gpuff release.) - Series, hour spots and fillers follow 3ABN's samples, not 3ABN's word. The episode rule of a series' first airing needs the day before; the hour-spot codes for days other than the sample's Wednesday must be in the library; one-part programs of five minutes or less are learned as RUSS fillers. (Questions to 3ABN.)
- Multi-language HLS has no master playlist, and no
CLOSED-CAPTIONSdeclaration. (3.54.0) - Gaps while an ABR item airs. Scheduled breaks and Break now wait for the next single-rendition item (Break now now says so). Rungs carry only the main language. A live take waits for the item to end, and the variant playlists do not carry the live feed. (3.54.0)
- GPU decode and scale as an opt-in, and keyring off-host. (3.54.0)
- The manual's list of audited actions in the Admin API section is not complete. (3.54.0)
- HLS bytes in the as-run log are counted when a segment closes. Documented, not scheduled.
- Not verifiable here, and needs the operator: NVENC on real hardware, NVENC filler data on
the production ffmpeg, on-air graphics on a real GPU composite node, gpuff
mp4mux, a real YouTube OAuth client, 3ABN's answers (generated logs and airing numbers, undocumented fields, dayparts, DST, hour spots, episode rules, fillers), a real A/53 caption master, and the browser proofs in Firefox and WebKit.
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:
- A live insert put frames on air that a receiver could not decode, and the channel never returned when the feed stopped.
- DVB service information named a programme the receiver could not find.
- An SCTE-35 return was thrown away as a duplicate.
- A fully encoded ABR item sent SRT and the RTMP push nothing at all.
- Restarting a channel after a settings change could leave its new engine running where nothing could stop it.
All of these are fixed, and each fix is measured by a test that fails without it.
New
- Feed tokens.
GET /api/epg/feed-tokenissues a credential for/epg/and/m3uand nothing else. It does not expire, and it is revoked when the account's password changes. The EPG tab now builds its links with it. Those links used to carry the account token, so a URL pasted into a media server opened every API route, and the subscription stopped working after 72 hours (the three tokens). - Feature reference, roadmap and README.
- The reference lists 136 features by area, each with the version that introduced it and where it is described.
- The roadmap gives the two remaining implementation rounds and the items that need the operator.
- New sections: live insertion, ad breaks & SCTE-35, audio languages, captions, DVB service information, QR codes, graphics & branding and tokens.
- The configuration table now lists every key.
- ON-AIR changes run inside their question. This covers removing, moving, dragging, locking and unlocking the item on air, and removing every ad break from the rundown. A refusal is shown in the dialog that asked, which stays open to retry or cancel. Before, the dialog closed first and the refusal arrived as a toast. Removing a selection that includes the on-air item now asks one question, not two.
Fixed β live insertion
- The feed's sound was undecodable. FLV carries raw AAC, and it reached air without ADTS headers. Measured at an SRT receiver: no decodable live tone at all. It is now wrapped on the way in.
- The timeline ran away. The rebase used
base < 0as "not set yet". The offset is negative whenever the encoder's clock is behind the channel's, so the base was recomputed on every sample. About 19 frames of an 8-second insert aired. - A lost feed never returned to the schedule. The channel sat on dead air until someone pressed Return. Now, after 5 s with no media, it returns by itself and writes the audit entry
live.lost. - A live insert is in the as-run log. It gets a row labelled
LIVE β RTMP from <address>, with the reason the feed was lost. - Two encoders on one key were mixed into one feed. A second publisher is now refused while the first is sending, and replaces it once the first has stalled.
- Long publishes desynchronised after 4 h 39 min. A type-3 chunk after an extended timestamp carries the field too, and it was read as payload.
- The publishing point died on its first failed accept. It now retries with a back-off.
rtmp_port: -1showedrtmp://host:-1/β¦as if it would work. The dialog now says publishing is off.- The dialog escaped a reason twice, so "&" read "&".
- The live panel kept polling after the dialog closed.
- The New Channel dialog showed the previous channel's publish URL, which carries its stream key.
Fixed β SCTE-35 and HLS
- Every splice packet carried continuity counter 0, so a receiver discarded the return as a duplicate. ffprobe saw one splice packet for two.
- The program map lacked the
CUEIregistration descriptor SCTE 35 Β§8.1 requires, so ffprobe read the PID as unknown data. It is now declared, with acue_identifier_descriptor. - An avail that outlasted its item never got its return. The return is now sent on whatever airs when the avail ends.
- Break now went out before its PID was declared.
- A resumed item fired breaks that were already past, because elapsed time restarted at zero.
- A
bothchannel held every break until an SRT receiver connected, so HLS carried no cues. A new receiver now starts the declaration check again. - Playlists lacked
EXT-X-PROGRAM-DATE-TIME, which RFC 8216 requires besideEXT-X-DATERANGE. Each segment also now carries its own avail section and duration.
Fixed β DVB and constant rate
- The SDT named a service the receiver could not find. It said service id+1 in TS 1, while the PAT and PMT said programme 1 in TS 0. They now carry the service's own numbers. ffprobe names the programme.
- PCR packets went on the audio PID. They are now on the PID the PMT names, continuing its counter.
- SDT, TDT, TOT and NIT went out only in gaps and while a receiver waited for a keyframe. A receiver watching a programme saw two SDTs in five seconds, both before the picture began. Now it sees one every 500 ms.
- The constant-rate check ran against the stored profile, before the request's profile was applied. A save that raised the encoder bitrate past the pipe passed.
- A changed rate or DVB identity was saved and ignored until a manual restart. The channel now restarts.
- The padding summary was written but never logged. It is now logged when a channel stops.
Fixed β engine, encoding and audio
- Restarting a channel could orphan its new engine. A settings change restarts a channel with Stop then Start. The old engine's supervisor then removed whatever engine the channel had, which was the new one. That engine kept running on air where Stop could not reach it, and the next Start put a second engine on the same outputs. The supervisor now removes only its own engine.
- A fully encoded ABR item sent SRT and the RTMP push nothing. The YouTube preset is ABR, so a pushed channel went silent once Encode All finished. They now carry the tallest rung, and the master playlist stays the master.
- Audio was routed by track position, not language. A file whose tracks were ordered differently aired French on the Spanish stream.
- Local encodes and demo watermarking dropped every audio track but the first. Watermarking now carries captions explicitly.
- As-run "Sent" was the channel's running total. It was written every five seconds, so the second item's figure included the first's, and a short item read 0. It is now each item's own, with final figures written at the end.
- Retry on a running encode started a second encode of the same file. The guard existed only in an unreachable duplicate
case, and a string search for its message passed. - Pressing Re-encode twice queued every file twice.
- A QR address was checked only when an encode rendered it. A bad one saved fine, and every encode then went out with no code.
- The built-in L-shape colours were never sent to the node, and a page's refresh defaulted to 5 seconds instead of 300.
Fixed β the console
- New Channel after editing a channel carried that channel's values. Graphics page and URL, snapshot interval, composite node, shuffle, and the RTMP push address and key all came across, so a new channel could push to the last one's YouTube stream.
- A stopped composited channel was shown as FALLBACK.
- The channel cards went stale. The SRT and L-shape chips stayed as they were until an unrelated field changed, and bytes, uptime and the bitrate graph froze. The card is now rebuilt when anything it shows changes, and the live figures are patched in place.
- The EPG index linked
/m3uwithout a token, which answers 401. - Edit could fill the channel dialog from the grid's summary record. The grid caches a short form of each channel (a few fields of forty). For up to three seconds after any action that reloaded it, Edit filled the dialog from that summary: SRT host "undefined", no push address, no graphics, no QR code. Save then wrote those blanks over the channel. The dialog now always loads the full record from the server.
Security
- A playback token could write. The six-hour scoped token travels in player and preview URLs. It opened
/api/files/with the owner's full rights, so a leaked preview URL could delete or rename the file. It now reads only: GET and HEAD, otherwise 403. - File previews carried the account token in the
<video>URL. They now carry the scoped token, or nothing. - EPG and M3U links carried the account token. They now carry the feed token.
- The feeds trusted an account token's own claims for its 72 hours. They are now held to the same revocation as the API: a deleted account, a changed password or a changed role ends it.
- Signing in as another account on the same page kept the previous account's derived tokens.
Verification
- Suite. 568 tests in the main package and 10 in
gpupool: the full suite passes. The race detector over the whole suite is clean. - Mutation tests. 54 of the defects above were reintroduced one at a time. 53 were caught by the test written for them. The miss was the per-message owner guard, which the publish-level test never reaches; it now has its own test, which catches it.
- Old code, same checks.
- The new browser proof run against the console as it was before these fixes fails 10 of its 16 checks.
- The dialog proof run against the console before the ON-AIR change fails 11 of its 206 checks.
- The six new manual checks, run against the 3.51.0 manual, all fail, naming 23 specific errors.
- On the wire, before and after (the "after" figures are each test's own pass):
- Live insert: 0 s of the feed's tone decodable and about 19 frames of picture, becoming dark β white β dark picture with 440 Hz β 1 kHz β 440 Hz sound, and back to schedule unaided.
- DVB: the PAT said programme 1 in TS 0 while the SDT said 4660 in TS 21; now both say 4660 in TS 21, and ffprobe names "Tulix One". The SDT went from 2 in 5 s (none after the picture began) to 10. PCR moved from the audio PID to the video PID.
- ABR item over SRT: 0 bytes, now 125 video packets of the tallest rung in five seconds.
- As-run "Sent" for three equal items: 93,060 / 280,120 / 467,180 bytes (running totals), now 93,060 / 187,060 / 187,060.
- Real binary. The 3.52.0 build was run against real ffmpeg, ffprobe, SRT and RTMP. The API check passes 22 of 22: feed and playback tokens, a DVB service read by ffprobe over SRT, a live take with a refused second encoder, auto-return, the audit entry and the as-run row, HLS cue tags with PROGRAM-DATE-TIME, and revocation on password change. The browser check passes 11 of 11 in Chromium: EPG feed links, New Channel after Edit, and the Help tab's feature reference, with no policy violation, page error or native dialog.
- Browser proofs. In Chromium: fixes 17/17, every dialog 206/206, secret state 13/13. Firefox and WebKit are not installed here; those runs skip and say so.
Removed
renderLBarPNG,ffColoranduploadGraphicincomposite.go: no callers since the node draws the built-in bar.SetItemAudioCountanditemExtraCountinhlswriter.go: replaced by per-language routing. The SRT "spare copy" branch is gone for the same reason.- The field
scteWarnOnceand the stale "SCTE-35 reaches HLS only" warning. - The unreachable second
case "retry"inhandleEncodeJob. Its state guard moved into the reachable handler. - A 3.5 GiB memory-limit call in
mainthat the 4 GiB default immediately overrode.
Known gaps
- Traffic scheduler β moved to 3.53.0. Series rotation, fixed spots at fixed times (for example ENG X050000S at 23:58:30),
rotation_days, flat-template anchors and a structured template editor. This round's audit and fixes took its place, as announced at the start of the round. - Traffic bugs do not reach air on a built-in-frame channel. Only the generated graphics page draws them, and the node draws the built-in frame itself. (3.53.0)
- The uploaded channel logo is not used by the L-shape, which takes
logo_url. (3.53.0) - Multi-language HLS has no master playlist.
EXT-X-MEDIAis written in the media playlist, where some players ignore it. There is noCLOSED-CAPTIONSdeclaration. (3.54.0) - Gaps while an ABR item airs. Scheduled breaks and Break now wait for the next single-rendition item. Rungs carry only the main language. A live take waits for the item to end, and the variant playlists do not carry the live feed. (3.54.0)
- Extra-language streams missing on an early SRT receiver. A receiver that connected before the first file item (during a start-up slate) gets them only after it reconnects. (3.53.0)
- Composited output waits at first start. It begins only at the first item boundary after the node is healthy, not on the first healthy check. (3.53.0)
- Break now answers "going out now" when it will not. During gap filler, the slate, a live feed or an ABR item it waits. An avail started inside an open one replaces its end time instead of getting its own return. (3.53.0)
- Two small validation gaps.
qr_corneris not validated: anything unknown means bottom-right. A DVB service created without aservice_idgets 1. (3.53.0) - GPU decode and scale for this server's cards, as an opt-in, and keyring off-host. (3.54.0)
- HLS bytes in the as-run log are counted when a segment closes, so a segment spanning two items counts with the later one. This is documented, not scheduled.
- The manual's list of audited actions in the Admin API section is not complete.
- Not verifiable here, and needs the operator: NVENC on real hardware, NVENC filler data on the production ffmpeg, on-air graphics on a real GPU composite node, gpuff
mp4mux, a real YouTube OAuth client, 3ABN acceptance (undocumented fields, weekend dayparts, DST convention), a real A/53 caption master, and the browser proofs in Firefox and WebKit (only Chromium is installed).
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
- Each card
nvidia-smilists becomes an encode node,local://gpu/N, in the same pool as the remote nodes. It is picked by the same least-loaded rule and runs the same ladder: the same keyframe times, captions, the channel's QR code and, for a constant-rate channel, CBR (see Encoding on this server's own NVIDIA cards). - A card is used only after a one-frame NVENC test encode on that card succeeds
(
-gpu N). A card whose test failed is tested again after ten minutes, and the list is read every minute. - NVENC sessions held by other programs count against a card's slots.
- A card that fails in a way that points at the device is parked for five minutes. The file goes back
to the queue for another card or node. The attempt is listed as sent back to the queue, with the
reason, and that is a new final job state,
requeued. - Settings
local_gpus(auto,offor card numbers) andlocal_gpu_slots(default 2, at most 8) are inconfig.jsonand in Admin → Delivery & Encoding. A value saved in Admin takes effect at once. A bad value is refused, with the reason, before anything is written. - Admin lists each card's model, memory, driver and state, in the driver's own words when it failed. The nodes panel marks cards as this server.
- The CPU fallback (
encode_fallback) now shares the same encode path, so a channel that falls back no longer loses its QR code and its constant rate. - “Encode” on the Media tab, “Encode All” and the queue count the cards as capacity. A server with cards and no remote node is no longer told it has nothing to encode on.
Fixed
- [FIX] High — the stream viewer (
/viewer) had played nothing since 3.47.0. It loaded hls.js fromcdn.jsdelivr.net, which the 3.47.0 policy (H-06) refuses. It also read the session fromplayout_token, a key the console never writes (pt_tok), so it never listed channels for a signed-in operator either. It now uses the player this binary already ships, and the console's session. - [FIX] The EPG index put the request's
Hostheader, unescaped, into a link and into handler code. The requester controls that header. It is now escaped, and the Copy button reads the URL from a data attribute. The link text was also wrong: it read<slug>/index.m3u8, a playlist path, next to an XMLTV link. It now shows the feed's URL. - [FIX] Three confirmations where Cancel did not cancel.
- Importing into a rundown: “OK = replace · Cancel = append”, so backing out appended the file.
- Repeating a day: Cancel meant “add alongside”.
- Adding a watch folder: Cancel meant “copy instead of move”.
- [FIX] Testing or adding a node that was down never showed what to check. The node
test answers
{ok:false, error, hint}. The console tested forerrorfirst, so it showed a toast and stopped. That left the hint unread and “Save it anyway?” unreachable. Both now appear. - [FIX] A refused “Clear all times” left an undo level for a change that never happened (H-05 class). The undo level is now taken only once the server has agreed.
- [FIX] A job sent back to the queue held its node's slot until restart. The record
stayed “queued” with its node set, so each lost job left a phantom one on that node. It now
ends as
requeued, and the console treats it as finished. - [FIX] A small ladder with a QR code panicked the encode when the tallest rung was under 480p, and left the job saying “encoding” for ever. It now encodes without the code and says so in the log. A panic in a job now fails that job.
- [FIX] The action buttons of the encoding nodes and the watch folders sat outside their table, beside the row rather than in its last column. Each cell was itself a flex box, which stops a cell being a table cell. Found on the real-binary screenshot, and a static check now refuses it.
Security — U-11 and U-10 closed
- One policy for every route:
script-src 'self', with no'unsafe-inline'anywhere. That covers the viewer, the EPG index, the YouTube return page, the manual, the about page and both guides. Their script moved to/viewer.js,/epg-index.jsand/youtube-done.js. The sandboxed graphics page keeps its own policy. - No handler interpolates a value. The 63 that did now read it from a
data-attribute with$num,$stror$bool. These readers refuse an attribute that is missing or does not parse. The handler text is now the same on every row, and the budget is 0. Two elements the conversion left with a repeated attribute were found by a new test and fixed. - No native dialog. All 66 calls are now the console's own dialog:
- labelled and announced, with focus inside;
- where the dialog makes a change, its button makes it, so a refusal is shown inside the dialog with what was typed still there;
- a typed confirmation word (RESTORE, DELETE) and value ranges are checked in the dialog;
- a notice has one button.
window.confirm(…)is counted too. - Moving the confirmations into dialogs took the H-05 direct-call count from 64 to 39. One call was added deliberately: the node test's answer is a result to show, not a refusal.
Verification
- New browser proofs:
audit/all-dialogs-proof.mjs, 167 checks over 29 workflows plus import, add/test node and the refusal notices. Cancel sends nothing, the button sends exactly its request, a refusal stays in the open dialog, and no native dialog opens.audit/pages-strict-proof.mjs, 21 checks: every page under the real header, with a channel name that would run script if it were ever interpolated.audit/local-gpu-proof.mjs, 14 checks: Admin and the nodes panel for cards in every state.
csp-strict-proof.mjsgrew to 38 checks. - Local GPU tests run with stand-in
nvidia-smiandffmpegprograms. They cover detection, failures in the driver's words, the pool, foreign sessions, the per-rung arguments, parking and requeue, the restart path, the Admin settings and “off touches no card”. They also cover progress while a card encodes, and a cancel that stops ffmpeg within a second, records nothing and gives the session back. The CPU path's CBR is measured, at 1500 kb in each second against a 1500 kb target. A VBR control on the same source used 20–39 kb. - Every browser proof now runs under a deadline. The H-05 proof stubbed
window.confirm. Once the confirmations became in-page dialogs, it waited for ever, and the suite sat untilgo test's ten-minute panic, which names no test. A stuck proof now fails its own test within three minutes, and a test proves the deadline works. TestConsoleAgreesWhichEncodeStatesAreFinalties the console's list of finished job states to the server's.TestManualLinksResolvechecks that every in-page link in the manual has a target.- Mutation checks: 37 bugs reintroduced, 37 caught. One was first missed: ignoring
offwas masked in the pool, and showed only as cards being test-encoded when off.TestLocalGPUOffTouchesNoCardwas added for it. TestDestructiveActionsReportFailurelooked for the textr.errorin the channel and file deletes. They now refuse throughmutate()in the dialog. It accepts that shape, and still fails when the delete goes through a bareapi()call.
Removed
localGPUsReady(localgpu.go), written during this release and never called. References were checked. Admin's “still looking” state does that job.- The 3.50.0 viewer's inline script and its CDN reference. The EPG page's unused slug computation.
Known gaps
- NVENC on a real NVIDIA card is unverified. The detection, the pool, the parking and
the arguments are tested with stand-ins, and the encodes run on ffmpeg's software encoder. Not yet seen on
hardware:
- that
-c:v h264_nvenc -gpu N -preset p5 -rc cbrencodes on the named card; - that a card's session limit matches what
nvidia-smireports; - the throughput per card.
- that
- NVENC CBR has no filler data with this test environment's ffmpeg (6.1). Where this server's ffmpeg lists a filler option it is used, and Admin shows which. Without one, the rate is the encoder's own rate control and may dip below the target on a still picture.
- Decoding and scaling for a local encode run on this server's processor, which the
channels on air share. There is no CUDA decode or
scale_cudapath yet. - The browser proofs ran in Chromium only. Firefox and WebKit are not installed here. The proofs are written for all three and skip, naming what was not proven.
- Some confirmations still report a refusal by toast, after the dialog closes. These are the on-air warnings (“this item is ON AIR — move it anyway?”), the “Open Admin?” question, and “remove every ad break?”. The question comes before an operation that has its own failure handling (undo restore, partial counts, the ad-break note), so the refusal is reported there.
- A channel with neither CBR nor DVB tables still sends a new SRT receiver nothing until the first keyframe, up to one GOP. This is unchanged and intended.
- Carried forward:
- on-air graphics on a GPU node are unverified;
- whether 3ABN's automation accepts a generated log is untested;
- the traffic dayparts were learned from one Wednesday;
- the scheduler is basic (no next-episode rotation, station IDs only where a block ends, one day of rotation history, templates edited as text);
- a daylight-saving day loads by elapsed time only;
- YouTube OAuth against a real account and the gpuff
mp4muxissue cannot be verified here; - the keyring sits on the same host as the database.
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
- [FIX] High — a constant-rate SRT channel sent nothing to a new receiver until the next keyframe. After every connect, the engine holds back the programme until a keyframe, so the receiver's decoder starts on SPS/PPS. That gate returned before anything reached the output: no padding and no service tables. Measured at a real SRT receiver on 3.49.0, with a 12 Mb/s target and a ten-second GOP, it got nothing for seven seconds. Equipment that needs a constant rate (a modulator, an ASI card, a multiplexer slot) reads that as a lost input. Padding and the tables now flow while the picture waits: 11.95 Mb/s from the first second. The programme still starts on a keyframe, and the new test checks that the first video packet carries SPS or IDR.
- [FIX] The Help tab was blank, and so was every “?” button. They show the
manual in a frame. The baseline security headers added in 3.47.0 (M-05) sent every response,
including the manual, with
frame-ancestors 'none'andX-Frame-Options: DENY, so the browser refused the frame. The manual may now be framed by this origin only. The console itself still refuses every frame. - [FIX] SRT refusals could go unreported. Refusal summaries were printed only when a later refusal arrived after the five-second window. A receiver retrying with the wrong passphrase and then giving up was logged as one attempt. The summary is now printed when the window closes, and anything pending is printed when the listener stops.
- [FIX]
config.sample.jsonlacked five keys:rtmp_port,advertised_base_url,google_client_id,google_client_secretandgoogle_endpoint_base. The sample says it lists every key. A test now holds it to that. - [FIX] The media preview popup would have stayed on “Loading” under the new
policy. The popup inherits the console's policy, and it carried its own inline script. The console now
drives it, and as a side effect an error message is now inserted as text rather than through
innerHTML.
Security — U-11, the console's script
- The 5,700-line inline script moved to
static/app.js, the head script tostatic/boot.js, in the same load order. The code is unchanged except for the conversions listed here. - All 258 inline handlers became
data-on-<event>attributes, read by the newstatic/ui-dispatch.js, which parses them and never evaluates them (see The console's content policy).- 240 were a plain call and only changed their attribute name.
- Sixteen did more and became named functions, with the same behaviour: key tests, an eight-statement channel selector, the grid item's Alt-click, the ABR ladder's four inputs, and others.
- The media picker's two hover handlers became a CSS rule.
- Each handler is attached to its own element, so event order and
stopPropagation()behave as the inline attributes did. Markup rendered and clicked in the same moment is bound by a capture-phase listener, so it is never missed. - The handler list is fixed. A value naming anything else (
fetch,alert,document.cookie, a member call, an assignment) is refused and runs nothing. - The console and its scripts are served with
script-src 'self'. Every other route keeps the general policy for now. - The browser proofs now run under the policy the product sends, with a report endpoint, so any violation fails the proof. Before, they ran against a bare file server with no policy at all.
Correction
- The Known gaps of 3.47.0, 3.48.0 and 3.49.0 said “CBR over SRT still transmits nothing”. That was stale: 3.45.0 had fixed it. Measured at a receiver this release, a channel whose target is far above its programme gets its target rate. Those entries now carry a correction note. The defect that did remain, silence at connect, is the first fix above.
Removed
liveFeedByKey(rtmpin.go). It had no callers; references were checked.
New permanent checks
- Go tests:
TestCBROverSRTDeliversTheTargetRate: at a real receiver, the rate holds from second 1, and the stream starts on SPS/IDR.TestControlUIHasNoInlineCode,TestDeclaredHandlersAreExactlyTheOnesUsed,TestControlUIIsServedUnderTheStrictPolicy,TestTheConsoleCanFrameTheManual,TestSampleConfigListsEveryKey,TestRefusalLogNeverLosesARefusal,TestListenerReportsEveryRefusedCaller,TestControlUISourceAssemblesandTestChangelogEntryForThisVersionHasKnownGaps.
- The new browser proof
audit/csp-strict-proof.mjsruns 32 checks. It starts with a sighted control: the same injections run on a page with no policy, and are refused here. It then covers every handler form and argument, propagation andreturn false, twelve hostile strings through the dispatcher, every page and the Help tab, and the preview popup. - The static scanners and every test that read
index.htmlnow read the scripts it loads. The H-05 scanner still counts all 64 direct mutations, which shows it sees the moved code. - Each check was shown to fail when its bug is reintroduced: 19 mutations, 19 caught.
- Six existing static checks went blind, in whole or in part, when the script moved, and
none of them failed:
TestNoDanglingElementIDs,TestDestructiveActionsReportFailure,TestChannelResponseCarriesEveryFieldTheUIReads,TestUIHandlersCannotFailSilently,TestUIReadsOnlyElementsThatExistandTestUIHasNoUndefinedFunctions. Four were found by emptyingapp.jsand listing which checks still passed, and two in review. Each now fails when it cannot find the code it checks.
Known gaps
- U-11 part 2. The manual, about, viewer and guide pages still carry inline script
and are served with
'unsafe-inline'. On the console:- 63 handler values still interpolate a render-time value (numbers, or JSON string literals that the parser reads exactly); a budget stops the count growing;
- 66 native
prompt/confirm/alertdialogs remain.
- A channel with neither CBR nor DVB tables still sends a new SRT receiver nothing until the first keyframe, up to one GOP. This is unchanged and intended: nothing before the keyframe can be decoded, and the connection is kept alive by SRT keepalives.
- The browser proofs ran in Chromium only. Firefox and WebKit are not installed here. The proofs are written for all three and skip, naming what was not proven.
- Carried forward:
- on-air graphics on a GPU node are unverified;
- whether 3ABN's automation accepts a generated log is untested;
- the traffic dayparts were learned from one Wednesday;
- the scheduler is basic (no next-episode rotation, station IDs only where a block ends, one day of rotation history, templates edited as text);
- a daylight-saving day loads by elapsed time only;
- NVENC, YouTube OAuth against a real account and the gpuff
mp4muxissue cannot be verified here; - the keyring sits on the same host as the database.
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
- RPX import and export. Every field of the 696-character record is read and written; each of the three samples comes back byte-for-byte identical. A file with data in columns no sample uses is refused rather than trimmed, and so is a record whose two copies of its start time disagree. A file whose line endings were converted to LF in transit is accepted and exported as CRLF.
- Integrity check before a log may drive a channel:
- the day runs 00:00:00 to 24:00:00 with no gap or overlap;
- on grid channels, programs start on the quarter hour and fill whole quarter hours;
- graphics stay inside their program;
- the right channel's list number and outputs;
- a daylight-saving day is flagged.
- Release gate. Only a released log can be loaded; a second release for the day supersedes the first. An imported log with errors needs an acknowledgement and a reason, which are audited. A generated log with errors cannot be released at all.
- Load into Rundown, after a dry run:
- program starts are anchored, everything else is chained;
- every file is trimmed to its log length, so the rundown keeps the log's clock to the second;
- the day's pending items are replaced, and loading again replaces rather than doubles;
- the past is never loaded;
- it refuses while any house code has no playable file, and asks first on a daylight-saving day.
- House IDs on media. A file plays a code by its house ID or by its file name; Assign file⦠sets the ID.
- Learning from a log:
- the channel profile β codes, outputs, timings, station ID, logo dayparts;
- the library β 38 programs with their parts and breaks, and 92 promos, fillers and IDs from the English day;
- a day template, which can be assigned to a weekday.
- Scheduler. Builds a day from a template on the quarter-hour grid or back to back:
- parts with their mid-program breaks;
- every break filled to the second from active promos and fillers, with rotation;
- station IDs where the template asks;
- new airing numbers.
- Graphics by rule, reproducing all 197 graphics events in the samples:
- rating (TVG, TVPG, TVY7) for 15 s at the start of every part;
- logo from 1:30 in to 2:00 before the end, chosen by daypart;
- E/I label from 1:30 to the end of children's programs.
What the 3ABN logs taught
- Programs are split into parts (house codes ending A, B, C; S is a whole program), with breaks between them. Column 326 is C on the first part of a program and F on everything else β true of all 775 events.
- Every program block is exactly 15, 30 or 60 minutes on the quarter-hour grid.
- The logo is set by time of day, not by program: in English, ENG_6 from 00:00 to 08:00 and from 17:00, ENG_8 from 08:00 to 17:00.
- The program number in columns 88β94 identifies the airing: shared by its parts, different for each airing (3ABN Today airs four times with four numbers).
- Columns 2β5 are a per-channel list number; every time in the samples is in whole seconds.
Fixed β found while building this
- [FIX] High — SRT receivers got nothing during a slate gap. The black-and-silence slate that fills a wait before an anchored item was written to the HLS segmenter only. An SRT channel β every 3ABN channel β sent its receivers no packets for the whole gap. It now goes through the same output path as content: an SRT receiver got 113,364 bytes in 8 seconds of gap.
- [FIX] The operator could not take the next item during a slate wait. The slate loop never looked at the operator's request, so take next did nothing until the anchor arrived. The existing test for it fails on the 3.48.0 source in this build environment. The padding fallback always honoured the request; which of the two a gap uses depends on whether a slate could be generated.
- [FIX] A cleaned HLS directory lost the slate until restart. The slate was located once per process. When its directory on /dev/shm was emptied while the service ran, every later gap fell back to padding with no picture. It is now made again when its file has gone.
- [FIX] Restart positioning ignored trims. A channel restarted mid-way through trimmed items resumed the wrong item at the wrong offset. Every day loaded from a log trims.
- [FIX] A latent deadlock. The database's transaction helper took its write lock twice; its first caller would have stopped every write in the process. Nothing called it until now. The static check that should have caught it skipped that function; it no longer does.
- [FIX] The tabs were grey system buttons. Since 3.47.0 turned the tabs into real buttons (U-01), the browser painted every tab as a button face, and the selected tab's light label sat on light grey, unreadable. The accessibility proof now measures the rendered tab, not just the colour tokens.
New permanent checks
- 28 new Go tests, the three real logs as fixtures, and two new browser proofs (Chromium):
- the Traffic screen driven from hostile data (33 checks: nothing executes; every workflow sends exactly what it should);
- the graphics bug layer under the real sandbox policy (6 checks).
- The accessibility proof gained two checks for the tab regression.
- Each was shown to fail when its bug is reintroduced: 30 code mutations, 30 caught.
Known gaps
- Graphics on air are unverified. They need a composited channel on a GPU node, which the build environment does not have. The node's HTML capture path was already noted as returning blank frames (renderLBarPNG), so until it renders pages, graphics drawn by the page may not reach air there.
- Whether 3ABN's automation accepts a generated log is untested, including our airing
numbers. The meaning of
S\, PRNEMPL/OSVKPPL/PRNEPPL,1.00,00:00:00:00and0000is undocumented; they are carried per channel exactly as the samples have them. - Dayparts were learned from one Wednesday. ENG_8_E as the E/I label is an inference; weekends and other channels need 3ABN to confirm.
- The scheduler is basic:
- a template names a specific program, with no next-episode rotation;
- station IDs are placed only where a block ends (the English ID inside a break at 23:58:30 is not reproduced);
- rotation looks back one day;
- flat channels are filled only at the end of the day;
- templates are edited as text, one block per line.
- A daylight-saving day loads only by elapsed time from midnight, after the operator confirms.
- Carried forward:
- the CSP still needs
'unsafe-inline'(U-11); - CBR over SRT; [Correction, 3.50.0: this entry was stale. The bug was closed in 3.45.0; a receiver-side test now measures the rate. 3.50.0 fixed a separate silence at connect β see v3.50.0.]
- NVENC, YouTube OAuth, Firefox/WebKit and gpuff ABR
mp4muxcannot be verified here; - five config keys are missing from
config.sample.json; - SRT refusal logging is throttled;
- the keyring sits on the same host;
liveFeedByKeyis unused.
- the CSP still needs
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
- [SECURITY] Critical — SRT encryption was never enforced in listener mode.
A channel with a passphrase sent its programme unencrypted to any caller that brought no
passphrase, and refused callers that brought the right one. Reproduced on the shipped
3.47.0 binary with ffmpeg: no passphrase, 770,800 bytes of programme received in the clear; correct
passphrase, refused. The SRT library’s listener ignores the configured passphrase; encryption
has to be negotiated per connection when it is accepted, and nothing did. Now an encrypted channel
accepts only callers with its passphrase, refusing the rest with
REJ_UNSECUREorREJ_BADSECRET, and an unencrypted channel refuses a caller that asks for encryption. Verified on the wire with a UDP tap between ffmpeg and the server: with the right passphrase, none of 7,457 data packets contained a plaintext MPEG-TS payload and all 7,457 carried the encryption flag; the same tap on 3.47.0 found plaintext TS in 7,286 of 7,299. Caller mode was already correct and is now tested too. Receivers that connected without the passphrase will now be refused; give them the passphrase. - [SECURITY] High — every channel’s live-insertion stream key was
guessable. A channel with no stored key — every channel the interface has ever
created, since it offered no way to set one — accepted RTMP publishes on
ch1,ch2,ch3… Anyone who could reach port 1935 could occupy any channel’s contribution input by counting, so the real encoder was refused, and an operator taking “the feed that just arrived” mid-event would take theirs. New channels get a random 192-bit key (live_…). Existing channels keep their key, because changing it silently would break every encoder configured with it; they are named at startup, flagged withkey_predictablein the API and a warning in the live panel, and New key (POST /api/channels/{id}/live/rekey, refused while on air) replaces it in one step. Keys are compared in constant time. - [SECURITY] High — a stream key could keep working after it was replaced. A publish was checked first against a key map built when the server started. A snapshot restore swaps the database without touching that map, so a channel’s previous key went on publishing until the next restart. Every publish is now authorised against the database. Proven with ffmpeg against the real ingest: the replaced key is refused and the current one accepted.
- [SECURITY] A stream key the keyring cannot decrypt no longer falls back to the guessable
one. Found in this release’s own encryption before it shipped: an unreadable key
read as empty, and an empty key meant “accept
ch<id>”, so restoring a snapshot without its keyring would have re-opened every channel to anyone who could count. A locked key now accepts nothing; the API reportskey_locked, the live panel says so, and New key recovers. - [SECURITY] Stream keys are no longer written to the service log. The publish log line printed the full key, which logs keep and ship far more loosely than the database it is now encrypted in. It shows the first characters and the length.
- [FIX] High — clearing a day’s rundown across the DST change deleted the wrong programme. On 1 November 2026 the US falls back and 01:30 happens twice. Stored as local wall-clock text, 01:30 EDT and 01:30 EST (an hour later) both sorted inside a 01:00–01:45 EDT window, so clearing that window deleted both. This is the latent bug 3.47.0 described; it is now a test that fails on the old storage.
Secrets at rest
- [SECURITY] SRT passphrases, live stream keys, encoding-node tokens and YouTube refresh
tokens are encrypted with AES-256-GCM under a keyring kept outside the database
(
secrets_key_file, defaultsecrets.keybeside it, 0600). Each value is bound to its column, so ciphertext cannot be moved from one field into another. A database copy reveals none of them. Existing plaintext is encrypted on first start, and the log says how many values of each kind. See Secrets at rest for backup, restore and rotation. - [SECURITY] Overwritten plaintext is erased, not just superseded. Encrypting a
value in place leaves the old bytes in SQLite’s free pages; so does deleting a channel.
Checked on a real upgrade from 3.47.0: the passphrase of a channel deleted before the
upgrade was still in the file. The database is now rewritten once on upgrade and runs with
secure_delete, and after the upgrade no plaintext secret remains in the database, its WAL or its SHM. Snapshots taken by earlier releases still hold plaintext; startup names them. - A secret that cannot be decrypted is never treated as empty. If a snapshot is
restored without its key, an encrypted SRT passphrase reads as locked, not blank, because a
blank passphrase means “send unencrypted”. A channel with a locked passphrase refuses
to start and says why; saving unrelated settings keeps the stored ciphertext; the API reports
locked_secretsandtoken_locked. - [SECURITY] Application keys from before key hashing are hashed. Keys issued by older releases were still stored in plaintext; they are hashed on first start and keep working.
- [FIX] Rotating the JWT secret no longer breaks YouTube. Refresh tokens were encrypted under a key derived from the JWT secret, so following 3.47.0’s advice to rotate it after a leaked archive would have made every connected YouTube account unusable. They use the keyring now, and are re-encrypted from the old form on first start.
One meaning for every timestamp
- [FIX] Every time written to the database is canonical UTC
(
2026-11-01 05:30:00), enforced at the database driver boundary rather than call by call, so code written next year gets it right too. The driver used to store atime.Timeas local time with a zone name and Go’s monotonic clock reading attached. - Existing rows are migrated exactly — the four columns 3.47.0 left in the
old form (
playlist_items.scheduled_at,channels.started_at,channels.updated_at,audit_log.at) and the as-run rows 3.47.0 flagged. A safety copy is taken first (backups/playout-…-premigrate.db, restorable from Settings like any snapshot) and only when there is something to convert. A value in no known form is left untouched and logged. - Correction to 3.47.0. Its Known gaps said the original offset of those as-run
rows was “not recoverable from the data”. That was wrong: the stored text carries a
numeric offset (
-0400), so the instant is exact. The 3.47.0 entry below is amended.
Accessible dialogs (audit U-10)
- The user-management workflows use real dialogs. New user, edit user, delete
user, change password and suspend/reinstate were chains of
prompt()andconfirm(), which cannot be labelled, validated or styled. In New user, the final “Make this user an admin?” box used Cancel to mean regular user, so there was no way to back out at that point: Cancel created the account. Each is now one labelled form dialog with inline validation announced to screen readers, Escape and Cancel that genuinely cancel, contained focus, and focus returned on close. Deleting a user requires typing their email. The password minimum in the interface is tested against the server’s. Native dialogs in the interface fall from 79 to 66, and a budget stops them coming back.
New permanent checks
- 34 new Go tests: secrets sealed at rest, no secret bytes anywhere in the database file after an upgrade or a delete, locked secrets never starting unencrypted, rotation, column binding, keyring creation, every bound time canonical, the DST window, exact legacy migration, live-key entropy and rekey, a locked key accepting nothing, publish authorised by the database (driven with ffmpeg), keys kept out of the log, and SRT encryption in both modes — refusal, delivery, and ciphertext on the wire with a control that proves the tap can see plaintext. Two browser proofs (Chromium): the dialogs (14 checks) and the secret-state warnings and New key workflow (13 checks, each warning paired with a control state that must not warn). Each was verified to fail when its bug is reintroduced: 27 code mutations, 27 caught; the secret-state proof fails against a page with its warnings removed and against 3.47.0.
Upgrade actions
- Back up
secrets.keyseparately from your database snapshots. - Delete or secure snapshots taken before 3.48.0; they hold plaintext secrets. Startup lists them.
- Press New key in each channel’s live-insertion panel and update its contribution encoder. Startup lists the channels still on a guessable key.
- Give SRT receivers the passphrase. Listener-mode channels with a passphrase now refuse receivers without it.
- 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
- The keyring is a file on the same host. It protects every copy of the database — snapshots, backups, disk images — but not a compromise of the running server, which can read both. A hardware or external key store would be a separate change.
- Channels created before 3.48.0 keep their guessable live key until an operator presses New key. This is deliberate (changing it silently breaks encoders); they are flagged everywhere.
- Snapshots from earlier releases still contain plaintext secrets. They are named at startup, not deleted — deleting an operator’s backups unasked is worse.
- SRT refusal logging is throttled to one summary per five seconds; a refusal inside a window is reported with the next summary, not on its own.
- Five config keys are missing from
config.sample.json(google_client_id,google_client_secret,google_endpoint_base,advertised_base_url,rtmp_port). Found this release; pre-existing. - The interface still needs
'unsafe-inline'(audit U-11): 63 handlers interpolate a value and 66 native dialogs remain, both under budgets that only go down. - CBR over SRT still transmits nothing when the target rate exceeds the programme rate (OPEN-BUG-CBR-SRT). [Correction, 3.50.0: this entry was stale. The bug was closed in 3.45.0; a receiver-side test now measures the rate. 3.50.0 fixed a separate silence at connect β see v3.50.0.]
- Not verifiable in the build environment: NVENC and on-card CBR (no GPU);
YouTube OAuth against a real Google account (token storage and re-keying are tested, the live
exchange is not); Firefox and WebKit (browser proofs ran in Chromium only); the gpuff ABR
mp4muxissue (needs the failing source file).
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
- [SECURITY] Critical — every source release contained live secrets.
Each archive was built by running
tar czfover a working directory, soconfig.json(admin password and JWT secret),playout.dbwith its WAL and SHM journals, a database snapshot underbackups/, the flight-state files and a.gitdirectory all shipped to whoever received it — 35 files that should never have left the box. Packaging now works from an explicit allowlist (release/release-manifest.txt); anything not named there is not in the archive, and a deny sweep refuses to build at all if a secret-shaped file is staged anyway. A test opens the finished archive and fails the build on any denied member. If you received any source archive up to 3.46.0, rotate the JWT secret, the admin password, the recovery secret and every GPU-node bearer token, and invalidate existing sessions. Changing the JWT secret signs out every session, which is the point. - [SECURITY] Critical — tenant graphics HTML could take over an operator's
session.
/graphics/{id}.htmlserves a channel's branding for a compositor to render, unauthenticated and outside the security middleware, and operator-supplied markup was written to it verbatim astext/htmlon the control origin — the origin whoselocalStorageholds the session token. A tenant could store a script as graphics and read the token of any operator who opened that URL. Every graphics response now carries a CSPsandboxdirective withoutallow-same-origin, which puts the document in an opaque origin: storage access throws and the API is cross-origin. Verified in Chromium — the unprotected page hands over the token, the fixed one answers “SecurityError: the document is sandboxed and lacks the 'allow-same-origin' flag”. For structural rather than policy isolation, set the newgraphics_portand the control origin stops serving the path entirely. - [SECURITY] Critical — a filename could run code in the interface.
Media filenames and collection names were interpolated into JavaScript string literals inside
inline
onclickattributes. HTML escaping does not apply in that context: the parser decodes'back to an apostrophe before the handler is compiled, so a file calleda'+alert(document.cookie)+'.mp4became working code when someone clicked preview. Seven such sites existed (the audit found five). Values now travel asdata-*attributes with behaviour attached by one delegated listener. Verified with ten hostile names — quote break-outs, backslash escapes, pre-encoded entities, tag injection, U+2028/U+2029, a bidi override, a template-literal break-out and a script close — against both the old and the new markup: the old markup executes three of them, the new one executes none, and every name still displays and reaches the handler byte-for-byte.
Found by running the test suite, which the audit could not
- [FIX] High — the as-run log returned nothing outside UTC. A compliance
log that looks empty is the worst possible failure for it.
started_atwas filled in by SQLite'sCURRENT_TIMESTAMPas UTC text, whileended_atwas written from Go as atime.Time— which the driver stores as local time with a zone name and Go's monotonic clock reading attached (2026-10-02 16:11:10.887 -0400 EDT m=+0.030346254). The query compares those as text, so in Atlanta the stored value sorted four hours past the window's upper bound and matched nothing. Durations computed from the two columns were also wrong by the UTC offset. Both are now written in one canonical UTC form, and the window is bound the same way. Four as-run tests that fail in 3.46.0 pass. - [FIX] The race-detector suite could not run at all. It crashed with a SIGSEGV
inside the SQLite driver, which called
runtime.GOMAXPROCSduring initialisation. The driver is upgraded (1.29.9 to 1.38.0) andgo test -race ./...now completes clean. A CI gate that cannot run is not a gate.
Service boundaries
- [SECURITY] High — the database was world-readable. Mode 0644, with no
code setting or checking it, holding SRT passphrases, GPU-node bearer tokens, app API keys and
every password hash. The service now sets
umask 0077before opening the database, so the WAL and SHM journals SQLite creates are owner-only from birth, and tightens the data directory to 0700 and existing files to 0600 at startup, reporting anything it could not fix. - [SECURITY] High — request bodies were unbounded. The server read timeout is deliberately disabled so large uploads are not cut off, which left every route able to accept an unbounded body, or one delivered a byte a minute after the headers completed. Register, login and reset are reachable without credentials. Bodies are now capped and bounded in time by one middleware around the whole mux — 64 KiB on the unauthenticated surface, 1 MiB for ordinary API routes, 4 MiB where graphics HTML travels — with media upload keeping its own proportional deadline. A declared length over the limit is refused before a byte is read.
- [FIX] High — two concurrent restores could both proceed. The gate was read in the handler but not taken until after validation and a full pre-restore database copy, so two admin requests arriving seconds apart could both walk past the check and into the destructive swap together. Ownership is now taken atomically before any preparation; the loser gets 409.
- [SECURITY] High — the compositor could be aimed at your private network. Graphics and logo URLs accepted any http(s) address and were handed to a node that renders them in headless Chromium, so a tenant could point the renderer at loopback, RFC-1918, link-local or cloud-metadata and read the result off the video. The URLs are now held to the same SSRF controls the HLS proxy already used, with an optional operator allowlist.
- [SECURITY] High — the control plane fetched script from a CDN. If the
vendored player failed to load, the page wrote a
<script>tag pointing at cdnjs with no integrity attribute, and the CSP permitted it. That script would have executed on the origin holding the operator's session. A control plane fails closed: the fallback is gone, cdnjs is out of the CSP, and a broken install now says so.
Interface correctness
- [FIX] High — failed changes reported success. Fifteen mutation calls discarded the server's answer entirely, including every bulk schedule operation, undo, air-time lock/unlock and admin user edit/delete. A rejected change still updated the screen, consumed a level of undo history, cleared the selection and showed a confirmation — so the rundown could show air times that were never committed while the channel played something else. Mutations go through a helper that throws, and nothing is updated or confirmed until the server agrees. Verified in a browser: with every mutation refused, local state is untouched, undo history is intact, the selection is kept and the operator is told what did not save.
- [FIX] Lifecycle intent was editable through an ordinary save.
desired_runningwas part of the general channel model, so a settings save could arm a stopped channel to come up on the next restart, or disarm a running one so it silently stayed dark. Only start and stop change it now; the same applies tostatus,current_file_idandstarted_at, which the engine owns. - [FIX]
DELETE /api/channelsanswered 200. The method switch had no default, so an unsupported method fell through and net/http sent an empty success. It is 405 with anAllowheader. - [FIX] Channel creation pushed graphics for channel 0. The call was deferred before the insert, against a struct whose ID is never filled in, and fired on error paths for channels that were never created.
Browser policy
- Security headers now come from one place. They were applied route by route,
and the separately registered public pages —
/docs,/about,/viewer, the guides,/graphics/— had none at all. The whole mux is wrapped, so the route somebody adds next is covered too. - HSTS on HTTPS. Sent only when the connection really is encrypted — our
own TLS listener, or a forwarded header from a proxy the operator has declared trustworthy with the
new
trust_proxy_headers. Asserted wrongly it makes a host unreachable over plain HTTP for a year, so it is deliberately conservative and does not claim preload. - Wildcard CORS now behaves like a wildcard. The code returned “allowed”
for
*with a comment saying credentials were not offered, and then reflected the requesting origin and sentAccess-Control-Allow-Credentials: trueanyway. A wildcard sends the literal*and no credentials, and startup says plainly what that still permits for a bearer-token API.
Accessibility and small screens
- Tabs are real tabs. Main and auth navigation were clickable
<div>elements inside arole="tablist"with no tab roles, no selected state and no keyboard behaviour. They are buttons with rovingtabindexand arrow-key navigation, and they keep an accessible name below 1150px where the text label is hidden. - Dialogs are dialogs. The five modals had no role, no
aria-modal, no focus containment and no focus restoration — Tab walked straight out into the page behind and Escape did nothing. Focus now moves into the dialog on open (not onto the close button, so Enter does not mean cancel), is contained while it is open, Escape closes it, and focus returns to whatever opened it. - A visible focus ring. Focus styling existed for inputs and nothing else, so a
keyboard user had no idea where they were. Every interactive control has a
:focus-visiblering. - Readable secondary text. The muted text colour measured 4.12:1 on the page background and 2.77:1 on the lightest surface it was used on — below the 4.5:1 WCAG AA minimum, at 11–12px, in 141 places. The token now measures between 5.00:1 and 7.43:1 on every surface it appears on.
- The login card fits a phone. It was a hard 380px with no override, so a 320px viewport could not contain it.
- Reordering works from the keyboard. Moving one rundown item one place was drag-only. Each row has Move Up / Move Down, with the same on-air confirmation and undo snapshot as dragging, and focus follows the row that moved.
- Labels, autocomplete and small defects. Form labels are bound to their
controls, sign-in fields carry
autocompletehints, four elements carrying twotitleattributes (the second silently dropped by the parser) are fixed, everytarget="_blank"link carriesrel, and the password dialog no longer accepts a 6-character password while claiming to require 8 and being refused by the server.
Build and release engineering
- gofmt is clean — 35 files were unformatted — and
release/ci.shruns every gate the audit asked for: gofmt, vet, build, test, race, the frontend scanners, the browser proofs, the archive allowlist and a secret scan. - One version, everywhere. The root
VERSIONfile said 2.50.0 while the binary reported 3.46.0 — nine releases apart. It is generated from the runtime constant at package time and a test fails if any surface disagrees. - The browser harness runs anywhere. It imported Playwright from an absolute
path on one developer's machine and the archive had no
package.json. There is a pinned dependency and a lockfile, and Chromium is included alongside Firefox and WebKit. - New permanent checks, each for a bug class that has now reached production once: the archive allowlist, the inline-handler scanner, the mutation-result scanner, a page-load proof that catches code which parses but throws at load, the hostile-filename proof, the accessibility proof, the stored-timestamp scanner, and a concurrent-restore test. Each was verified to fail when the bug it covers is reintroduced.
Known gaps
- Four timestamp columns still hold the old local-time form
(
playlist_items.scheduled_at,channels.started_at,channels.updated_at,audit_log.at). They work today because both sides of every comparison use the same form; they are latent across a DST change. Fixing them changes what existing rows mean, so it needs a data migration, and getting that wrong moves every scheduled programme by the UTC offset. A test records the exact set and fails if it grows. - As-run rows written before this release keep their local-time
ended_at. The original offset is not recoverable from the data, so they are flagged rather than rewritten — silently editing a compliance log on a guess is worse than leaving it legible and wrong.
Correction (3.48.0): that was wrong. The stored form carries a numeric offset (2026-10-02 16:11:10.887 -0400 EDT m=+0.03), so the exact instant is recoverable, with no guess involved. 3.48.0 converts these rows exactly, after taking a safety copy — see its changelog entry. - App API keys are still stored in plaintext, and SRT passphrases and node tokens are recoverable from the database. The filesystem controls above close the local-read exposure; hashing and encryption change the data model and the admin interface.
- Firefox and WebKit are not installed in the build environment, so the browser proofs ran in Chromium only. They are written for all three and skip with a message naming what was not proven.
- The interface is still one 6,500-line file with inline handlers, so the CSP
still needs
'unsafe-inline'. 63 handlers interpolate a value; a budget stops that number growing. - CBR over SRT still transmits nothing when the target rate exceeds the programme rate (unchanged; see OPEN-BUG-CBR-SRT). [Correction, 3.50.0: this entry was stale. The bug was closed in 3.45.0; a receiver-side test now measures the rate. 3.50.0 fixed a separate silence at connect β see v3.50.0.] NVENC and on-card CBR remain unverifiable without a GPU, and YouTube OAuth is unverified against a real Google account.
v3.46.0 β A Durable Record of Encode All
Fixed
- “Encode All” left no trace. Retries were written to the audit trail and the bulk action β the one people actually press β was not. The job list is held in memory, keeps only the ten most recent finished jobs, and is empty after a restart, so an operator asked later whether a channel had been re-encoded had nothing to check. Each press now records what it did: “12 queued, 33 already encoded, 0 not ready β ABR 1080p@5000k Β· 720p@2800k”, against the channel and the account that pressed it.
Why a batch can vanish from the panel
Three reasons, all normal, worth knowing before hunting a fault:
- Nothing needed doing. Encode All skips files that already carry every rung
of the current profile, so a second press queues nothing and creates no jobs. The reply says
already_encoded, but a panel with no new rows looks the same as one where the button did not work. - Finished jobs are pruned to the ten most recent, every two minutes, so a batch that completed quickly is gone by the time anyone looks.
- The list does not survive a restart. The encoding still happened and the renditions are on disk; only the record of the jobs is lost.
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
- A channel with a constant rate transmitted nothing. The one left open last release. The pacer budgeted for every second since it started, so the gap between a channel starting and its first frame arriving β a file opening, a rendition being fetched β produced a budget for that whole gap. At 12 Mb/s that is megabytes of padding in a single synchronous write, and the engine stopped inside it: instrumentation showed exactly two samples written and the loop never returning. The channel reported itself running, wrote HLS segments, and put nothing on the transport output at all.
- A single padding burst is now bounded. The budget stays honest β a caller writing once a second still gets a second’s padding, or the rate would sag to the content rate β but no one write is large enough to stall the engine. Anything beyond the bound is dropped rather than carried: wire time that has passed cannot be filled later, and carrying it would keep every burst permanently large.
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
- π’ Break now on the Rundown page, beside the other manual controls, and
POST /api/channels/{id}/scte35behind it. An operator on air can signal an avail of any length without touching the rundown, on an item that has no scheduled breaks at all. - It goes out through the same code as a scheduled break β an immediate
insertion taking a different route would be a second implementation to keep correct. Verified on
a channel with no scheduled breaks:
#EXT-X-CUE-OUT:45.000with a real payload in the playlist, and asplice_inserton PID 0x0111 declared in the program map. - 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.
- The dialog says the programme keeps playing. An avail tells a downstream splicer it may insert β operators expect a break button to cut to black, and that is better learned from a prompt than on air.
v3.43.0 β A Bad Padding Write No Longer Kills the Output
Fixed
- One failed padding write used to destroy a channel’s SRT output for the whole
run. Padding is discretionary, but a failure called
clearOutput()β so a single bad interval left the channel transmitting nothing, with one line in the log and a status that still said running. The interval is now skipped and the next one tries again, with the warning rate-limited to once a minute.
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
- Pushing a multi-language channel sent noise. RTMP carries a single audio
stream, and the publisher routed on video-or-not: every language went into it interleaved. A
channel with English, Spanish, French and German pushed to YouTube would have arrived as four
soundtracks in one. Only the main track is sent now; the rest are counted and reported as
alt_audio_dropped, with a line explaining why, so an operator can see that one language is going rather than wondering.
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
- The SCTE-35 PID collided with audio on a channel carrying four languages. The muxer hands out PIDs in order β video 0x0100, then one per soundtrack β so a fourth language reaches 0x0104, which is exactly where splice information was nailed down. The program map would have declared SCTE-35 on a stream carrying German, and the sections would have ridden on top of the audio. It is now chosen clear of whatever the muxer assigned, and the declaration and the sections use the same number.
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
- The declared soundtracks changed between programmes. Languages were declared per item, so a rundown mixing one-, two- and three-track files advertised English/EspaΓ±ol, then English/FranΓ§ais/Deutsch, then nothing at all. A player builds its audio menu when it loads the playlist: a viewer who chose EspaΓ±ol found the entry gone two programmes later, and one refreshing during a single-language item saw no alternate audio at all. Found by watching the playlist across a mixed rundown rather than a single file.
- The set is now the channel’s, decided once from the whole rundown β every language any item carries, in first-seen order. Measured across a mixed rundown: English, EspaΓ±ol, FranΓ§ais and Deutsch, unchanged through every item boundary.
- A slot an item cannot fill carries the main soundtrack rather than silence. A declared track that goes quiet for a programme is a viewer hearing nothing and assuming the channel is broken, and some players stall on a stream whose packets simply stop. Verified by decoding all four: on a two-language item, English reads 441 Hz, EspaΓ±ol 886 Hz, and the two unfilled slots carry 441 Hz β audio, not silence.
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
/guide/deployβ putting this into production without having written it. A system user and directory layout, the configuration fields worth setting deliberately, a hardened systemd unit that binds 443 without running as root, the ports and which to restrict, what to watch, what to copy off the box, and how to upgrade.- A troubleshooting table that maps a symptom to the one place worth looking, ending with the standard this project has been held to: every refusal names what failed and what to do about it; if one does not, that is a bug worth reporting.
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
GET /healthzfor whatever puts this instance in and out of rotation. Metrics already existed and are thorough, but a load balancer cannot use them: they are IP-allowlisted, they are a page of text, and computing them costs more than a check running every two seconds should. This is cheap, unauthenticated, and answers one question β can the database be reached, because a process running without it looks alive from outside and serves nothing.- Documented that
/metricsis off untilmetrics_allow_fromis set, and answers 404 until then. That is deliberate, but it also meant the endpoint could be missed entirely β as it was here, until the route was read rather than the response.
Measured
- No memory leak. Four channels looping for four and a half minutes: resident memory oscillated between 73 and 125 MB β the collector’s sawtooth, not growth β with the thread count flat at nine and all four channels still delivering at the end.
v3.37.0 β Does It Play, and How Many Fit
Measured
- A real client plays the output cleanly. Forty seconds of a live channel consumed over HTTP across two different rundown items: 998 video packets over exactly 40.0 seconds, no decoder complaints and no gap over 200 ms. Structural checks never answered whether it plays β a stream that decodes but stutters at every segment boundary passes all of them and is unwatchable.
- Capacity on one machine. Eight channels on air: 197 MB resident, 10.3% of one core, all eight writing fresh segments β about 17 MB and 1.3% of a core each. Extrapolations, caveats and what the figure excludes are in the manual under Capacity, Measured.
v3.36.0 β An Adversarial Pass
Verified
- 35 adversarial checks, all passing, committed as
audit/security-audit.py. Path traversal at every point a name or id from a request reaches the filesystem β backup download, HLS paths, the graphics page, file streaming β with encoded, doubled and Windows-style separators. Authentication against an unsignedalg:nonetoken, a forged admin token, a tampered signature and SQL in the login. Injection through search parameters and channel names. Body-size and id-count limits. And that the signing secret and encode-node tokens never come back over HTTP. - Header injection and request smuggling are exercised over a raw socket: a
client library refuses to send a header containing CRLF, so testing through one tests the client
rather than the server. A 20-kilobyte path, a
Content-LengthandTransfer-Encodingsent together, and an injected header were all handled β 404, 401, and not echoed, with the server still serving afterwards.
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
- A backup can actually be restored. Taking one was tested; restoring one never was β and a backup that cannot be restored is worse than none, because it buys confidence that is not there. The audit now takes a snapshot, deletes a channel, checks an unconfirmed restore is refused, runs a confirmed one, and confirms the channel returns with its settings and that the session survives. Six checks, all passing.
- Channels resume after a restart. Three on air at once, the server stopped and started: all three came back and were writing fresh segments within twenty seconds. Checked by segment age on disk, not by the status field β a channel that reports “running” and writes nothing is exactly the failure worth catching.
- Three concurrent channels each held their own output directory and rate. Concurrency had never been exercised either.
Audit totals: 61 + 43 API checks, 35 in each browser.
v3.34.0 β Closed Captions, Finally Tested
Verified
- Caption passthrough works, and has now actually been exercised. The claim stood untested for a simple reason: no file on hand carried captions, and ffmpeg will only embed A/53 data its input already has β it will not author it from a subtitle file. So the caption data is now built here: an H.264 SEI of type 4 carrying the GA94 identifier and 608 pairs, injected before every keyframe, wrapped back into a master.
- Put on air, the probe reports
has_captionsand every HLS segment carries the A/53 message. The audit checks both on every run, so the claim stays true.
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
- HLS carries every soundtrack. Each language gets its own stream inside the
segments, and the playlist declares them so a player can name them in its audio menu:
#EXT-X-MEDIA:TYPE=AUDIO,NAME="English",LANGUAGE="eng",DEFAULT=YESand the same for each alternate. The URI-less form is used deliberately β the tracks are muxed into these very segments, and pointing at a URI we do not serve would give a player an audio menu whose entries fail to load. - Languages are named where a viewer would recognise them β English, EspaΓ±ol, FranΓ§ais, α₯αα αα£αα β and anything else keeps its code, which is more useful than a wrong name.
Fixed
- A second language reached air as noise. The first audio track is wrapped in ADTS on the way out and the extra tracks were not, so they arrived as raw AAC where every consumer expects ADTS. Two audio streams on the wire, one of them rubbish, and nothing reporting a fault β the stream looked right in every count and playlist.
- Both languages were being written to one HLS stream. The segmenter had a single audio PID, so the transport output carried them separately and HLS mixed them together β a regression from the previous release, found by decoding the audio rather than counting the streams.
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
- Every audio track a file carries now reaches air. Extra tracks were read, counted and shown in the interface, then dropped with a log line saying so. Each now becomes its own stream on the transport output, interleaved beside the picture rather than arriving in a burst at the end.
- Each stream is labelled with its language in the program map, as an ISO-639
descriptor read from the file’s own
mdhd. Without it a receiver shows two identical “Audio” entries and the viewer picks blind. A file that declares no language is carried asundβ undetermined is not the same as English, and relabelling it would be a guess presented as fact.
Fixed
- SCTE-35 was declared in the program map on channels with no ad breaks, once the map was being rewritten for languages. Declaring a stream that carries nothing tells a receiver to wait for splice information that will never arrive.
TestQueueMessagesCarryPositionAndETAfailed under load and passed alone: it sampled the queue before the second job was enqueued. It now waits for the state it asserts. A test that fails at random is worse than no test β it teaches you to ignore a red suite.
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
- Connect YouTube in the channel dialog. Authorise once on Google’s own
pages and PlayoutGo creates the broadcast and the stream itself, binds them, and puts the ingest
address on the channel β no stream key is ever typed. It becomes the same
rtmp_push_urlthe publisher already takes, so nothing downstream changed. - Broadcasts default to unlisted: one created by mistake should not be announced to a channel’s subscribers.
- The refresh token is encrypted with AES-GCM under a key derived from the server secret, which is not in the database β a copy of the database alone yields nothing. The access token lives in memory and is refreshed a minute early, because one that lapses between the check and the call fails a live operation for no reason. Neither is ever returned or logged.
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
- Adding a watch folder that was already watched answered “could not save the watch folder”, which sends an operator looking at permissions and disks for something the interface already knew. It now says the folder is already being watched.
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
- A step-by-step guide at
/guide/push, linked from the channel dialog itself β “How do I find my stream key?”. It covers the one thing that trips everybody: YouTube gives you a Stream URL and a Stream key in two boxes, and PlayoutGo takes one address with a slash between them. Also what each failure means β connection refused, a refused key, drops climbing, retries widening β and why a small drop count at start-up is normal.
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
- A push target would not stick. The channel response is a hand-built map and
rtmp_push_urlwas not in it, so the value saved correctly, came back empty when the dialog reopened, and was erased by the next save of that dialog. No error at any point β the setting simply would not stay. - The watch folder list dropped its targeting. Channel, collection and “add to rundown” were stored and never returned, so a drop box that feeds a channel looked identical to one that just imports.
- The API accepted a push address that was not RTMP. The interface checked it as you typed; the server did not, so anything that is not the interface could store nonsense and the publisher would retry it for ever.
Checks added
- Every field the interface reads off a channel must appear in the response. Proven by removing
rtmp_push_urlagain and watching the check fail. - A second audit β 37 checks over tenant isolation, user management, backup and download, watch folder editing, schedule mutation, deletion cleanup, and the paths that only run when something goes wrong. Notably: an operator cannot see, read, rename, delete, start, stream from or schedule into another account’s channel β seven separate checks, all passing.
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
- RTMP publishing. A channel can send itself to YouTube, Facebook, Twitch or anything else that takes an address and a stream key. Written against the standard library, the same as the inbound side: handshake, chunk stream, AMF0, and the engine’s own samples wrapped as FLV tags. It takes the frames that already go to SRT and HLS, so there is one quality to reason about rather than two.
- A YouTube quality preset β 1080p 4500k, 720p 2500k, 480p 1000k, AAC 128k, two-second GOP, from their own live encoder guidance.
GET /api/channels/{id}/pushreports connected state, frames, drops, rate and retries. The stream key never appears β not in the response, not in a log line.- The push never slows the channel: a service that cannot keep up gets frames dropped and counted, because a live push that buffers falls further behind every second.
Fixed while building it
- The publisher chunked at 4096 bytes without sending
SetChunkSizefirst. RTMP defaults to 128, so the far end desynced and reported a packet size mismatch β the connection looked like a network fault and was a protocol one. - Redaction of the stream key ran the address back through a URL serialiser, so logs showed
%E2%80%A6instead of an ellipsis.
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
- A channel could exceed its constant bitrate in silence. Padding only ever adds; it cannot reduce a programme that is already above the target. A 4 Mb/s channel playing a 6.5 Mb/s file transmits at 6.75 Mb/s β over the pipe it was configured to fit, with nothing said. It now names both rates in the log and points at the remedy: encode the files at or below the target.
- A QR code on a channel that plays originals did nothing. The code is burned in when a file is encoded, so a channel that never encodes can never show one. The dialog now says that instead of rendering a preview for a code that will never reach air.
Added
- “Constant Bitrate β Two Different Things” in the manual: the channel’s transport rate, which PlayoutGo paces itself, versus encoding a file at a constant rate, which the node does. They belong together β either alone leaves a gap.
v3.26.0 β SCTE-35 Declared Where a Splicer Reads It
Fixed
- The program map never mentioned the SCTE-35 stream. The splice sections were being transmitted correctly on PID 0x104 all along β a raw capture proves it β but the PMT a receiver reads declared only H.264 and AAC, so nothing downstream had a reason to look at the PID. The declaration is now added to that map as it leaves. ffprobe lists PID 0x104 as a third stream where it previously saw two.
- The cause was a PID collision, and it is worth recording. The engine’s own muxer builds a PMT that declares SCTE-35 perfectly well β on PID 0x0100, which is the SRT muxer’s video PID. Anything it wrote there would land inside the picture. The programme’s real map is on 0x0200 and is built by the muxer that carries the video, which knows nothing about splice information.
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
- HLS ad signalling works. An ad break on a rundown item produces
#EXT-X-CUE-OUT:10.000and#EXT-X-DATERANGEwith a real payload β decoded and checked: splice command type 0x05 (splice_insert), duration 0x0DBBA0 = 900 000 ticks = exactly ten seconds. That is what most SSAI systems read.
Found β and stated rather than papered over
- The SRT transport stream carries no SCTE-35. Captured 25 MB of the SRT output across a live ad break: zero packets on PID 0x104, and the PMT declares only H.264 and AAC. The declaration is set on one muxer while the SRT output is built by another β the same split that once left the CBR pacer on a path the programme never took. The library behind the SRT output has no section-based private stream, so this is not a flag to flip; it needs the PMT rewritten and sections injected alongside.
- Until then a channel with ad breaks and SRT output says so in the log when it starts: the markers are in the HLS playlist only, and a splicer reading in-band SCTE-35 will not see them. An operator needs that before air, not after.
v3.24.0 β Proving the Node Does the Work
Verified end to end against gpuff 1.8.3
- The QR code is burned in by the node, not here. A channel carrying a QR address was encoded through gpuff and the node received the rendered image: only the bottom-right quadrant of the output has any bright pixels (YMAX 232) β the other three are pure black. gpuff has no endpoint named after QR codes and needs none; the code travels as an ordinary image overlay with a position and a scale.
- The L-shape is composited by the node. A session on gpuff 1.8.3 produced bottom bar luma 39, right column 34, programme 125, with every layout field echoed back.
Added
- The audit now checks that the rendered code actually reaches the node β it watches the node’s work directory for the overlay rather than trusting that the field was set. Asking “did the endpoint answer” would have passed on a build that sent nothing.
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
- “What PlayoutGo Sends an Encode Node”, answering two questions that keep coming up. There is no “QR API” on the node and there does not need to be: PlayoutGo renders the code itself and sends it as an ordinary image overlay with a position and a scale. The node never learns it is a QR code β which is why the thing being burned in can change without the node needing a release.
- The L-shape contract in full, including which fields are sent when, and why the
graphicsblock is sent only when an operator supplied a page.
Verified against gpuff 1.8.3
- QR overlay: a white test image on a black source encodes with the corner at luma 235 β on the picture. The scale is relative to the overlay’s own height, not the frame’s, which is worth knowing when a badge looks smaller than expected.
- L-shape: bottom bar 39, right column 34, programme 125, with every layout field echoed back on the session.
v3.22.0 β A Functional Audit, and Two Faults It Found
Fixed
- The audio sample rate was never recorded. The probe never asked ffprobe for
sample_rateβ it returns exactly the entries named, so parsing a field that was never requested read nothing. Every track reported 0 Hz and theaudio_hzsent to encode nodes was worthless. The guard written for that feature checked the source text rather than the behaviour, which is how it shipped; it now asserts the probe requests the field. - Opening a channel that was not in the cached list did nothing at all.
if (!ch) returnβ no dialog, no message, no error. A channel made in another tab, or a stale list, and the Edit button was simply inert. It now fetches the channel, and says so if it has genuinely been deleted.
Added β the audit itself
audit/api-audit.pyβ 57 checks across auth, media, collections, channels, rundown, playout, live insertion, guide, encoding, QR, graphics, admin, as-run, stats and robustness. Each asserts on what came back: that a reorder changed the order, that a probe reported dimensions, that a live take with nothing arriving is refused with a reason.audit/ui-audit.mjsβ 34 checks over every page, dialog and control, run in both Firefox and WebKit.- Both build their own fixtures. An audit that passes only against a warm database is not an audit β three of the failures in the first run were exactly that.
Result: API 57/57, Firefox 34/34, WebKit 34/34, no JavaScript errors.
v3.21.0 β Live Insertion, In and Out
Fixed
- A take waited for the current item to finish. The engine only looked for a live feed between items, so cutting to a truck during a ninety-minute film would have taken ninety minutes. It now interrupts the item on the next keyframe β measured at seven seconds into a twenty-second item, where before it waited for the item to end.
- Handing back left the channel dead. The live path never advanced the channel’s timeline, so when the schedule resumed it emitted timestamps from before the cut β time ran backwards by the length of the insert and the segmenter never closed another segment. The output stopped at the moment the operator returned to programme.
- Returning took five seconds. Releasing the feed stops samples arriving, and the loop only noticed a hand-back between samples β so it ended on the idle timeout rather than on the operator’s command. It is now immediate.
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
- Every encode job now carries the source’s audio sample rate. A node
whose own probe cannot read it from an unusual bitstream fails to mux β
“mp4mux: audio stream 1 has no sample rate” β while the same file encodes
perfectly in single-rendition mode. We probe every file on the way in, so the figure was known
here the whole time and there is no reason to make the node rediscover it. Sent as
audio_hz; a node that does not read the field simply ignores it. - The media probe now records each audio track’s sample rate alongside its codec, language and channel count.
v3.19.0 β Collections, and Drop Boxes That Feed a Channel
Added
- 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 collection is a label: the file stays where it is, and moving it between collections moves no bytes.
- The Media page gains a filter bar with a chip per collection and a count, plus All and Ungrouped. Each file shows its label, and clicking the label filters to it.
- Labelling in bulk. Tick the files, type a name, apply. Importing two hundred clips and labelling them one at a time is not a workflow anybody would use.
- Renaming a collection renames it everywhere, and renaming onto an existing name merges the two. Renaming a label must not mean re-labelling two hundred files by hand.
- A watch folder can feed a channel. Point one at a channel and anything landing there is imported, labelled with the folder’s collection, and added to that channel’s rundown β after it probes as playable, because adding first and discovering the file is broken second is how a channel ends up with a gap on air.
Fixed while building it
- Watch folders existed as two separate types, one for the config file and one for the database. The targeting was added to one, so the API silently dropped it on the other.
- The poller rebuilt its folder struct by hand from three of six fields, which discarded the channel and collection on the way to the importer.
- The collection column was added to every SELECT and not to the INSERT, so it read back correctly and was never written.
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
- API Β· Worked Examples. Six complete workflows β putting a file on air from nothing, cutting to a live feed and back, encoding a rundown to an ABR ladder, holding a constant bitrate for a modulator and proving it, L-shape graphics with a headline changed on air, and the guide for partners. Each one is the request as you would send it and the answer as the server gives it, with a note on what to expect and where it commonly surprises you.
- An error table that says what to do rather than what went wrong: what 400, 401, 403, 404, 405, 409 and 202 each mean here, and the action for each.
- Public endpoints documented β
/graphics/{id}.html,/docsand/aboutβ including why each is served without a token, which is the question anyone reviewing the surface will ask first.
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
- A stream kept playing after you navigated away. Hiding a page does not pause the media inside it, so a channel opened in the HLS player carried on into the room β audible, invisible, and with no control on screen to stop it. Leaving a page now pauses whatever it was playing: the HLS player, the rundown preview, and any other media element left running. Staying on the page leaves it alone.
Added
- An overview page at
/aboutβ what the system is, for someone who has not used it. Self-contained in one file, so the same markup can be saved and sent to somebody who has no server to run it on. Checked at desktop and phone widths with no horizontal overflow.
v3.16.0 β The Live Cut Reaches Air, and Safari Is Checked
Fixed
- Taking a live feed stopped the channel’s output. The feed arrived perfectly, the engine reported it on air β and not one segment was written from the moment of the cut. Two causes, both in the samples handed to the segmenter: they carried no duration, which is what a segmenter adds up to decide where a segment ends; and their timestamps were rebased to zero, sending the channel’s clock backwards by however long it had been on air. The feed now continues the channel’s timeline and each sample carries the gap to the one before it.
- Proven with pictures, not logs. Before the cut the output measured luma 125.6 / chroma 126.8 (the file); after it, luma 78 / chroma 187 β the blue live feed, on air, in the channel’s own HLS output.
Verified
- Safari. Every page, the channel dialog and the QR preview now run under WebKit β Safari’s engine β as well as Firefox: 12 checks, no JavaScript errors. Until now every browser check here had run in one engine only.
v3.15.0 β L-shape Works, and the Live Buttons Were Dead
Fixed
- Live insertion’s controls did nothing at all. Every one of the five
functions behind them read
cm-idfor the channel β an element that has never existed in the markup.$v()returned an empty string, the caller parsed it to 0, and each function returned early. Take, Return and the status poll were dead from the day they shipped, silently, because reading a missing element is not an error. They resolve the channel properly now: verified against a live RTMP feed reading “ready β 1280×720 + audio, 6.6 Mb/s” with Take enabled. - The constant-bitrate warning never warned. It compared the configured rate
against
cm-video-kandcm-audio-k, which are calledcm-enc-vandcm-enc-aβ so a rate too low to carry the encoder was accepted in silence and underran on air.
Verified
- L-shape compositing works end to end against gpuff 1.0.4. A PlayoutGo
channel with its own colours drove a session and the furniture is on the picture: bottom bar
luma 36.5, right column 35, programme 118.5.
gpuff also echoes
bar_backgroundandbar_accentback on the session now, so what a node accepted can be read rather than assumed.
Checks added
- Reading an element that does not exist now fails the build. It is the quietest fault in the interface β no error, no log, the control simply does nothing β and it had killed an entire feature.
v3.14.0 β The QR Preview Never Worked, and Warnings Now Say What To Do
Fixed
- The QR code preview failed for every address ever entered. An
<img>cannot send an Authorization header, and the token this appended to the query came from an endpoint that does not exist β so every request was refused and the panel said “that address could not be encoded” about addresses that encode perfectly well. It is fetched properly now and handed to the image as a blob. Typinghttps://tulix.comrenders a code. - A pasted
HTTPS://is accepted. Refusing it was a lecture about typography, not a check. - “1 channel need attention” β needs.
Added
- Every channel warning now says what to do about it. “No HLS segment for 2341s” is a symptom; underneath it now sits the cause and the remedy β the current item is missing or unreadable, or the disk is full; check the Rundown for a failed file and the free space on the Admin page; stop and start the channel to pick up from the schedule. The same for SRT that will not connect, an empty playlist, graphics falling back to clean output, and a failed encode.
v3.13.0 β A Sweep for the Mistakes That Do Not Announce Themselves
Fixed
- Two different functions were called
encModeChanged. The later definition silently replaced the earlier, so changing a channel’s encode mode ran the MEDIA dialog’s handler and failed on fields that dialog does not have. Valid JavaScript, invisible to every check we had, and broken since both were written. - Two different functions were called
loadSignupPolicy, so only one of the two panels ever loaded its checkbox. Renamed, and the caller that wanted the other one now gets it.
Checks added, because finding these one at a time in production is not a method
- Every function defined twice in the interface now fails the build, and every function an inline handler calls must exist.
- Every API path the interface calls is checked against the routes the server registers β 97 call sites, all routed. This is the check that would have caught the retry button pointing at an endpoint nobody had written.
- A browser sweep now exercises all nine pages AND calls each dialog and mode handler for real, because the faults that hurt are the ones that only appear when a function runs β a variable used before its declaration compiles perfectly and throws on click.
- A guard on the channel id that “Encode All” passes, since a literal zero in the right position is invisible to the compiler and made every job it created unretryable.
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
- The on-air preview rendered as a title over an empty strip. Reproduced here
at last, against a channel genuinely on air: the player container computed to zero
height in a 143×60 panel while the video sat inside it loaded and playing β
source attached,
readyState 4, no error. A stylesheet rule was winning over the inline size, sowidth()andheight()on the player did nothing. The size is now set with priority on the element itself: 203px player in a 378×249 panel, measured.
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
- Every failed encode reported “the node gave no reason” while the node was
saying exactly what went wrong. gpunode writes the cause into the job’s
messageβ “failed at 1080p: ⦔ β and the failure path readerror,reasonand the log tail, but not that. The reason was on the wire the whole time. - “Encode again” refused the jobs it was most needed for. “Encode All” recorded no channel against the work it created, so retrying one answered “this encode was not started from a channel” β about a job that plainly was. The channel is now recorded, and a job also carries its own settings, so a retry works even for a file encoded from the Media page or a channel whose profile has changed since.
- The on-air preview can no longer be an empty box. It now checks the playlist before playing and says “this channel is not serving HLS yet (HTTP 404) β start the channel, or give it a few seconds to write its first segment”; a player error writes its reason into the panel; and a player left attached to an element that is no longer in the page is discarded rather than reused, which rendered nothing at all.
Documentation
- The API reference gains
POST /api/encode/jobs/{id}/retry, the live-insertion routes (live,live/take,live/return) andGET /graphics/{id}.html.
v3.10.0 β Retrying a Failed Encode
Fixed
- A file whose encode failed could never be encoded again. “Already encoded” was decided from the FIRST rung of the ladder only, so a job that wrote 1080p and then died on 720p counted as finished β “Encode All” reported everything done, for ever, and the only way back was deleting renditions by hand. Every rung must now be present, and present means a file with something in it: a zero-length rendition is what a failed encode leaves behind.
- The “β» Encode again” button had nothing behind it. The interface offered it on every failed job and the server had no such route, so it answered 404. It now re-queues the file with its channel’s settings, clears the failed row, and is recorded in the audit trail. A job that never belonged to a channel says so plainly rather than guessing a quality nobody asked for.
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
- A failed encode now offers to run again, not to be cleared. Clearing it only tidied the list: the file was still unencoded, and the work had to be found and re-queued by hand by whoever remembered which file it was. β» Encode again re-queues the same file with the same channel’s settings, replaces the dead row with the new attempt, and is audited. A small β still dismisses a row without retrying, for a failure that has been dealt with another way.
- A finished encode is not retryable β the server refuses with “this job is done β only a failed or cancelled encode can be retried”, so a stray click cannot throw away a good encode. A job that has already been cleared answers “no such job β it may have been cleared already” rather than failing silently.
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
- The node test said both things at once. It reported “using the
GPU” and, three lines down, “this node has no usable GPU and is encoding on
the CPU” β about a node whose cards were provably encoding at the time. Two more
places still compared the encoder against the literal
h264_nvenc, which no node in the fleet answers any more. Every one of them now asks the node whether it uses its card, through a single function, so the answer cannot differ between two lines of the same dialog. - A failed encode said only “node reported failed:” and stopped.
The reason was read from a log tail, and a node that states a clean reason without a log left the
sentence ending on its colon. The node’s own
erroris preferred, the log tail is added when it says something else, and when there is genuinely nothing the message names the job and node whose log has it β rather than trailing off and leaving an operator to guess. - “Encode All” now says what actually happened. It counted the files in the rundown, then silently skipped the ones already encoded and reported the outcome in a toast that fades. It now says plainly that fewer may be sent, and afterwards states how many were queued and how many were already done, where it can be read.
- A node’s encoder is a sentence β “nvenc h264/hevc (gpu), libx264 (cpu, external) for anything the card path declines” β which is true and far too long for a table repeated on every row. It shows as NVENC h264/hevc, with the node’s own words on hover.
- The slots figure explains itself: encodes running now over the most this node will run at once, a ceiling the node sets from what it measured its own hardware can do β which is why it differs between machines.
- The on-air preview could still open as a title with nothing under it: video.js replaces the element with its own container, and with fluid sizing off that container can end up with no height at all. Its size is now set explicitly.
v3.7.0 β Say Which Machine, Which Encoder, and Which Channel
Fixed
- Every GPU node was labelled “CPU only”. The node table compared
the encoder against the literal string
h264_nvencβ gpunode’s spelling. gpuff answers “NVENC h264/hevc”, so two Quadros encoding perfectly well read as CPU for ever. What decides it now is the node’s owngpu_encodeanswer. The same wrong comparison also logged “encoding on the CPU, not the GPU” on every GPU node it ever talked to. - A card that is fitted and unused now says so, and why. Three states rather than two: GPU Β· NVENC, CPU β card present, unused with the node’s own explanation underneath, and CPU only where there is no card. The middle one is a fault to fix; it used to be indistinguishable from a machine that never had a GPU.
- A finished job now says where it ran and on what β machine, encoder, GPU or CPU, and how long ago. The node’s job id is kept, because that is what its log is searched by, but it is small print now instead of the only thing on the line.
- Testing a node refreshes the table behind the dialog, which had just been proved stale by the test itself, and the result says whether the card is in use rather than only naming an encoder.
- The red count in the navigation now tells you which channel. The reasons were computed and hidden in a hover title β useless on a tablet, and an alarm without an address everywhere else. Clicking it opens the channels page and lists them.
- The on-air preview could become a dead box. Every failure path removed the
<video>element and nothing ever put it back, so after one failure the panel opened with a title and nothing else, for the rest of the session. The element is rebuilt when missing, it carries controls, and a player that will not start leaves its reason on screen instead of a toast that has faded by the time anyone looks.
v3.6.0 β Let the Node Draw the Bar
Fixed
- A page URL was sent for every composited channel, not only the ones that had one. A page outranks the node’s own drawn bar, so every channel took the page path β and while that renderer returned a blank capture, every channel got a white band instead of its colours. The white band I had been reporting was largely self-inflicted. A page is now sent only when an operator set HTML or a URL; otherwise the channel passes its two colours and the node draws the furniture.
- A channel whose node was unreachable at start served clean output for as long as it ran. Sessions are re-created when a node’s output goes dead, but that watches a session that exists β when none was ever created, nothing retried. A node still measuring its capacity when the channel started was enough to lose graphics for the whole run. A sweeper now retries every 30 seconds.
v3.5.0 β Branding as Pixels, Not a Page
Changed
- A channel’s branding is now drawn here and uploaded to the node. gpuff
renders a page for html graphics and that renderer currently returns a blank capture whatever the
page contains β its own release says so plainly. An image has no such problem: the node
composites it directly. The bar, the accent line and the ticker strip are drawn with the
channel’s own colours and sent to
POST /v1/graphics, and the session references the name that comes back. - Only the furniture is drawn. The headline and ticker stay with the node, which draws them from the session fields β so a text correction is still a live update rather than a new image and a restarted session. That was worth preserving; it is the difference between a seamless fix and a visible one.
- A node that takes no image falls back to the rendered page, so nothing that worked before stops working.
Fixed
- Re-creating a session raced its own delete. The node accepts a delete immediately and stops the session behind it, so the id is not free the instant 200 comes back β and creating straight away was refused as a conflict, costing the channel its graphics for a whole watchdog cycle. The id is now waited for.
v3.4.0 β L-shape Reaches the Picture
Fixed
- The compositor could not fetch a channel’s branding. The page was
served from the authenticated API, and a compositor renders it with a headless browser carrying
no credentials β so every session died with “rendering the graphics page β¦ answered
HTTP 401”, which reads like a source problem and is not. It now sits at
/graphics/{id}.html, beside the clean programme it decorates: the page holds a headline, a ticker and three colours, and the programme underneath it was already public. - A stale session on the node blocked the new one. A node that outlives a playout restart keeps the old session under the same id, and a spec that has changed since is refused outright β “job id already exists with a different spec”. The channel then had no graphics and the reason sat in a node log. The old session is now cleared before the new one is created.
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
- A channel created while the server was running had no live publishing point. Points were registered once at startup, so a channel made afterwards showed its publish URL in the interface while the listener refused every encoder that used it β “no channel is configured with this stream key”. An unknown key is now resolved against the database rather than a map built at boot, which also covers a key changed by hand. Verified: a channel created after startup accepted a publisher immediately β connected, 1280×720, 6.9 Mb/s.
- Composited channels now always give the node something to draw. The
compositor renders a page; it draws no text itself. Built-in branding was being sent as a
graphics object with no page, and the node refused the session for exactly that reason. Every
composited channel now points at its own
graphics.html, whether the operator wrote markup or set a headline and three colours.
v3.3.0 β L-shape Comes Back, on gpuff 0.78.0
Added
- Composite sessions speak gpuff’s vocabulary. 0.78.0 takes a layout plus
either a PNG it already holds or a page to render, with the ticker as
crawl; older nodes took a graphics object and drew the text themselves. Both shapes are sent, because fields a node does not recognise are ignored and sending one alone would break whichever node it was not written for. - A channel’s branding is now served as a page at
/api/channels/{id}/graphics.htmlβ headline, ticker, logo and the three colours, drawn on a transparent background so everything unpainted is programme. gpuff draws no text of its own: it renders a URL. Uploading a fresh PNG on every text change would have been a round trip per keystroke; a page the node re-fetches on its own timer is not. It is servedno-store, because a headline correction a proxy holds for five minutes is a headline correction that did not happen.
Verified against gpuff 0.78.0
- html graphics really composite. Same source, same frame region, with and without the overlay: luma 212.3 against 127.2. Not merely accepted β on the picture.
/v1/composite/{id}/updateanswers exactly as asked for: “on air within a frame; the session was not restarted and the playlist is continuous”,restarted: false.- Health reports
composite: true,chromium: true, andadmissionas a verdict separate frompressureβ all three asked for and all three delivered.
v3.2.0 β Live Insertion
Added
- An RTMP publishing point on every channel. A contribution encoder β a truck,
OBS, a hardware box at a venue β publishes to
rtmp://SERVER/live/<key>. The key defaults toch{id}, so a channel has a working point the moment it exists. Written against the standard library only: handshake, chunk stream, AMF0, and FLV decoded into the same media samples the file reader produces, so the engine takes a live feed exactly as it takes a file. - You can see it arriving before you cut to it. The channel dialog polls the point while it is open and reports what is actually there: picture size read from the encoder’s own parameter sets, whether audio is present, the measured rate, frames and keyframes, and β when it is not usable β why: nothing publishing, no keyframe yet, stalled for N seconds. That is the difference between cutting to a live feed and cutting to black.
- Take live and Return to playout. Two buttons. The cut is immediate in both directions β taking live interrupts whatever is playing rather than waiting for it to end, and returning drops back into the schedule. A take is refused when the feed is not fit, with the reason, rather than putting nothing on air. Both are audited.
- The cut always begins on a keyframe, and every keyframe carries its parameter sets, so a viewer joining at the moment of the cut decodes immediately instead of waiting. Feed timestamps are rebased onto the channel’s timeline, so the output stays continuous across the cut and a publisher that restarts its clock does not send the stream backwards.
- If the feed stops for five seconds the channel returns to the schedule by itself. A live insert that fails should become programming again, not dead air.
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.
progresschanged again β an object in 0.44, a number in 0.49. Both are read, which is why the change passed unnoticed instead of stalling every job at 0%./v1/compositeanswers 501 Not Implemented. That is an honest refusal, and compositing work is no longer offered to a node that says it cannot β but it does mean channels using html/url L-shape graphics have no node in the fleet that can serve them./v1/metricsanswers Prometheus text through a redirect, where the pool client expects JSON for per-owner usage. The error is swallowed, so the only effect is that per-owner figures are absent from fleet aggregation β nothing fails.
v3.1.0 β Verified Against gpuff 0.48.0
Fixed
- Compositing work was offered to nodes that cannot composite. A node was
chosen for L-shape sessions by its ROLE alone, and gpuff reports role “both” while
answering
composite: false. The session was dispatched and failed at the node instead of being refused here with a reason. A node’s own answer now beats the role it was configured with β and a node that says nothing is judged exactly as before, so nothing that worked stops working.
Added
- The node list reports
gpu_encodeandcompositeas the node states them. A node quietly encoding on its processor while cards sit idle is not broken, just several times slower β and invisible unless it is reported.
Verified against gpuff 0.48.0
- Health parsed, node healthy, two slots. An ABR ladder and a constant-bitrate single
rendition both encoded to
done, 100%, “ready”, withcbrreaching the node’s job specification. - 0.48.0 changed
pressurefrom a word to an object of host load. Nothing here reads that field, so it is ignored rather than misread β but it is the fifth field whose shape has changed between node releases, which is why capabilities are asked for rather than inferred.
v3.0.1 β gpuff 0.44.1, and CBR That Actually Reaches the Encoder
Fixed
- A job could ask for a constant bitrate, be told it got one, and be encoded
variable. gpuff read
cbrfrom the submission, stored it in the job’s METADATA and reported it back in the job JSON β but it never reached the encoder specification. That is worse than not offering the option at all: the operator holds a receipt saying the stream is safe for a fixed pipe. Both paths are connected now, file jobs and live sessions alike, and a job’s stored spec showscbr = truewhere it belongs. - The refusal for an out-of-date node no longer names gpunode, which is retired. It describes what is wrong β a build too old to return MP4 β and points at capability reporting instead of a product version.
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.
- A boolean where a name was expected.
gpuwas declared as a string; gpuff sendsfalse. The whole health document became unreadable, so the server reported “all encoding nodes are down” about a node answering 200 β and every encode queued for ever. - A version compared across products. gpuff reports 0.43.0; measured against gpunode’s minimum that reads as hopelessly old, so a healthy node was skipped. Capability is now the evidence: a node that states what it can do is believed over version arithmetic.
- Progress as an object. Declared as a number; gpuff reports
{"percent":100,β¦}. A finished job polled as “encoding, 0%” for ever and its renditions were never recorded. - A flat response instead of an envelope. Job status was read only from
{"job":β¦}; gpuff returns the job at the top level, leaving the state empty. Both shapes are accepted now β an envelope is a convention, not a contract worth losing an encode over.
Fixed β the message that hid all four
- “Every node is busy” was a guess, and usually wrong. The dispatcher already knew why it had placed nothing and the code threw that away. The queue now says “queued at position 10 β no node available (http://β¦: unreadable health response)”. That single change turned blind restarts into targeted fixes.
- Editing a channel threw on every open. A scope error introduced with the re-shuffle setting referenced an undefined variable, aborting the rest of the dialog so the filler pool, encode profile and QR fields never populated. The static invariants could not see it β they check that called functions exist, and this was a variable reference. The browser sweep found it, which is the argument for running one every release.
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
- The pacer was attached to a path the programme does not take. Padding was
emitted from
writeToOutput, which carries gap packets, slate and service tables β while programme content leaves through the SRT muxer inwriteSampleOutputs. So a channel set to a constant bitrate transmitted its programme at whatever rate the content happened to be, with no stuffing at all, and the padding machinery held a stream nobody was watching. Measured live with a receiver attached: 0.00 Mb/s of padding before, 1.99 Mb/s β half of a 4 Mb/s pipe after, exactly filling the gap left by 1.98 Mb/s of programme. - The channel now measures 3.97 Mb/s against a 4.00 Mb/s target, worst segment 1.47% out, steadiness 0.65%. Programme bytes are counted where they are emitted, and the pipe is topped up on the same path. Two build guards keep both halves in place.
- PCR is now reported rather than failed on. It is read from a remuxed capture, which rewrites clock references on its own schedule, so its absence there is not evidence of absence on the wire β the same reason null packets cannot be counted from a copy. A permanently red line that means nothing teaches an operator to ignore the whole result.
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
- “Test CBR & padding (5 min)” β the button on the channel dialog now judges the stuffing itself, not just the rate. Two criteria replace what was only an informational note: padding is being emitted to hold the rate (reported as Mb/s and as a percentage of the pipe, and only required when the programme leaves a gap to fill), and programme and padding together make the target. The figures come from the pacer’s own counter, because a captured copy of the stream cannot show them β a remuxer discards null packets, and the padding IS the mechanism being tested.
- A channel with no receiver attached now reports “inconclusive” and says “no bytes left the transport output while the test ran β an SRT listener transmits only while a client is connected, so attach a receiver and run this again”. Previously it reported the rate as 50% below target, which is the most misleading thing it could say: nothing was being transmitted at all.
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
- An encode can now be asked for at a constant rate. The GPU nodes have always
been able to produce one β true CBR on NVENC and x264 alike, capping the peak and padding the
undershoot β but nothing here could request it: the encode profile had no such field and the
dispatch never sent one, so a rendition destined for a modulator, an ASI card or a fixed
contribution circuit always came back variable. The encode dialog now offers
“Constant bitrate β single rendition for a fixed-rate pipe”, and the profile
carries
cbrthrough to the node. - Measured on a source that swings from black to noise, which is where the difference shows: a variable rendition spread 69% (0.05β2.31 Mb/s against a 2 Mb/s target) while the constant one held to 3% (1.98β2.13). The ladder modes stay variable-rate on purpose β that is the right choice for adaptive delivery over HTTP, and paying the picture-quality cost of CBR there would be a mistake.
v2.96.0 β Constant Bitrate Ran Above Its Rate
Fixed
- The content path never told the pacer what it had written. Holding a stream
at a constant rate works by writing the programme and then filling the rest of each short
interval with null packets β which requires knowing how much programme went out. The only
note()in the engine counted service TABLE bytes, so the pacer believed the pipe was empty and sized every interval's stuffing against a full budget, padding a complete target on top of the programme. Measured on the real function: a channel configured for 4 Mb/s ran at 5.82 Mb/s β 45% over. A constant bitrate exists to serve a modulator, an ASI card or a multiplexer, and every one of those overflows on a stream that runs above its declared rate. - The fix is one line in the right place β the content is declared before the stuffing is asked for β and a guard now fails the build if that call disappears again or moves after the stuffing, because declaring it late pads the first interval of every write against an empty budget.
Added
cbr_rate_bpsandcbr_stuffed_bytesnow reach a channel's live statistics (the fields existed but nothing filled them). How much of a fixed pipe is padding is worth seeing on its own, and it is the instrument that shows whether the pacer is working on real hardware.
Stated plainly
- No padding is emitted while no subscriber is attached, which is correct β there is nothing to
send to. This environment could not hold an SRT subscriber long enough to watch padding flow end
to end, so the end-to-end proof belongs on hardware with a real receiver: start the channel, run
the CBR conformance test, and
cbr_stuffed_bytestells you the rest.
v2.95.0 β Filling a Whole Day, Honestly
Fixed
- “Fill day” filled a fraction of the day and reported success.
Asked to fill twenty-four hours from a library of two twenty-second clips, it laid its item limit
and stopped β 2.8 hours of 24 β answering
{"added":500}with nothing to say it had stopped early. The response now statescovered_hoursagainstwindow_hoursandcomplete, the limit is 2000 rather than 500, and when the limit does bite the note explains why and what to do instead: a day of twenty-second clips would need about 4,320 rows, so those belong in a LOOP, not in a rundown. The interface shows the shortfall instead of a cheerful count.
Added
- Re-shuffle on every loop, beside Loop playlist. A looping channel replayed one fixed sequence for as long as it ran, so “fill the day randomly” from a handful of files gave viewers the same order all day. With this on, the rundown is re-ordered each time the channel comes back to the top β same clips, different sequence, no rows to lay out. Proven on air: A,B,C,D became D,B,C,A on the next pass.
Verified β no change needed
- The rundown’s π preview plays real pictures. Driven in a real browser and sampled frame by frame: 640×360, playing and advancing, 73 distinct colours. Not a frozen first frame, not a black rectangle.
v2.94.0 β Alternate Languages Stop Vanishing Quietly
Fixed
- A file carrying alternate-language audio lost the extra tracks without a word.
Playout airs the first audio track and says so in its log, but nothing reached the operator: no
field, no badge, no warning. An English-and-Spanish master uploaded as “ready”, aired
English, and the Spanish was discovered missing on air β if at all. Every file now records what
it carries: each audio track with its language, channel count and codec, exposed as
audio_tracksand shown in the media library as “π 2 audio Β· first airs”, with the languages in the tooltip. A single tagged track shows its language instead.
Added
- CBR padding is now visible in a channel’s live statistics
(
cbr_rate_bps,cbr_stuffed_bytes): how much of a fixed pipe is being paid for and thrown away, and β just as usefully β whether the pacer is padding at all.
Stated plainly, not implemented
- Carrying more than one audio language is not supported. The transport
muxer publishes a single audio PID and the reader takes the first track. Making a channel carry
two languages needs multiple audio PIDs with ISO 639 descriptors in the PMT, and matching
EXT-X-MEDIAaudio groups on the HLS side β a real feature, not a flag. Until then the interface tells you exactly what will air rather than letting you find out later.
v2.93.0 β CBR Conformance Test
Added
- A CBR conformance test, on the channel dialog beside the rate. Setting a constant bitrate is a promise to whatever carries the channel β a modulator, an ASI card, a fixed-rate contribution circuit β and the promise is only worth what the output actually does. The test measures the running channel and reports each criterion separately, so a failure says which property broke: enough samples to judge, mean rate against target, worst single excursion, steadiness across samples (standard deviation as a percentage of target), transport continuity errors, and PCR presence. It runs for at most five minutes and then stops itself, and the button toggles it off sooner.
- It refuses honestly rather than reporting nonsense. A channel with no CBR rate is told there is nothing to verify. A channel that is off air is told the test measures real output. A channel that publishes only HLS is told that constant bitrate governs the transport-stream pipe and that HLS segments are variable by nature β measuring them would produce a frightening verdict about a setting that does not apply to that output.
- The rate is sampled from the transport pipe’s own byte counter, which counts stuffing, and NOT from a captured copy: a copy through a remuxer discards null packets, and the padding is the constant-rate mechanism. HLS bytes are excluded, so a channel publishing both outputs is still measured on the pipe the setting governs.
v2.92.0 β Nothing Succeeds Silently
Fixed
- Posting a day’s schedule to
/api/schedule/{id}answered 200 with an empty body and did nothing. That endpoint reads a schedule and is GET-only, but anything else simply fell out of the handler β and an empty 200 is what every caller reads as success. The path is the natural guess for an import (it is the endpoint called “schedule”), so an integrator was told the import worked and then found an empty rundown. It now refuses, and the refusal names the endpoint that does accept one:POST /api/playlist/{id}/import. A guard now fails the build if ANY handler branches on the request method without refusing at least one, so the class cannot return.
Checked and found correct β no change needed
- Schedule import is exemplary. A three-line CSV built the rundown with every
air time anchored, and a row naming media that does not exist is reported rather than dropped:
parsed 2, added 1, skipped 1, "line 3: no media matches \"NOT_THERE.mp4\"". - Filler pools are stored on the channel and read back correctly.
v2.91.0 β Renditions Stop Advertising Zero
Fixed
- Re-encoding a file with a different ladder catalogued the old rungs at 0 kbps.
The rescan that records renditions sees every rendition directory a file has ever had, not only
the ladder just encoded β so any label outside the current ladder was written with a bitrate of
zero. The moment the house bitrates changed, the library began advertising renditions at
“0k”: visible in the π preview source bar and returned to every consumer of
GET /api/files/{id}/renditions, including anything that might take the figure literally when choosing a rung. - Renditions are deliberately keyed by (file, label, bitrate) so one file can carry the same rung at two bitrates for two channels. That design is kept; what changes is that a rescan now resolves a REAL figure β the ladder’s value, else the bitrate already recorded for that rung, else the file’s own measured bitrate β and if none can be established honestly it leaves the existing entry alone rather than writing a fabricated zero.
Checked and found correct β no change needed
- A channel’s encode profile does reach the node. A channel carrying an unmistakable ladder (540p@1234k, 270p@321k) produced exactly those renditions at exactly those bitrates. An earlier run that appeared to contradict this was a badly chosen test source β 540p cannot be encoded from a 360p master, and the node correctly declined to upscale.
v2.90.0 β When Encoding Stops, You Are Told
Fixed
- A GPU node outage was invisible in the interface. When every encoding node is unreachable the server writes a careful, de-duplicated alert β “3 encode(s) are waiting and none of the 1 configured node(s) are reachable. The queue is holding the work and will start it as soon as a node returns” β and the type carrying it promised, in its own comment, that these “surface in Admin alongside the node list”. Nothing ever read the slice. The operator saw a queue that never moved and an explanation sitting in a log file on the server. Alerts now ride the queue response the interface already polls (no new poller) and render as a banner beside the node list. Verified live against a dead node: the alert reaches the API and the words appear on screen.
Checked and found correct β no change needed
- A channel’s encode profile reaches the node intact. A deliberately
unmistakable profile (a single 432p rung at 1234k, 6-second segments, 2-second keyframes) was
submitted through the channel and the node reported back
accepted: 1 rendition(s), 6s segments, keyframe/2sanddone: [432p]β the ladder, the geometry and the rung count all survived the journey.
v2.89.0 β The Guide Stops Showing Filenames
Fixed
- The XMLTV feed displayed raw filenames to viewers. When an item had no
description, the guide fell back to the stored filename β extension, underscores and all β so a
partner carrying the channel, or anyone in Plex, Emby or Jellyfin, saw
“S01E03_FINAL_v2.mp4” where a programme title belongs. The TV app API had a
prettyTitlehelper for exactly this and showed the same programme correctly; the guide, which is the most externally visible thing this product emits, never used it. Both now share one helper: the operator’s own title wins when set, otherwise the filename is tidied (extension removed, separators and escapes resolved). Verified against a live feed β a partner now sees “S01E03 FINAL v2” and “The Nine O’Clock News”.
Checked and found correct β no change needed
- Guide content, not just its form. Against a known schedule the feed carried one programme per item, correct titles, 20-second durations matching the media, contiguous start and stop times with no gaps or overlaps, a timezone offset on every entry, and a first programme starting exactly at the anchored time.
- As-run accuracy. Both aired items recorded, durations within a second of the media, every row carrying a start, an end and the channel name, and nothing left marked playing after the channel stopped.
- The as-run CSV keeps the real filename deliberately: it identifies the asset for ad reconciliation, where the file β not the on-screen title β is the thing being reconciled.
v2.88.0 β 360p Rung, and the 4K Threshold Corrected
Changed
- The house ABR ladder gains a 360p rung at 400k, so the encode dialog now offers 1080p 3500k / 720p 1800k / 480p 800k / 360p 400k β matching gpunode 1.49.0 exactly, so a file encoded here and one encoded on a node are interchangeable.
- The extra 2160p rung is earned by a 4K master, not a 3K one. Measured on the long edge, with enough tolerance to cover DCI 4K (4096 wide) and masters that land a few pixels under 3840 after a crop.
v2.87.0 β House Ladder, and an Audit Trail That Covers the Schedule
Changed
- The house ABR ladder is now 1080p 3500k / 720p 1800k / 480p 800k (gpunode 1.48.0 and the encode dialog agree, so a file encoded on a node and one encoded here are interchangeable). A 2160p rung at 4500k is added only when the master is 3K or larger β measured on the long edge, so a vertical 1620×2880 master earns it and a 2560-wide “2.5K” one does not. The decision is made when the source has been probed, not at submission, because only then are its dimensions known; a caller-supplied ladder is still honoured exactly as given.
Fixed
- The audit trail did not cover the schedule. Starting and stopping a channel were recorded, so the trail could tell you a channel ran β but adding an item, re-sequencing the rundown, removing an item, trimming in/out points and setting ad breaks recorded nothing at all. Those are precisely the changes that decide what transmits and when the channel hands over to an ad server, so the one question an audit trail exists to answer β who changed this, and when β had no answer. All five now record, with the channel named rather than an id that means nothing a year later, and a test keeps each writer paired with its entry.
v2.86.0 β A Rejected Upload Now Says Why
Fixed
- Uploads that failed showed “error” and nothing else. A truncated
transfer, an empty file, a non-video file and an unreadable MP4 all looked identical in the
media library β no message, no hint, nothing to act on. The database column, the recording
helper and the playout engine’s use of them all already existed; the PROBE, which is the
path every upload takes, set the status without ever writing a reason. Every rejection now
carries plain language that identifies the problem and says what to do:
“the uploaded file is empty (0 bytes) β the transfer did not complete”,
“too small to be a valid MP4 β it looks truncated or is not video at all”,
“no usable video track”, each ending with what to try next. The media grid
shows it on the file’s tile, and the API exposes it as
error_msg.
Checked and found correct β no change needed
- HLS conformance across item boundaries. Every published playlist satisfied TARGETDURATION ≥ every EXTINF, media sequence rose monotonically, discontinuity tags appeared at the junctions, and declared durations matched the segments within 0.5 s.
- Cross-user isolation, nine ways. One operator cannot list, stream, schedule, encode-list, delete another’s media, nor read, start or delete their channels, and the as-run log does not leak them. All refused with 403.
- Bad uploads reach a terminal state β none stuck in “processing” β and duplicates by name are kept as separate files rather than overwriting each other.
v2.85.0 β A Waiting Channel Now Transmits Something Playable
Fixed
- While a channel waited for a scheduled item, it transmitted an undecodable
stream. With no gap-filler pool configured, every fallback in the gap filler emitted
null packets. A transport stream of pure stuffing contains no elementary streams at all β
ffprobereportsnb_streams=0β so a viewer who joined a channel before its first anchor received a playlist full of segments no player could decode, and a CDN cached them. The channel looked healthy from every angle: the playlist served, segments appeared, the status said running. - The product already generated a slate β black and silence β and already had a
playSlatepath; the waiting path simply never used it. Gaps now try the slate first and fall back to null packets only where no slate can be produced. Measured on the same channel: 15 KB segments carrying nothing but PID 8191 became 63 KB segments carrying H.264 1280×720 and AAC. An invariant keeps the gap filler from ever dropping straight to nulls again.
Checked and found correct β no change needed
- Late junctions are by design. An anchored item whose predecessor is still
running starts late, because PlayoutGo never truncates a file β and the rundown says so: a red
β OVERLAP row and a
+MM:SS overbadge appear on the anchor, showing the exact lateness. Verified in the browser on a deliberate 15-second overlap. - Looping a rundown that contains anchors does not spin. On the second cycle every anchor lies in the past; the engine still played each item once per cycle (4 plays in 75 seconds for two 20-second items) and kept producing segments.
v2.84.0 β The Rundown Now Shows What Is On Air
Fixed
- The rundown’s status column could never say “playing”. The column documents three states β pending, playing, played β and the tooltip promises “playing = on air now”, but no code anywhere wrote that value. Only “played” and “failed” were ever set, so the row actually transmitting read “pending”, indistinguishable from items that had not run yet. Any consumer asking the schedule API “what is airing?” β a dashboard, VODOTT, an automation script β got no answer at all. The engine now marks the item on air when it starts it, and only one item per channel can hold that mark.
- And it cannot get stuck. Stopping a channel releases the marker, so a stopped schedule never claims to be transmitting; startup releases every marker in the database, so a crash cannot leave an item reading “on air” from hours ago β the same reasoning that already closed orphaned as-run records. A finished item is never dragged back to playing.
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
- Starting a channel that was already on air answered HTTP 500. A
double-clicked play button, and any “make sure this channel is running” automation,
therefore looked exactly like a broken server: monitoring pages on 5xx, and the caller cannot
tell a genuine fault from a channel that is simply already up. Stopping twice was already
idempotent; starting twice now is too, answering 200 with
"already_running"and saying nothing was changed. A channel caught mid-stop answers 409 β a transient conflict worth retrying, not a server error. Only a real failure is a 500. - Filling a day that crosses a DST transition laid items onto the next day. The end of the window was computed as midnight plus N elapsed hours. A spring-forward local day is only 23 hours long, so “fill until 24:00” ran to 00:23 the FOLLOWING day and put programming on top of that day’s own schedule; a fall-back day stopped an hour early. The window end is now a wall-clock hour on the requested day. Asserted on the next real transitions in both directions: anchors strictly increasing, none sharing an instant, and nothing spilling past the day that was asked for.
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
- A control character in one channel name made the whole XMLTV feed unparseable. The escaper handled the five markup characters and passed everything else through β but XML 1.0 has no representation at all for a NUL or the other C0 controls: they cannot be escaped, only removed. So a single channel whose name carried one produced a feed that no conformant parser would accept, breaking the EPG for every channel in it and for every downstream set-top box, while the UI showed nothing wrong.
- Fixed in two places on purpose. The API now refuses control characters and text-direction overrides in channel names and descriptions, naming the exact character; and the serialiser drops anything XML cannot carry, because rows created before this release already exist in operators’ databases and would otherwise keep poisoning the feed forever. Proven on a database still holding such a row: the feed parses, and the NUL is gone from the output.
- Text-direction overrides (U+202Aβ202E, U+2066β2069) are refused too. They are legal XML but make a stored name display as something else entirely in a console, a log or an audit trail β the mechanism by which a name is made to lie about itself.
Unchanged on purpose
- Legitimate names are untouched: accents, Cyrillic, em dashes, emoji and punctuation all pass and round-trip exactly. Ampersands and angle brackets still escape as before.
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
- Polling kept running after logout.
doLogoutcleared the two timers it knew by name, so the admin auto-refresh carried on hitting an admin endpoint every five seconds on a LOGGED-OUT screen, answering 401 each time, indefinitely. Measured before and after: 2 calls and 2 unauthorised replies in twelve seconds became zero. - Two more timers nobody was tracking, found by the invariant written for the
first: a six-hour session refresh that would keep trying to renew a session that no longer
exists, and the encode-queue poller. Every repeating timer now registers with
appInterval(), and logout callsclearAppTimers()β a registry cannot forget a timer somebody adds later, which naming two of them by hand always could. - A data race in the test suite (not in the product): an engine started by an earlier test kept reading the package-level database handle while the next test’s setup replaced it. Harmless in production, where that handle is assigned once at startup and never swapped β but a flaky suite stops meaning anything, so setup now stops everything still running before it swaps globals.
Audited (browser-measured, not eyeballed)
- No timer leaks: visiting all eight pages five times leaves exactly the timers it started with. Poll rate on the busiest page is 9 API calls in ten seconds, and does not grow after repeated revisits.
- A refresh does not clobber text being typed into a dialog, does not close an open dialog, and does not lose rundown rows.
v2.80.0 β HTML Graphics Land Where They Can Render
Fixed
- A channel using pasted HTML or an external URL could be placed on a node without headless Chromium. The picker asked only for a composite-capable node, so with a mixed fleet the overlay could land somewhere that cannot render it β failing AFTER the channel was already depending on it, which is exactly what the capability advertisement exists to prevent. Placement now derives the requirement from the channel’s own graphics: built-in graphics take the least-loaded node as before, while html/url graphics take a Chromium-capable node even when it is busier, because a node that cannot render them is not a candidate at all. When nothing in the fleet advertises a browser, the log and audit trail say precisely that β “no node advertises headless Chromium” is a different problem from “no node configured”, with a different fix.
Verified
- The html L-shape, end to end for the first time. A channel with pasted HTML graphics on a Chromium-equipped node: graphics came good at 32 s, the fallback flag cleared, and the viewer received 1920×1080 output with average luma β 91 β real content, not the black frame a broken overlay produces. This needed gpunode 1.47.0, whose snapshot fix made html graphics possible at all.
v2.79.0 β The Fleet View
Added
gpupool.Fleet()β the whole pool at one moment./v1/metricsis per node, so an operator running several systems across several nodes had to open N endpoints and add up by hand. Fleet gathers health from every node, sums per-owner usage across them when given an operator token, and reports unreachable nodes rather than skipping them β a node that cannot be asked is exactly the one an operator needs to see. Agpupoolctlcommand prints it as a table or JSON.
v2.78.0 β Fleet-Aware Dispatch
Added
- Capability- and label-aware selection in
gpupool. The library can now be told what the work NEEDS β a codec, headless Chromium, HDR, or an operator’s node labels β instead of discovering the gap after an upload.Candidates()returns every eligible node best-first, andTryEach()walks them until one accepts, which is the difference between choosing a node and actually placing the work: a node can pass its health check and refuse the submission a moment later.
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
- Ad-break patterns and bulk apply. Nobody breaks up a 24-hour linear day one item at a time. The π’ editor now generates a regular avail structure β every N minutes, each D seconds, first at M β and can apply the result to every item in the rundown in one action, skipping items too short for a given break rather than failing the run. It reports what it did per item.
- Ad markers on the hourly grid. Day and Week views now show π’n on any item carrying breaks, so the ad structure of a day is visible at a glance instead of only inside a dialog.
- ABR ladders straight from the encode dialog β 1080p+720p+480p, or a lighter 720p+480p β so producing a ladder no longer requires creating a channel profile first. Each option states its trade-off. Verified end to end: the two-rung choice produced both renditions on the node.
Fixed
- Bulk apply could wipe a day’s ad structure silently. Applying an EMPTY break list to every item removed every avail in the rundown β the same button that normally adds them, with no warning. It now names the consequence and asks first. Found by a test whose pattern legitimately produced nothing, which is exactly how an operator would hit it.
- A pattern that cannot fit an item now explains itself (“the first break alone starts after it ends”) instead of quietly leaving an empty list behind.
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
- The whole page panned sideways on a phone (95 px). The top bar’s
right-hand cluster β clock, address pill, two buttons β is
flex-shrink:0, so below ~560 px the bar grew past the screen and took the navigation off the left edge, which is the one thing that must never move. Below 560 px the cluster now yields: the clock goes (every page that needs time shows it), the address is capped with an ellipsis, and the wordmark drops to its icon. Horizontal overflow is now zero at every width tested. - π² Fill day was unreachable on a phone β and in the wrong container everywhere.
The button had been nested INSIDE the view-switcher’s segmented control, a span with inline
overflow:hidden. On a desktop the group was wide enough to hide the mistake; at 390 px the button was clipped away β present in the DOM, reachable by no gesture. It is now a sibling beside the group, where an action belongs. Every remaining off-screen control was checked to sit in a genuinely scrollable ancestor, not merely off the edge.
Hardened
- A new invariant fails the build if any non-segment button is placed inside the clipped segmented control, with a reintroduction proof β the desktop-hides-it class of layout bug.
v2.75.0 β Ads Reachable, Encoding Usable, Two Dead Buttons Found
Fixed
- Encoding said “no node” while the encoder was using one. The
node panel and the encode dialog listed only database rows, so an install that declared its GPU
nodes in
config.json— exactly what the L-shape setup documents — was told “No encoding node is enabled” and sent to Admin to add a duplicate, even thoughactiveNodes()had always encoded on them happily. File-declared nodes are now listed (flagged as file-managed and read-only, because the file is the authority). - Admin ▸ Snapshots: Download and Restore were dead buttons. Both
embedded a raw
J()/JSON string in theironclick, whose double quotes closed the attribute — the buttons rendered perfectly and threw “Unexpected end of input” on click. Restore is the disaster-recovery path, so this mattered. Both fixed and browser-verified.
Added
- π’ Ad breaks — SCTE-35 avails, finally reachable from the interface.
The engine has always emitted SCTE-35 in-band and CUE-OUT/CUE-IN in the HLS playlist,
but breaks could only be set with curl or a schedule import: there was no UI at all. Every
rundown row now carries a π’ button (showing the break count) opening an editor that adds
breaks at mm:ss, lists them, deletes them, and — crucially — reports what the
server actually kept: a break past the item’s end is refused with the server’s own
words, and a break landing inside another is reported as dropped rather than silently
discarded. Proven end to end: breaks set in the editor produce
#EXT-X-CUE-OUT:6.000,#EXT-X-DATERANGEwithSCTE35-OUT, and#EXT-X-CUE-INin the live playlist. - A real encode dialog replaces the
prompt()box that asked operators to type a number from a text list. It offers the default 720p, any channel’s profile (so the encode matches what airs), or a custom height/bitrate, and names the node that will run the job.
Hardened
- The handler invariant now also catches the
J()alias forJSON.stringify— the hole the two dead backup buttons lived in — and a new identifier scan fails the build on any read of an undeclared variable, the silent-empty- dropdown class. Both carry reintroduction proofs.
v2.74.0 β The Guided Tour
Added
- π§ Guide β an in-interface walkthrough from zero to on-air. Sixteen spotlight steps across every page teach the whole loop on the REAL controls: create the channel (form field by field, including graphics/L-shape and what fallback means), bring in media (upload, π rendition preview, π encode ladder), build the rundown (drag, anchors, Set & flow, π² Fill day, the Day grid), manual control on air, go live, watch it in the player’s My-channels picker, EPG, stats/as-run, and the Admin machinery (nodes, storage tiers, snapshots). Next/Back/Esc; a brand-new account with no channels is offered the tour exactly once, never nagged. Launch it any time from π§ Guide in the navigation.
Audited
- The remaining never-clicked controls from the 2.73 audit were exercised live: file β Edit modal (opens, Escape-closes), rundown π anchor toggle / β remove / π shuffle / π§Ή encoded-copy cleanup, player play/stop, EPG token regeneration and public toggle, admin Add Folder / Add Node / storage Preview / New User, and the Help articles β all green, zero JS exceptions. Four earlier “misses” were selector artifacts of the audit harness, not product defects, and are noted as such in the audit report.
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
- Escape now closes dialogs. No modal anywhere closed on Escape β the universal way out of a dialog simply didn’t exist; an operator was trapped until they found the Γ. One global handler closes the topmost open dialog; Save/Cancel remain the explicit paths. Browser-verified.
- The HLS Player page gained a "My channels" picker. Playing your own channel required hand-pasting its URL. The picker lists your channels (live ones flagged), fills the URL bar with the real playback address, and plays. Browser-verified: pick β URL β playlist HTTP 200.
- A latent script bug the old invariant could not see: the picker’s
first version called an escaper that didn’t exist β inside a template literal, where the
onclick audit never looks and where the failure is a silently empty dropdown. Fixed (the
app’s
X()escaper), and the class is now extinct: a new invariant strips comments, strings and template TEXT (keeping${...}code, recursively), harvests every defined function and parameter, and fails the build on any bare call to an undefined name. Its own canary proves it catches exactly the bug that motivated it.
v2.72.0 β One Dispatcher, Shared
Changed
- The composite node picker now rides the
gpupoollibrary (§2.3a of the pool spec) β the one shared implementation of pool admission that VODOTT and ssh101+ import too, instead of three private dispatchers whose arithmetic disagrees. Behavior is contract-pinned by the v2.67 selection battery, unchanged: least-loaded wins, draining nodes take no new sessions, the pin overrides everything, and older nodes are measured by this origin’s own placements. One rule the migration made explicit: absence is not refusal β a node that never learned to sayacceptingis admitted; only an explicit no refuses, so mixed-version fleets upgrade node by node.
v2.71.0 β The Board Cleared
Added
- B3 β the last backlog letter: rendition preview in the π dock. The dock now offers a source bar β Original Β· 720p Β· 2500k Β· 1080p Β· 5000kβ¦ β so what you preview can be the encoded rendition, byte-for-byte what delivery serves, not just the upload. Two read-only routes back it:
GET /api/files/{id}/renditionslists the labels that exist ON DISK (a catalog row whose file is gone from every tier is honestly omitted), andGET /api/files/{id}/renditions/{label}/video.mp4serves the bytes with the mirror as fallback. The asset name is a strict allowlist βvideo.mp4and nothing else β so there is no path surface to traverse; ownership is enforced by the same gate as every file route.
Fixed (gpunode 1.39.0)
- The snapshot-death guard β the second black-screen suspect closed: an html/url session whose snapshot refresh fails five times in a row (dead Chromium, vanished vendor page) now ENDS itself, flipping the origin to the clean programme instead of riding a stale or black overlay on air forever. One success resets the count. Proven by a seam-driven battery: four failures hold the last good frame, the fifth ends the session, a success resets.
v2.70.0 β Black Screens Named, Days Filled
Fixed
- The L-shape black screen (gpunode 1.38.1). The composite filter graph ended without pixel-format normalization; with alpha overlays in the graph (logo, translucent boxes), libx264 silently converts β but h264_nvenc encodes those formats as black frames: a running pipeline, fresh segments, nothing on screen. Every rehearsal here ran libx264; production Quadros run NVENC, which is why it never showed in a rig. The graph now ends
format=yuv420p[vout], and the filter contract test enforces it forever. (Also verified while reproducing: a node that cannot REACH the origin fails fast and playout falls back to the clean feed within a second β that path was already correct.) - A failed preview now says why. The π dock previously showed a silent black rectangle on any failure; it now names the cause β session expired (log in again), file missing on disk, access denied, server unreachable, or a codec the browser cannot decode.
Added
- π² Fill day β random schedule population. One click on the Rundown toolbar lays the account’s library randomly across the rest of the day as minute-anchored grid items (reshuffling each pass so small libraries don’t repeat back-to-back). The API takes a date, window, and replace flag:
POST /api/channels/{id}/schedule/fill-day {"date","start_hour","end_hour","replace"}; replace clears only PENDING anchors inside the window β played history is never touched. This feature did not previously exist in any form.
Proven by test
- Fill-day: a six-hour window fills with monotonic whole-minute anchors inside the window, shuffled; replace does not accumulate; empty libraries and inverted windows are refused with plain words. The node’s real-video e2e runs through the normalized graph.
v2.69.0 β Your Place in Line
Added
- B6: queue position and ETA for waiting encodes. A job waiting for a node slot now says exactly where it stands β “queued β 3rd in line of 5 for a slot, ~up to 24m at recent pace” β refreshed every poll in the encode panel (the message column already renders it). Position comes from a real arrival-order wait registry; the estimate multiplies position by the average of the last 10 slot-occupancy times and is deliberately labeled “up to” β it errs long rather than promising short. With no history yet, the position shows without a guess.
Proven by test
- Two jobs against a permanently-full node report “1st in line of 2” / “2nd in line of 2” with ~2m and ~4m estimates from seeded history; the wait registry is proven leak-free when waiters exit; ordinal wording covers 11th/12th/13th/21st/102nd.
Known gaps
- B3 (previewing the encoded ABR ladder in the π dock, not just the original) is the last open backlog letter β next session.
v2.68.0 β Warm Before the Fire
Added
- B2: S3 pre-warming. The failover cache exists so channels keep airing when local storage dies β but without warming, the first S3 fetch happens DURING the outage, and a multi-gigabyte WAN pull is exactly the latency a broadcast cannot hide. The pre-warmer now stages what each running channel will need soon β the item on air, the next five in play order (loop-aware), and anything anchored within the next 90 minutes β into the cache before any disk has died. It activates only when it earns its disk: S3 configured AND local redundancy below two live copies (no mirror, or a tier down); a healthy primary+mirror pair keeps the cache cold. Pulls are capped at 4 per five-minute sweep β steady, not a stampede β each staging is audited (
storage.prewarmed), and S3 copies are size-gated before being trusted, exactly like the restore path.
Proven by test
- A running channel’s upcoming items land in the cache byte-complete and the pass is idempotent; a healthy mirror keeps the cache cold; six wanted files stage exactly 4 in one sweep.
v2.67.0 β Spread the Load
Added
- B5: composite load spreading. A NEW composited channel now lands on the composite-capable node carrying the fewest L-shape sessions β the node reports its count in health (gpunode 1.38.0,
composite_sessions), so sessions from OTHER origins sharing the node are counted too. Three rules with teeth: a draining node takes no new sessions (that is what draining is for); a pin still overrides everything, including load and draining β a pin is an instruction, and second-guessing it helps nobody; an older node that does not report a count is measured by the sessions this origin has placed on it. Ties break by config order β stable and predictable.
Proven by test
- Three nodes at 3/0/1 sessions β the empty one is chosen; the pin beats a loaded node; an EMPTY but draining node is skipped in favor of a loaded live one; two count-less old nodes spread away from the one already carrying this origin’s sessions.
v2.66.0 β The Arbiter
Added
- B4: checksummed rendition mirroring. The rendition reconciler previously matched copies on SIZE, so a same-size corrupt copy (bit rot, a torn write) slid through β and could even be copied OVER the good side. Every rendition directory now carries a manifest β size and SHA-256 per file, written from known-good content at adoption β and the manifest, not either copy, is the arbiter: a copy failing its hash is repaired from the copy that passes (audited as
rendition.repaired), and when BOTH fail, nothing is propagated and the failure is loud (rendition.corrupt_both+ log). Existing rendition directories adopt automatically on the first sweep. Verification is lazy β a (path, size, mtime) cache means steady state costs a stat, not a hash β so five-minute sweeps stay affordable on a production library.
Proven by test
- A single byte flipped mid-file in the mirror (same size β the old reconciler would have missed it) is detected and repaired from the primary; the same flip in the PRIMARY is repaired from the mirror; with both copies corrupted differently, neither is propagated and the audit trail says so. Plus adoption: manifest written and replicated on first contact with pre-manifest data.
Fixed
- Documentation: the media mirror is configured by the TOP-LEVEL
mirror_dirkey β a nestedstorage.mirror_diris silently ignored (the startup linemirror=βis the tell).
v2.65.0 β Full Disks and Far Storage, Proven
Verified live
- S3 tiering works, both directions. Against a live S3-compatible endpoint: the storage sweep uploaded the replica byte-exact (3,501,015 bytes, UNSIGNED-PAYLOAD, path-style); then, with every local copy destroyed, the startup failback sweep restored the file from S3 β
[storage] restored β¦ from S3, byte count identical. The restore gate correctly refuses zero-length HEAD answers (proven by an intentionally broken test server before the honest one).
Added
- B8: the disk-full battery. Two tests against a REAL full filesystem (2 MB tmpfs, kernel ENOSPC β skipped cleanly where mounting is unavailable): a channel whose HLS directory fills keeps the process alive and resumes segments within 30 s of space returning β a full disk is a pause, not a death; an upload onto a full disk is rejected with a non-200 and no truncated file is ever recorded as ready media. Both passed against the product as it stands β the panic-guard and supervision epochs paid for themselves.
v2.64.0 β Proven Live; Backups Audited
Proven live (both release binaries, one chain)
- The v2.63 live text update, rehearsed on air: composited channel up β the ticker edited through the ordinary channel dialog PUT β the node’s text file rewritten 0.5 s later β the ffmpeg PID never changed (473 before, 473 after) β
graphics_oknever flickered β the viewer stayed at 1920Γ1080 throughout β audit: exactly onegraphics.live_update. The item carried since v2.50 is closed in every sense.
Added
- Backup verification is now in the audit trail. Every nightly snapshot already had to pass a FULL
integrity_checkbefore being kept (an unrestorable snapshot is discarded, never listed); each outcome is now also an audit row βbackup.verifiedwith the filename, orbackup.verify_failedwith the reason β so “when did we last have a proven-good backup?” is answerable from the Admin page, not just the logs.
v2.63.0 β Text Changes On Air, Within a Frame
Added
- Seamless graphics text updates (the item carried since v2.50). Editing a composited channel’s headline or ticker now changes the on-air text within one frame β no session restart, no item-boundary wait, the viewer never blinks. The node’s filter reads both texts from files with
reload=1(re-read every frame); PlayoutGo diffs every graphics save and, when ONLY headline/ticker changed, POSTs the new texts to the running session (/v1/composite/{ch}/update, gpunode 1.37.0). Structural changes β colors, logo, layout, html β are baked into the running filter graph and take the existing boundary-debounced restart, as they must. A failed live update falls back to the restart path automatically; every live update is audited (graphics.live_update). - Hostile text can no longer touch the filter graph at all. Previously user text was escaped into the graph; now it never enters it β the graph references only the trusted session path, and the text lives in plain files. The filter-contract test asserts the stronger property.
Known gaps
- Live-update proven by unit + endpoint + real-video pipeline tests; the joint live rehearsal (edit the ticker while a viewer watches, assert the ffmpeg PID never changed) is carried to the next session β the mechanism (reload=1) is ffmpeg-documented behavior exercised by the node’s e2e test.
v2.62.0 β The Watchdogs
Added
- systemd watchdog integration (Type=notify). The process announces READY and then heartbeats every 45 s (half of the unit’s
WatchdogSec=90) β but ONLY while genuinely serving: each beat requires the HTTP listener to answer a real TCP dial and the database to answerSELECT 1within 5 s. A WEDGED process β deadlocked, not crashed, the failure mode panic guards cannot catch β goes silent on purpose and systemd restarts it. Proven against a fake systemd socket: READY first, heartbeats while healthy, verified silence once the listener closes. gpunode 1.36.0 does the same (listener dial + work-directory write as its gate). - Updated units + the host-watchdog runbook section. The incident’s empty oom/panic grep points at a hard kernel/driver lock or power β something no process can survive.
RuntimeWatchdogSec=60arms the hardware watchdog so even a full kernel hang reboots the machine within a minute; the runbook documents it alongside how to read the empty grep and the BMC event log.
v2.61.0 β No Matter What
Added
- The crash-proofing audit. Every goroutine in the process now runs behind a recovery guard: the scheduler, the SRT accept loop, and β most importantly β each channel’s engine, where a panic (a corrupt file, a demuxer edge) now fails THAT CHANNEL with a logged error while the server stands. Proven by test: a channel fed 64 KB of crafted garbage keeps the process alive AND a healthy channel starts and reaches running right beside it. gpunode 1.35.1 gets the same treatment (composite pipeline, snapshot loop, flight recorder all guarded).
- The panic ledger. “It did not crash” and “nothing went wrong” are different statements: every RECOVERED panic β background worker, HTTP handler, channel engine β is counted and named, and the flight recorder carries
panics_recoveredandlast_panicin every sample. A silent recovery is no longer silent. - BACKLOG.md ships with this release β the complete accounting of remaining bug fixes and features, split into what needs the operator and what is implementable on request.
Known gaps
- Memory limits, bounded logs, and systemd walls (v2.58) plus this audit close every crash vector under the process’s control; a kernel or driver panic on the host remains outside any process’s reach β the flight recorders exist precisely to prove which side of that line an incident fell on.
v2.60.0 β On Screen, Where You Can Find It
Fixed
- An empty rundown now shows the hour grid. The β¦ Day view rendered only a help panel when the rundown was empty β so “drag a file onto the hour it should air” was impossible for a brand-new channel, the exact operator who needs that gesture. The help is now a banner above a full 24-hour grid, and dropping onto a slot creates the first item. Proven by the first-ever browser drag simulation: a full HTML5 drag (dragstart β dragover with the live
β₯ 14:40label β drop at 70% of the 14:00 slot) persistedscheduled_at 14:40:00exactly, on an empty rundown.
Added
- The flight recorder has an Admin card: sample count, RSS and goroutine trends, HLS headroom, and the last ten samples as a table β browser-verified rendering live data. The black box was an endpoint; now it is on screen.
- Doubled-filename repair has Admin buttons: π Preview lists every candidate with its before/after (changing nothing), β Repair all applies with a confirmation and audit β browser-verified. The endpoint shipped in v2.57; the buttons mean it will actually get used.
Known gaps
- Carried: the crash’s
journalctl -b -1capture, items 6, 8–11, 21, 26, 32–37 definitions, utopia restore rehearsal, production doubled-names run (now one click on the Admin page).
v2.59.1 β The Sweep’s Findings
Fixed
- The composite-node pin now survives the interface. The pin stored correctly (proven at the database), but two API payloads that feed the channel list β the full list and the periodic status summary β omitted
composite_node_url, so the poller wiped the value from the interface’s memory and the dialog always reopened empty. Both payloads carry it now; browser-verified end to end: fill β save (the PUT carries the value, 200) β reopen shows the pin.
Browser-verified this release (headless Chromium on the release binary)
- π file preview opens the floating dock titled with the clip’s name; the π§Ή encoded-copies purge click-through works (confirm dialog β toast β rendition directory verifiably gone); the pin field appears when composited mode is selected and round-trips;
/api/admin/flightserves samples. The “browser-unverified” notes from v2.54–2.59 are closed.
v2.59.0 β The Black Box
Added
- Flight recorder. Every 30 s the process writes its own vitals β RSS, heap, goroutines, open descriptors, disk headroom for HLS and uploads, running-channel count β atomically to
playout-flight.jsonbeside the database, a bounded one-hour ring a few tens of kilobytes big. At the NEXT startup, a run that ended without the clean-shutdown marker gets its final state logged LOUDLY, with the hour’s trend (“RSS 400MBβ3900MB” is a smoking gun; a perfectly ordinary last sample points at power or kernel).GET /api/admin/flightserves the ring. Written in direct response to the unpingable-server incident: the evidence must be on disk BEFORE the crash, because that is the only place it can be. - Encoded renditions now fail over. The reconciler keeps the mirror holding a byte-for-byte second copy of the rendition tree, both directions β primary seeds and heals the mirror, the mirror restores a replaced primary disk. Renditions never go to S3: they are regenerable, and re-encoding beats egress. This closed the last v2.49 storage gap β and its test immediately caught an interaction bug: a purge that cleared only the primary would have watched the mirror resurrect its work on the next sweep. Purge now clears both sides.
- Per-channel composite node pinning. A channel can pin its L-shape to one node (channel dialog β graphics); an unavailable pin logs and falls back to automatic selection rather than dark graphics.
- gpunode 1.34.3 reports
goroutinesin its health payload β the node’s side of the diagnosis story.
Known gaps
- Pinning is exercised by compile and the auto-fallback path, browser-unverified. Items 6, 8–11, 21, 26, 32–37 still need their definitions. Carried: utopia restore rehearsal, production name-cleanup, the crash’s
journalctl -b -1capture.
v2.58.0 β Self-Protection, Purge, and File Preview
Added
- The process can no longer take the machine down. After a production box became unpingable overnight, both programs now self-limit memory even when run bare: playout defaults
GOMEMLIMITto 4 GiB (gpunode 1.34.2 to 6 GiB), overridable by the env var β the runtime works harder near the limit and the kernel’s OOM killer takes one process, never the box. Hardened systemd units ship with the release (MemoryMax, bounded journald logs so nothing can fill a disk, auto-restart), plus a console-diagnosis runbook for capturing the evidence of what happened. - Per-channel media purge, from the Rundown page.
POST /api/channels/{id}/media/purge: Delete encoded copies removes every rendition of the rundown’s files (regenerable β originals stay); …and originals then deletes the uploads too, in that fixed order β an original never goes while renditions still point at it, and originals of files on any RUNNING channel are refused outright (deleting what is on air is dead air, not hygiene). Optionalfile_idsnarrows to some files; every purge is audited with bytes freed. Proven by test: copies-first, on-air guard, stopped-channel originals removal. - Preview any file from the Rundown screen. A π on every rundown row and every Add Files entry plays the file in the floating dock β the original via
/api/files/{id}/stream, which every browser decodes natively, so it works whether or not the file is encoded yet.
Known gaps
- The crash itself cannot be diagnosed from here β the runbook’s
journalctl -b -1capture is the evidence path. Items 6, 8–11, 21, 26, 32–37 still need their definitions re-stated. Carried: utopia restore rehearsal, production name-cleanup run.
v2.57.0 β Three Carried Items Closed
Fixed
- Grid drops now land in the CHANNEL’s timezone. Slot times were built in the browser’s zone while the rows were labelled in the channel’s β a Vienna browser dropping on a New York channel’s 14:00 row pinned Vienna’s 14:00, six hours off the row it was aimed at. Slots now carry the channel-zone instant (Intl-based correction, DST-safe, exact for half-hour and 45-minute zones β verified against New York, Kolkata, Kathmandu and UTC from a Vienna clock), and pointer minutes are added arithmetically so odd-offset zones cannot shift them. The grid-TZ audit is closed.
Added
- Manual control works during the scheduled wait (item 25, completed). A channel looping filler (or padding nulls) before an anchored item now answers the operator: take next joins the anchored item immediately β proven on a real engine joining 151 ms after the request, ~35 s ahead of air time β and jump repositions the rundown mid-wait. Interrupt checks run in the filler clip loop and the null-padding loop alike; an interrupted filler clip is not a playback failure.
- Stored doubled filenames can be repaired (item 5, completed).
POST /api/admin/media/fix-doubled-nameswith{"dry_run":true}previews every candidate;falseapplies and audits. Long stems that are one string repeated exactly twice only β proven by test to repair the true double, leave “gogo” and odd-length lookalikes alone, and to touch NOTHING on a dry run. Display was already fixed in v2.54; this cleans the data itself.
Known gaps
- Items 6, 8–11, 21, 26, 32–37 are carried BY NUMBER from a list whose definitions predate the archived transcripts β they need re-stating to be actionable. Also carried: utopia restore rehearsal (needs the production server).
v2.56.0 β The L-Shape Comes Back On Its Own
Fixed
- A rebooted composite node now recovers automatically. Sessions live in the node’s memory, so a node restart lost them β and nothing on the playout side ever re-created one: the health loop only watched. As built, a node reboot meant the L-shape stayed dark until someone restarted the channel. The health loop now RE-CREATES the session after ~half a minute of dead node output (8 stale ticks β long enough that the node’s own supervised-ffmpeg restart handles a mere hiccup first), and keeps trying while the channel runs composited. Create-or-replace on the node makes the re-kick idempotent.
Proven live β the complete failureβrecovery loop, both release binaries
- Composited up β SIGKILL the node β fallback in 4 s, viewer probed on clean 640Γ360 during the outage (no dead air) β node restarted β session auto re-created β item-boundary wait observed (item 46, live) β composited resumed 36 s after the node came back, viewer probed at 1920Γ1080 β audit:
graphics.compositedΓ2,graphics.fallbackΓ1. No operator action anywhere in the loop.
Known gaps
- Carried: grid-TZ audit, stored-name cleanup, interruptible filler wait, items 6, 8–11, 21, 26, 31, 32–37, utopia restore rehearsal.
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
- The composite node now pulls the channel’s CANONICAL slug
(
name-id, what is actually on disk) via a new dedicated/hls-clean/route. The oldchNform 404’d against the real directory, and the shared/hls/path was one filename coincidence away from the node compositing its own output in a feedback loop. The clean route resolves the same aliases viewers may use and is never touched by the L-shape intercept, by construction. - Viewers using channel aliases (name, chN) now receive the composited output too.
The alias-resolution branch of HLS delivery served straight from disk, silently bypassing the
L-shape while
graphics_oksaid otherwise. - Playlist-name mapping in composited delivery. The viewer asks for
index.m3u8; the node emitslive.m3u8. The health check already spoke the node’s name; the serve path forwarded the viewer’s verbatim, got 404, and quietly fell through to clean β composited channels LOOKED healthy and DELIVERED clean.
Proven live (both release binaries, one machine)
- Channel start β session auto-started on the node pulling
/hls-clean/β¦(640Γ360 in) β item-boundary wait OBSERVED β composited serving at 33 s β viewers on the canonical slug AND thechNalias probed at 1920Γ1080 while the node’s source path stayed 640Γ360 β SIGKILL on the node β fallback in 4 seconds, viewers on clean 640Γ360, no dead air β audit: onegraphics.composited, onegraphics.fallback.
Known gaps
- Recovery-side (node returns, boundary-debounced resume) proven by the fake-node battery, not in this live run. Carried: grid-TZ audit, stored-name cleanup, interruptible filler wait, items 6, 8–11, 21, 26, 31, 32–37, utopia restore rehearsal.
v2.54.0 β Robust Under Load (production findings)
Fixed
- Full encode nodes no longer fail jobs. “Encode All” with more files than slots FAILED the overflow jobs with “no encoding node available (every node is busy or draining)” while a healthy node sat right there β a full car park treated as a missing road. At-capacity nodes are now a distinct condition: jobs QUEUE and wait for a slot (up to 2 hours, saying so live in the jobs panel), retry through brief node unreachability (3-minute grace, so a node restart cannot kill a batch), and only then does the
encode_fallbackpolicy apply. Proven by tests: a full-then-freed node is waited for; truly absent nodes still fail. - The player library ships inside the binary. video.js loaded from a public CDN; with that CDN blocked or slow, every player rendered NOTHING β no preview, no visible error. It is now served by this server itself (
/vendor-video.min.js), the CDN demoted to a fallback, and a preview whose library still failed says so in words inside the box instead of rendering nothing. - The L-shape composite source URL pointed at a playlist name that does not exist (
live.m3u8; the segmenter writesindex.m3u8) β latent since v2.50, it would have broken the first real node session. Found while auditing the preview path.
Added
- The hourly grid always shows the plan. An idle loop rundown with no anchors previously produced an empty grid (and an empty “β” times column). It now projects the loop from the “Schedule day from” time β or the next quarter hour β drawn as projected; dropping any item pins a real anchor.
- Start/Stop the channel from the Rundown page β a state-aware button beside the preview; the operator building the day puts it on air without changing tabs. Uses the same audited start/stop endpoints.
- Doubled filenames read once. Download-template accidents (
NameName.mp4) display as the single title in the rundown, the now-playing banner, the encode jobs and the media panel β long stems only, so real names like “gogo” are untouched; the raw filename stays in the hover title. (Item 5.)
Known gaps
- The stored
original_nameis unchanged β display-level fix only; a data cleanup pass is carried. Grid-TZ audit, joint L-shape rehearsal (now unblocked by the source-URL fix), interruptible filler wait, items 6, 8–11, 21, 26, 31, 32–37, utopia restore rehearsal carried.
v2.53.0 β The Grid You Can’t Miss
Fixed
- The hourly grid rendered EMPTY on most real channels β two crashes, browser-caught and browser-verified fixed. renderGrid called a helper that no longer existed (
isLiveRow, a dangling reference from the v2.47 refactor), and any channel with the defaultLocaltimezone madetoLocaleDateStringthrow β either exception aborted the render before a single hour row was written, so switching to β¦ Day showed a blank box and the feature appeared not to exist. Both fixed; invalid timezone strings now degrade to browser-local formatting instead of blanking the page; and a permanent test (TestNoDanglingScheduleHelpers) now fails the build if any schedule helper is called without being defined β proven to catch the reintroduced bug. - Grid time labels read as a 24-hour clock. They previously truncated a 12-hour string, so a 14:35 item displayed “02:35”.
- The Day/Week grid buttons could be clipped clean off the page. The Rundown toolbar was a no-wrap flex row holding ten controls; on any window narrower than roughly 1400px the row overflowed and the view switcher β the only way into the hourly grid β was pushed out of sight. This is very likely why the grid “did not exist” on real-world screens. The toolbar now wraps, and the view switcher has left it entirely.
Changed
- The view switcher is now its own prominent row directly above the rundown content β β° List view Β· β¦ Hourly grid β Day Β· β₯ Week β always visible at any window width, with a one-line reminder that drops pin the exact time under the pointer.
- The on-air preview is now a floating dock. Instead of a box inline at the top of the page (scrolled away the moment you worked on the grid), the βΆ Preview player floats bottom-right above the content with its own close button β visible while scrolling and dragging anywhere on the page.
Known gaps
- Grid-TZ audit carried (slot times built in the browser’s zone, unchanged); joint L-shape rehearsal on utopia, interruptible filler wait, items 6, 8–11, 21, 26, 31, 32–37, utopia restore rehearsal, item-5 query.
v2.52.0 β Drop On The Minute
Fixed
- Grid items would not open on click. v2.51.0's Alt-click-to-play addition left schedule-grid items with two
onclickattributes; browsers keep the first, so a plain click stopped opening the trim editor. One handler now does both: Alt-click plays the item now, plain click edits it.
Added
- Drops pin the exact time under the pointer. Dropping media (or moving an item) on the β¦ Day / β₯ Week grid previously anchored to the top of the hour, whatever height you aimed at. The pointer's position within the slot is now the minutes, snapped to 5 β and a floating
β₯ 14:35label tracks the pointer while dragging, so the operator aims at a time, not a box. The confirmation toast names the pinned time. - The grid introduces itself. A one-time callout on the Rundown page points at the β¦ Day / β₯ Week buttons (with a “show me” shortcut), and the empty-rundown help mentions dragging files straight onto an hour. The hourly grid and the on-page βΆ Preview player have shipped since v2.45–2.47; an installation that does not show them is running an older build.
Known gaps
- New UI static-checked, browser-unverified. Grid slot times are built in the browser's timezone (unchanged from before); the channel-timezone rendering of labels is correct but a browser far from the channel's zone should verify drops land where intended β a full grid-TZ audit is carried. Also carried: joint L-shape rehearsal on utopia, interruptible filler wait, items 6, 8–11, 21, 26, 31, 32–37, utopia restore rehearsal, item-5 query.
v2.51.0 β Manual Control
Added
- Item 25 β the operator's hands on a live channel.
POST /api/channels/{id}/control: take next, restart the airing item, jump to any rundown item. One mechanism: the engine's sample loop β the same place it checks for shutdown β checks for the request, ends the item there, finalises its as-run row asskipped(a decision, not a fault), and the ordinary advance path does the rest. Jump repositions the rundown (loops are reset and repositioned so the run continues cleanly from the target); cross-tenant item ids are refused. Proven on a REAL engine: three real clips on air, take-next observed advancing in seconds against a 20-second item, restart producing a second as-run airing of the same item, jump landing on item 3 β all in one 4.5 s test. - Rundown live controls. The now-playing banner gains β Take next and βΊ Restart item; Alt-click on any schedule-grid item plays it now (noted in each item's tooltip).
Known gaps
- Manual control interrupts a PLAYING item; a channel waiting on a scheduled item (filler loop) does not take commands until the item starts. Interruptible filler is future work.
- New UI passes static checks, browser-unverified. Carried: joint L-shape rehearsal on utopia, items 6, 8–11, 21, 26, 31, 32–37, utopia restore rehearsal, item-5 query.
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
- The L-shape is implemented β compositor phase 2, items 39 and 43–46, on the HLS pull-composite-re-emit path the decision doc recommended. A composited channel's clean HLS is pulled by a composite-capable gpunode, framed (programme top-left 75%, sidebar + bottom bar), and re-emitted; THIS origin keeps serving viewers, so delivery accounting, DRM and CDN configuration never move. The built-in frame β headline, smoothly scrolling ticker, colours, logo β is rendered entirely by ffmpeg on the node, no browser required. Pasted HTML (43) and external URLs (44) use headless Chromium with interval snapshots and hold-last-good-frame; nodes without Chromium refuse those two modes with a clear message.
- Loud fallback, patient recovery (45/46). A stale node playlist (unchanged is as dead as 404) flips viewers to the clean programme immediately β red FALLBACK chip, audit entry,
playoutgo_channel_graphics_ok0. Recovery returns composited output only at the next item boundary, never mid-programme, shown by an amber chip. Proven by a state-machine test against a live fake node covering both transitions twice. - Everything controllable from the interface. The channel dialog gains pasted-HTML, external-URL and snapshot-interval fields; channel cards show the L-shape state; and the storage tiers (mirror directory, S3 credentials) are now editable in Admin β Media Storage Tiers β validated (the mirror must be writable), persisted to config.json, applied live, audited. The last config-file-only feature is config-file-only no more.
Known gaps
- The full playoutβnode chain is proven by the node's real-video e2e (1920Γ1080 out) plus the origin's state-machine tests against a fake node; the two real programs composing one live stream end-to-end has not been rehearsed in this environment β first composited channel on utopia is that rehearsal.
- Graphics changes and URL-snapshot updates restart the node's pipeline; the seconds of gap are covered by the clean fallback (and return at an item boundary). Seamless overlay refresh without a restart is future work.
- One composite node per system is selected automatically (first composite-capable); per-channel node pinning is not built.
- All new UI passes static checks, browser-unverified. Carried: utopia restore rehearsal, items 6, 8–11, 21, 25, 26, 31, 32–37, item-5 data query.
v2.49.0 β The Disk Is Allowed To Die
Pairs with gpunode 1.33.0 (unchanged this release).
Added
- Tiered media storage: primary β local mirror β S3. Configure
mirror_dir(e.g. a second disk at /data1) and/or ans3block (AWS/MinIO/Wasabi; built-in SigV4 client, no SDK dependency). Every original is replicated to every tier β immediately on ingest, and by a reconciler sweep every five minutes whose single direction-free rule (every tier holds every file at the right size, from wherever a good copy lives) is at once seeding, mirroring, healing, and post-outage resync. Replica copies are temp-and-rename, so an interrupted copy can never pass a later size check. Deletes propagate to every tier. - Automatic failover. Write-probes (not stats β a dead NFS mount stats fine) every five seconds, two consecutive results to flip a state. Primary down: reads resolve per-file down the chain β primary, mirror, cache, synchronous S3 restore β and ingest writes to the first healthy local tier, so uploads keep working through the outage. Announced in the top bar, the Admin storage card, the audit trail, and the log.
- Automatic failback with resync. Two healthy probes bring the primary back: a sweep is kicked, what landed on the mirror during the outage flows home, ingest returns to the primary. Failback is a state the reconciler converges on, not an operation anyone performs. An β³ Sweep now button exists for the day a new disk goes in.
- Storage visibility. Admin card with per-tier health and the active ingest tier;
GET/POST /api/admin/storage; audit entries for failover, failback, mirror loss, and manual sweeps; a full documentation section including what the S3 tier is NOT (a playout source).
Fixed
recordAuditcrashed on system-initiated events (no HTTP request); a nil request now audits as the system rather than panicking.
Known gaps
- Replica integrity is size, deliberately β bit-rot detection would read the whole library every sweep; use ZFS on the mirror if that is the concern.
- An S3 restore during playback is a synchronous download before the item plays (logged, timed). Pre-warming upcoming rundown items during S3-only operation is not built.
- Failover of the ENCODED renditions tree is not included β the encode profile can regenerate renditions from replicated originals; originals are what cannot be regenerated.
- The storage card and failover badge pass static checks and have not met a browser.
- Carried: compositor phase 2 pending the video-path ruling, utopia restore rehearsal, items 6, 8–11, 21, 25, 26, 31, 32–37, item-5 data query.
v2.48.0 β Whose Bytes Are These
Pairs with gpunode 1.33.0 (unchanged this release).
Added
- Per-user disk quotas on original files. Zero = unlimited (the default); set per account in the Users panel or via
disk_quota_byteson the admin user update. Enforced at every path that adds bytes, BEFORE a byte is written: the browser upload answers 507 with the numbers (this file, space used, the ceiling); the watch folder leaves the file unimported and retries on later polls, logging at most every ten minutes. The re-import scan is deliberately not gated β it adopts files already on disk, so refusing it would hide bytes, not save them. Renditions the system encodes never count against the quota: their size is the encode profile's doing, not the user's. Deleting files frees allowance immediately. - Space utilization per user in the admin panel. Every account row shows originals + encoded renditions + quota with percentage; originals come from the library's own accounting (one SUM per user), renditions from a single walk of the encoded tree per page load. Quota edits are audited.
- Accounts see their own usage. A bar above the upload area (
GET /api/media/usage) turning yellow at 80% and red at 95% β so a refused upload is never the first anyone hears of the quota. Suspended accounts see the suspension notice there instead. - Suspension now closes the upload gate. Suspend has stopped channels and refused starts since it shipped; uploads, however, still worked β an account under a billing dispute could keep growing its library. Both ingest paths now answer the suspension with the reason. Nothing else changed: the library is kept, restore lifts everything at once.
Already present, verified this release
- Suspend (stop all channels + refuse starts, keep the library) and delete-user full teardown (stop streams β collect paths β cascade β remove originals AND encoded renditions β report
freed_bytesand anything stuck) both shipped previously; this release adds an end-to-end test that deletes an account with real files on a real disk and proves every byte and row is gone.
Known gaps
- The new UI β usage bars, per-user utilization rows, the quota prompt in Edit β passes static checks and has not met a browser.
- The encoded-tree walk sizes renditions on each admin panel load; on a very large library this is disk work an admin page does. Fine at current scale; a cache is the fix if it ever is not.
- Quota accounting is the library's
file_size; bytes outside the library (stray files in a user directory) are invisible to it until a re-import scan adopts them. - Compositor phase 2 (items 39, 43–46) still awaits the video-path ruling. Carried: utopia restore rehearsal, browser backlog, items 6, 8–11, 21, 25, 26, 31, 32–37, item-5 data query.
v2.47.0 β Demo Accounts, and the Grid Shows the Whole Day
Pairs with gpunode 1.33.0 (unchanged this release).
Added
- Demo accounts. A checkbox on sign-up, with the deal stated next to it: everything a demo account uploads is transcoded to at most 720p with a “Playout from TULIX.COM” watermark β white bold text over a soft dark pill, bottom-right, sized proportionally β before it can air. Enforced at the probe gate every upload path converges on, so the browser uploader, the watch folder, and the re-import scan are all covered; a demo file is never “ready” clean, and an enforcement failure marks the file error rather than letting it air unmarked. Transcodes run locally on the playout host CPU, bounded to two, deliberately: demo traffic never occupies a GPU slot paying work is queued for, and demo works with no encode nodes configured. Admins see a DEMO badge and can lift (or apply) the status β future uploads only; burned files stay burned, and re-uploading is the path to clean copies. Register API gains
demo; the user object gainsis_demo;POST /api/admin/users/{id}/demotoggles it, audited both directions. A full documentation section covers the mechanics, the exact watermark filter, and the caveats. - Day and Week grids show the whole existing schedule. The grids only rendered items with anchored air times, so a looping or flowed rundown showed an empty week β which read as “nothing scheduled” when the channel was on air around the clock. The grids now render every item computeFlow can place: anchored items solid at their air time, flowed and loop-projected items dimmed and dashed at their computed time, with the projection explained in the legend and in each item’s tooltip. Dragging any item onto an hour pins it. A stopped loop with no anchors has no fixed point to project from, and the grid now says exactly that instead of showing blank slots.
Fixed
- The “No air times set” empty state no longer appears for a loop that is on air β the loop is projected into the grid instead, matching what the List view has done since 2.43.0.
Known gaps
- Every UI change this release β the sign-up checkbox, the DEMO badge and lift button, the projected items in Day/Week β passes the static checks and has not met a browser.
- A watermarked file re-imported under a demo account is a new database row and will be watermarked again (a second pill over the first). Documented; harmless; not worth machinery.
- Demo enforcement covers files entering the library from this release on; files a demo account uploaded on an OLDER release (if any) are already ready and stay clean.
- Compositor phase 2 (items 39, 43–46) still awaits the video-path ruling in DECISION-compositor-video-path.md. Carried: utopia restore rehearsal, item 31 completion, the item-5 data query on utopia, items 6, 8–11, 21, 25, 26, 32–37.
v2.46.0 β A Restore Button, At Last
Pairs with gpunode 1.33.0 (unchanged this release).
Added
- Snapshot restore is in the product (item 28). Restoring meant hand-running cp on a production box with the service stopped, deleting journal files by hand, and hoping. The product now performs the whole procedure: the snapshot is verified BEFORE anything is touched, every channel is stopped, journal debris is removed (the trap the runbook warned about), the current file is set aside, the snapshot is copied in, migrations run β restoring an older snapshot into newer code is exactly when they must β and channels the restored database marked as running are resumed. Every failure path rolls back to the previous database and says how far it got. Requests in flight may transiently error while the handle swaps; that is the honest cost of a maintenance action, not something to hide.
- The state being replaced is snapshotted first (item 30). Automatically, verified, named
playout-β¦-prerestore.dbso it is recognisable in the listing and covered by normal retention β so a restore is always itself restorable. If the safety snapshot cannot be taken or verified, the restore refuses to happen. - Snapshots can be downloaded through the interface (item 29). Admin-only, name-validated by shape rather than by enumeration, audited. An off-box copy is the difference between backups and backups that survive the box.
- A looping channel's EPG no longer goes silent after one pass (item 27). A loop repeats, so its guide now repeats: passes are projected across the seven-day window, bounded at 2000 programmes so a ten-second loop cannot generate a million entries, and the projection is declared in the XMLTV itself. One-shot rundowns are untouched.
- A decision document for the phase-2 compositor video path ships alongside this release (not in the product): SRT round-trip vs HLS pull-composite-re-emit, trade-offs grounded in the code as it exists, recommendation: the HLS path β chiefly because it makes the item 45/46 fallback a segment-aligned playlist switch instead of the hardest code in the product. Awaiting a ruling.
Known gaps
- Restore is proven end-to-end by test on a real database; it has not yet been rehearsed on utopia. Rehearse it there once, on a non-production hour, before ever needing it in anger.
- The Restore/Download buttons and the typed-confirmation dialog pass the static checks and have not met a browser.
- The restore swaps a package-level database handle while requests may be in flight; the operation is gated (no channel can start mid-restore) and every test runs under the race detector, but the swap is a stop-the-world action by design, not a concurrent one.
- Compositor phase 2 (items 39, 43β46) awaits the video-path ruling. Everything else carried: item 31 completion, the item-5 data query on utopia, the browser-verification backlog, items 6, 8β11, 21, 25, 26, 32β37.
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
- Graphics mode is a channel property (item 38). Normal or composited, chosen by the operator at creation. The system NEVER flips it β there is deliberately no code path that writes it except the operator's own create and update, and an update may only change it while the channel is stopped: a channel must never change shape mid-broadcast, and the ticker must never vanish precisely when something is going wrong. On air, a mode change is refused with 409; content changes are allowed any time.
- The graphics content model (item 42). Headline, ticker, three colours and a logo URL, stored per channel like the DVB info, validated before storage (hex colours, http(s)-only logo β it is fetched by the compositing node), edited in the channel form, returned by the API. This is the data the phase-2 compositor renders; the CMS exists before the machine that consumes it, which is the right order.
- Node roles (items 40/41). Every gpunode now declares what it takes β encode, composite or both β and PlayoutGo reads the node's own answer from health rather than configuring a parallel copy that could disagree. A composite-only node is never offered encode work: the single Free() gate every dispatch decision passes through now includes the role, so the Quadros can be marked composite and the 4090 keeps its capped NVENC sessions for file encodes. Nodes that predate roles read as encode-capable, which is what they are. The Admin nodes panel shows each node's role.
Known gaps
- The compositor itself is phase 2: persistent composition sessions on the node (39), paste-HTML and snapshotted external-URL sources (43/44), the un-composited fallback with its red banner, audit entry,
playoutgo_channel_graphics_okmetric and item-boundary debounce (45/46). No metric ships before the machinery it reports on exists β a gauge that is always 1 is a lie. - The channel form and nodes panel changes pass the static checks and have not met a browser.
- Set
-role compositeon 162.212.179.53 and .60, leave the 4090 at the default, and audit every node's-slotsβ it is per card.
gpunode 1.33.0 β A Node That Knows Its Role
Pairs with PlayoutGo v2.45.0.
Added
- The
-roleflag (item 40): encode (default), composite, or both β advertised in/v1/healthso every dispatcher reads the node's own answer. A composite-only node also refuses an encode submission itself, before the body is read, so a dispatcher that never looked cannot cost a client a multi-gigabyte upload for a job that was always going to be refused. An invented role value refuses to start.
Fixed
- [FIX] Item 47: An idle node forked nvidia-smi every two seconds, around the clock β 43,200 spawns a day for numbers that were not changing. Telemetry freshness is now state-dependent: 1.5 s while jobs run, 30 s idle, cutting the idle spawn rate twenty-fold to about 2,880/day. A test pins the idle TTL so the burn cannot quietly return.
v2.44.0 β Captions Were Being Stripped From Compliant Source
Pairs with gpunode 1.32.0.
Fixed
- [FIX] High β compliance: Every encode silently discarded closed captions. Both libx264 and h264_nvenc drop A/53 caption SEI unless told otherwise, so a transcoded library lost its captions while the originals kept them β an FCC problem manufactured out of clean content. Every encode path, on the nodes and in the local fallback, now carries
-a53cc 1. - [FIX]:
has_captionsis finally set by something. The probe now recognises the A/53 signature (T.35, country 0xB5, ATSC, GA94) in the first 250 video samples, and the media list shows a CC badge. Detection errs toward missing on a pathological file rather than false-positives, because the flag informs and gates nothing. - [FIX]: The playout remux itself never touched captions β they ride inside the video NAL stream and pass through verbatim β and that is now pinned by test: a caption SEI is tracked byte-for-byte from AVCC through Annex B into reassembled transport packets, so a future muxer change cannot strip captions unnoticed.
- [FIX]: Empty Day and Week views explain themselves. A grid of blank hour slots looked broken and said nothing. It now distinguishes an empty rundown from a loop with no air times, and says what to do about each.
- [FIX]: The startup admin line no longer implies the config decides the login after first run, and every path β database, uploads, HLS, encoded, backups β is logged as resolved, because relative paths under systemd resolve against WorkingDirectory= and not where anyone tested from.
Added
- config.sample.json ships with the source: every key, its default, and the sharp edges written next to the settings they cut with β first-run-only credentials, the array-typed
tls.domains, admin recovery, relative-path behaviour. Underscore-prefixed keys are comments and the startup checker now knows the convention. A test fails if the sample's defaults ever drift from the code's. - The channel allowance is now proven through the API, not just at the database: a tenant at their limit is refused with their numbers in the message, and an administrator is exempt. The database-level concurrency tests existed; the handler wiring β the path a reseller’s tenant actually hits β had never been exercised.
Known gaps
- Caption work is preservation and detection, not generation: content without embedded captions still has none, PMT carries no ATSC caption service descriptor yet, and end-to-end verification against real captioned broadcast source has not run β the sandbox has no such material. Item 31 stays open until it has.
- Rundown-names-doubled (item 5) does not reproduce from the code: every render site shows the name once and the playlist query selects a single column. Check the data on utopia:
sqlite3 playout.db "SELECT id, original_name FROM media_files WHERE original_name LIKE '%.mp4 %' OR length(original_name) > 80;"β a doubled value there implicates a historic import, not the interface. - The empty-state views, CC badge and admin-line wording pass the static checks and have not met a browser.
- Open items 6, 8β11, 21, 25β30, 32β47 untouched.
gpunode 1.32.0 β Encodes No Longer Discard Captions
Pairs with PlayoutGo v2.44.0.
Fixed
- [FIX] High β compliance: Every encode mode β CBR and VBR, NVENC and x264, aligned and not β now carries
-a53cc 1, so A/53 closed captions in the source survive the encode instead of being silently dropped by the encoder defaults. One shared insertion point on the common argument tail covers every branch, so a future encode mode cannot forget it.
Known gaps
- Verified as argument construction and flag acceptance by a real ffmpeg; an end-to-end encode of real captioned source, checked with a caption analyser, has not run.
v2.43.0 β The Channel That Played and Reported Nothing
Fixed
- [FIX] High: A playing channel reported 0B delivered. Byte accounting counted transport-socket writes only, and an HLS-only channel β the common case β has no transport socket, so live stats and the as-run log said nothing had been sent while segments were plainly flowing. The same trap had already been found and fixed twice, in the sparkline and the host total; the two stats paths were the ones still missed. All four sites now share one figure, and a test fails if socket-only counting ever comes back.
- [FIX] High: The dead-air slate was deleted at every startup. The orphan sweep removed everything under the segment directory that was not a channel, and the slate is not a channel. The guard now spares the service's own dot-directories, and the regression test was proven the honest way: remove the guard, watch it fail.
- [FIX]: Subprocess failures now carry their own explanation. "exit status 254" with nothing else cost a debugging session; ffmpeg's stderr was connected to nowhere. Every failure now quotes the child's last words.
- [FIX]: A misspelled configuration key is named at startup, with a suggestion.
admin_loginused to be absorbed silently; it now prints did you mean admin_email? β and the check recurses into nested sections and node entries. A file that cannot be parsed at all is reported by line and column, not byte offset, before the process exits. - [FIX]: The schedule anchor field is prefilled with today at the next half hour, so Safari's empty-field-that-looks-full trap has nothing left to spring on. An operator's own value is never overwritten.
- [FIX] Packaging: watchfolders.go was absent from the 2.42.0 source archive although the routes, database layer, docs and tests all reference it. Reconstructed against that surviving contract β the recovered unit tests pass β but diff it against the original before trusting it, and re-cut the archive from a tree that has the real file.
Added
- PCR self-measurement. The node now measures its own clock emission β mean interval, worst jitter against the 30 ms target, and whether the value ever ran backwards, which is the exact signature of the overflow class that broke receivers every 57 minutes. Three new series in
/metricsbesidepcr_late_total, each labelled a self-check: absolute accuracy (Β±500 ns) depends on the physical output path and needs a hardware analyser. - SRT proven end to end, for the first time. Both modes had existed for many releases and neither had ever met a receiver. Listener mode with an external pull, caller mode dialling out, seventy transport packets verified byte-exact and in order each way β and the far-end drop test proves a write to a dead receiver fails rather than blocking, which is what the engine's reconnect depends on. Loss and reordering on a real network remain live-box work.
- A preview player on the rundown page. The operator building a day can now see what is on air without changing tabs. Served from the origin, not the CDN, because it is a view of what the server is producing right now. Depends on the v2.42.0 CSP fix.
- A clock. Time-of-day in the top bar, ticking β a playout screen without one is unusable in a gallery.
- Projected air times for a running loop. A looping rundown has no anchors, so the whole column showed "β" even on air. The engine now reports when the airing occurrence began, and the rundown flows every later item forward from that fixed point. Rows before the live item stay blank: in this cycle they are the past, and a time would be a guess about the next one.
- The countdown to the next item ticks. It was frozen at render time, which made it confidently wrong within seconds; and with the projection above, looping channels now get it at all.
Known gaps
- Everything in the interface β the CSP fix, the view toggle, the prefill, the preview player, the clock, the projection β compiles and passes the static checks and has not met a browser. Verify on utopia before it goes near air.
- The 0B fix matches the code's only path to that symptom; confirm on the live box that the figure moves.
- SRT across a real network (loss, reordering, latency) is unproven; loopback cannot show it.
- watchfolders.go is a reconstruction until diffed against the original.
- Open items 5β11, 16β21, 25β47 from the handoff are untouched, captions (31) still blocking commercial launch.
gpunode 1.31.0 β Two Buttons That Reported Success and Did Nothing
Shipped with PlayoutGo v2.42.0.
Fixed
- [FIX] High: The console's "Purge finished jobs" and per-job delete were never implemented. Both confirmed the action and did nothing β so an operator clearing space before a large encode believed disk had been freed that had not. A button that plainly fails is better than one that lies, because the second sends someone away satisfied.
- Both work now. Verified on a real node: a work directory of 15 MB purged to 8 MB, with the log stating what was freed. Purge takes only finished work, because destroying an encode a client is waiting on is not what anyone means by housekeeping, and deleting a running job stops the encoder first rather than removing the directory from under it.
- The path is contained: a job id that escaped the work directory would delete somewhere else entirely, so ids are checked for shape and the resolved path verified before anything is removed.
- A check now reads every action the console sends and fails if the server does not handle it β this whole class was invisible because the request succeeded.
v2.42.0 β Registration Could Not Be Closed From the Product
Fixed
- [FIX] High β security: Anyone able to reach the server could create an account, and there was no way to stop it from the interface. The endpoint that closes registration existed, with a working guard, and nothing in the product called it β the setting was reachable only with curl and a hand-copied token. Verified on a default installation: a stranger registered successfully and received a working token.
- The admin settings now carry the control, with the current state written out rather than left as a checkbox β "anyone who can reach this server can create an account" is a sentence an operator acts on, where an unchecked box is not. A change the server refuses puts the control back, so it cannot show registration as closed when it is open.
- The documentation for this endpoint described it as "installation-wide settings", which said nothing. It now states what it controls and, more importantly, that registration is open by default β right for an installation on a private network, wrong for one reachable from outside it.
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"
v2.41.0 β Three Features Nobody Could Reach
Fixed
- [FIX] High: Suspension, database snapshots and the audit trail had no interface at all. All three were built, documented, tested and impossible to use from the product β an operator would have needed curl and a hand-copied token. A feature nobody can reach is not a feature that shipped.
- Suspension is now a control on the user row, with the state shown as a badge and the reason beneath the address. The confirmation says plainly that nothing is deleted, because Suspend and Delete sit next to each other and only one of them can be undone.
- The snapshot panel says whether the newest one was actually readable and what it holds, and turns red when it was not β which is the whole point of verifying them. The audit panel shows who did what, when, and from where.
- There is now a check that fails when an operator-facing endpoint has no control, no element to render into, or is not loaded when its page opens.
gpunode 1.30.0 β The Node Documents Itself
Shipped with PlayoutGo v2.40.0.
New
- [NEW] An API reference at
/docs. The node had none at all: fifteen routes, and every integrator read the source and got it wrong in a different way. Served without a credential, because there is nothing here an operator should have to authenticate to read and a credential is a poor gate on a reference someone needs before they have one. - What it documents is the reasoning rather than the shapes, since a signature already gives the shapes. Which figure to schedule against and why a slot count is the wrong one. Why the overlay scale is the caller's to choose and must be sized against the shortest rung. That
-slotsis per card, so a two-card node runs double what it says. That a job the node has never heard of comes backlostrather than404, and what that distinction buys after a restart. How to separate credentials without a flag day. - A check now reads every
/v1route and fails if the reference omits one β and separately asserts that the five things an integrator gets wrong uninformed are all still explained.
v2.39.0 β Twenty-Eight Undocumented Endpoints
Documentation
- [FIX] High: Twenty-eight of forty-nine routes were undocumented β the entire encoding surface, application keys, the whole application API a television app authenticates against, as-run, the playback proxy token, and the QR re-encode endpoints that this session built. An endpoint nobody has written down is one an integrator cannot use and a support call nobody can answer.
- Every route is now described with what it does and why it behaves as it does β the reasoning that is not recoverable from a signature. Nothing documented was fictional, which is the failure that would have been worse: an endpoint someone builds against and discovers does not exist.
- There is now a check that reads every registered route and fails if the documentation omits one, and reads every documented path and fails if no route serves it. It runs in the ordinary suite, so the two cannot drift again.
routes documented 49 / 49 endpoints rendered 73 markup balanced yes
v2.38.0 β The Summary Was Missing Three Fields
Fixed
- [FIX] High β introduced in v2.35.0: The summary payload dropped three fields the interface actually renders. Channel descriptions vanished from every card, and every SRT channel showed the wrong direction badge β the code falls back to "caller" when the mode is absent, so a listener channel displayed as a caller with the wrong connection instructions beneath it. An operator reading those would have dialled out to a channel waiting to be pulled from.
- The cause was trimming by judgement: I chose which fields "looked" needed rather than deriving the list from what the interface reads. A summary is only safe when it comes from the caller's actual use.
- There is now a check that extracts every field the grid reads and fails if the summary omits any of them. It would have caught this before it shipped, and will catch the next field added to a card.
summary 189 bytes/channel (8 fields, all of them used) full record 803 bytes/channel (32 fields)
v2.37.0 β Writes Queue Instead of Colliding
Performance
- [FIX] Medium: Concurrent writes collided on SQLite's write lock. SQLite permits exactly one writer; with a pool of connections all attempting writes, the losers retry with backoff until the busy timeout expires. Measured at twenty concurrent writers: most finished in twenty milliseconds and the unlucky ones took six hundred β not doing more work, losing a race repeatedly. Writes are now serialised in Go, so each waits its turn once. Transactions take the same queue, since they hold the write lock for their whole life.
- Reads are deliberately untouched: WAL lets them proceed alongside a writer, and holding them back would invent contention that does not exist.
20 concurrent writers Γ 15 writes before p50 20ms p95 609ms max 790ms 272/s after p50 21ms p95 532ms max 659ms 290/s
What the work nearly introduced
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
- [FIX] GPU-SEC-002: One token granted everything. A client needs to submit work and read its own jobs. Handing it the same credential that opens
/v1/clientsβ which lists every system using the node, their addresses and their consumption β means every tenant on a shared node can see every other tenant's usage. None of them did anything wrong to get that, and it is not what any of them were given a token for. - No migration window is needed. This was listed as requiring one, and it does not: the existing token keeps every power it has, so an installation that does nothing is completely unaffected. Setting
GPUNODE_OPERATOR_TOKENis what activates the separation, at which point the submit token keeps/v1/healthand/v1/jobsβ a dispatcher still works β and loses only the cross-tenant telemetry. The split is a decision rather than an outage. - A credential that is real but insufficient gets 403 rather than 401, because telling a client to check a token that is fine wastes an afternoon.
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
- [FIX] High: The channel listing carried forty fields where the interface renders six. On two hundred channels that is 158 KB every poll, per operator, almost all of it discarded on arrival. A summary is now sent to the polling path β the editor still asks for the whole record, so nothing loses information it was using.
- [FIX] Medium: The listing took the playout lock once per channel. Two hundred acquisitions per request, and that is the same lock the engines take to start, stop and swap items β so a busy listing page was competing with the channels actually going to air. Live figures are now gathered in one pass, with the manager's lock released before each engine is read: holding it across the gather would restore exactly the contention it removes.
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
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
- [FIX] High β found while implementing the deduplication recommendation: The source cache grew without limit. Nothing ever removed a cached master, and the cache grows faster the better deduplication works, because success is measured in sources retained. On a node encoding a library that is a slow, silent fill of the volume the encodes themselves need β ending in failures that look like a disk problem rather than a cache with no ceiling.
- Eviction is by last use, not by arrival, so a master encoded weekly stays and one nobody has touched in a fortnight goes. A source held by a running or queued job is never removed: deleting one under an encode produces a failure that looks like a corrupt file.
- [FIX] GPU-PERF-001: The forwarded header was believed from any peer. Any client could name itself whatever it liked β attribute its usage to a neighbour, appear as several clients, or disappear entirely. Telemetry is what an operator uses to decide which system is loading a shared node, and a figure the subject controls cannot inform that decision. It is now read only from a proxy on the same host or network, or one named in
GPUNODE_TRUSTED_PROXIES.
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
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
- [NEW] Every goroutine either binary starts is guarded. Forty-four in total: the encode workers, the health probes, the reaper, the job persistence, the completion callbacks, the reverse-DNS lookups, the shutdown handler and every background loop. A panic is logged with its stack β one that quietly recovers is one whose failure nobody investigates β and the work carries on.
- [NEW] A panic in a request handler becomes a 500 rather than a dropped connection. Go's server already stops such a panic from killing the process, but it drops the connection: the caller sees a network failure and retries, which for an interface polling every three seconds is a stream of failures with no explanation. The guard is applied at the mux rather than per route, because a route added later without it is exactly how a guard stops covering what it was meant to. The panic value is not returned β it can carry internal detail, and a stack trace in a browser is a disclosure.
- Long-lived tasks restart after a fault rather than stopping at the first one. A worker that fails immediately is delayed slightly, so it cannot fill the log and burn a core.
Verified
100 malformed requests across both services playout: RUNNING Β· answers HTTP 200 gpunode: RUNNING Β· answers HTTP 200
v2.32.0 β A Fixed Bug That Came Back Wearing Different Clothes
Fixed
- [FIX] High: Six background workers ran with no panic recovery. A panic in any goroutine takes the whole process, which for this service means every channel off air because a housekeeping task met a nil pointer. The reclaimer, the backup writer, the encode dispatcher, the queue's own persistence and both resource guards were all exposed.
- The playout engine already recovered, having once killed the server. That fix was applied where the problem happened rather than to the pattern β so every background worker added since has been unprotected, which is how a fixed bug comes back wearing different clothes.
- A panicking worker is now logged with its stack and restarted, because one that quietly recovers is one whose failure nobody investigates. The restart is delayed slightly so a worker failing instantly cannot fill the log and burn a core.
What else was checked
v2.31.0 β An Audit Trail That Works
Fixed
- [FIX] High: The audit trail was called from five places and never wrote anything. Handlers recorded channel starts, stops, deletions and suspensions through a function whose backing type did not exist β the table was created, the calls were there, and every one was a no-op. Nothing in the interface said so, and the first anyone would have known is when they went looking for a record that was never kept.
- The writer and reader are now complete and tested:
GET /api/admin/auditshows who did what, when, and from where. Verified live β a start, a stop, a suspension and a deletion all appear with the account and address that made them. - The address recorded is the direct peer, not a forwarded header. A header is set by whoever is making the request, and a trail recording what the subject claimed about themselves is worse than one recording nothing, because it looks authoritative.
v2.31.0 β An Audit Trail
New
- [NEW] Actions that change something are recorded with who did them. When a channel goes off air the first question is whether someone changed something, and until now there was no way to answer it. The as-run log says what played; the server log says what the software did. Neither says that an operator edited a rundown four minutes earlier, or which of three people with administrator access stopped the channel.
- Channel starts, stops and deletions; media deletions; account suspensions and restorations.
GET /api/admin/audit, newest first, filterable by action or by person β because "what did this person do" and "who stopped channels" are the two questions actually asked. - Deliberately narrow: not a request log, which already exists and nobody reads. Kept ninety days β long enough to answer "what changed before this started" for a fault noticed late, short enough that the table does not grow without limit.
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
- [NEW] Every snapshot is opened and read before it is kept. Silent corruption is the classic way backups fail: the file is written, it is the right size, it appears in the listing, and it is unreadable β which is discovered at the one moment nothing else is working either. A snapshot that fails is discarded rather than retained, because a bad one is worse than none: it satisfies the retention count and displaces a good one, so the operator believes they are covered right up to the point they are not.
- The listing reports what the newest snapshot actually holds β "2 accounts, 1 channels, 1 files" β rather than only that a file exists. Only the newest is checked on each view: opening every snapshot on every page load turns a status page into disk work, and the older ones were each verified when they were written.
- Verification opens the file read-only and immutable, so it neither modifies what it is verifying nor leaves journal files beside it β which would leave the pruner and the listing disagreeing about what is in the directory.
What the tests found
v2.29.0 β Automatic Database Snapshots
New
- [NEW] The database is snapshotted automatically. Everything that is not media lives in one file β every channel, rundown, account, app key, rendition record and as-run entry. Lose it and the media on disk becomes an unusable pile with no idea which channel played what: recoverable in principle, in days of manual work, while nothing is on air.
- The documentation said to copy the file. That is correct advice and it is not a backup: it happens when someone remembers, and the person who most needs it is the one who did not.
- A snapshot every six hours, the newest eight kept, and one taken shortly after startup β so a fresh install has one before anything can go wrong with it, and a misconfigured backup directory is discovered then rather than in six hours.
GET /api/admin/backupsshows what exists and how old the newest is;POSTtakes one before a risky change.
Why it is not a file copy
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
- [FIX] GPU-SEC-003: Console actions had no origin check. They change state and are authenticated by a cookie, which a browser attaches to a cross-site form post as readily as to one from the console itself β so any page on the internet could drain this node or cancel an encode on behalf of whoever was signed in, needing only that they visit it. The state-changing route is registered directly rather than through the wrapper, which is precisely where the check was missing and mattered most. Verified: a POST carrying another site's origin is refused with 403.
- [FIX] GPU-AUTH-001: Nothing limited how fast the console token could be guessed. It is the only credential the console has, and a script could try it as quickly as the node would answer, for as long as it liked. Eight attempts per address in a ten-minute window that expires on its own β an operator who mistypes their own token waits rather than needing the process restarted, and a correct sign-in clears the count. Verified: refused at the ninth attempt, with the wait stated.
- [FIX] GPU-SEC-004: A content policy on the console, which renders job names, client tags and card names that all come from elsewhere.
- [FIX] GPU-HTTP-001: Connections have an idle timeout and headers are bounded. Without the first, a peer that opens a connection and never speaks holds a descriptor indefinitely β and enough of them leave the node unable to accept a submission or read a source file.
gpunode 1.25.0 β The Silent Overlay Drop, and Capacity That Decides Itself
Shipped with PlayoutGo v2.27.0.
Fixed
- [FIX] GPU-OVERLAY-003: An overlay sent after the source was silently discarded. Worse than reported: every part after the streamed source was read as a form field, so the image was swallowed into the form map and truncated at the field limit. A client that sent the file first got a job that succeeded with no overlay in the picture and nothing anywhere saying so. Part order was load-bearing and silent β which works for whoever happened to write their client one way round. A late overlay part is now recognised as one. Verified: a submission with the file first burns the code in at 86 and 130 pixels across two rungs.
- The other half of the same failure: overlay settings arriving with no image β a wrong field name, a proxy that dropped it β are now refused rather than encoded without. Silence is the expensive part; a refusal costs one request and is unambiguous.
- [FIX] The card list was split on commas, the same fault the telemetry query had. Some card names contain one, and here the shift put part of a name into the memory column β which now sizes the node's capacity, so a mis-parse would have set concurrency from a fragment of text.
New
- [NEW] Automatic capacity. Started without
-slots, a node decides its own concurrency from what it actually has. A figure set by hand is a guess that ages: it does not follow a card being swapped, a driver failing over to the CPU, or a node rebuilt on different hardware. - A consumer card is held below the driver's NVENC session limit, because exceeding it does not queue β the encoder fails to open, so the cost of guessing high is a failed job and the cost of guessing low is a slightly idle card. Professional cards have no such cap and are sized by memory. A CPU fallback is sized by cores rather than by a card it is not using. On a mixed node the most conservative card decides, since the scheduler applies one figure to every device.
v2.26.0 β Suspension, and Files That Cannot Be Played
New
- [NEW] Account suspension.
POST /api/admin/users/{id}/suspendstops the account's channels immediately and refuses to start them again, with the reason shown to whoever tries. Nothing is deleted. The usual reason to suspend is a billing dispute, and a customer who then pays should not have lost their library over it β lifting the suspension brings the account back exactly as it was. - Checked in the start path rather than only in the interface, because the scheduler, the resume-on-startup path and the API all reach the same function β a suspension enforced in one place lifts itself the moment the server restarts. An administrator cannot suspend themselves, or nothing would be able to lift it, and cannot suspend another administrator, which would be a support call rather than a policy anyone chose.
- [NEW] A file that cannot be played is marked and skipped. A wrong file given an
.mp4name is an ordinary mistake, and it used to leave the channel retrying the same file every pass β dead air on a loop, with nothing saying which file was responsible. It is now marked with a reason an operator can act on, the scheduler stops offering it, and the account is told which file to replace.
Confirmed already correct
- Deleting a user stops their channels and removes their files. Channels are stopped first, the file paths are noted before the cascade runs β once the rows are gone there is no way to find them again β and uploads, renditions and logos are removed from disk.
- Originals are never modified. An encode reads the source and writes elsewhere; the channel plays the rendition when one exists and the original when it does not.
- The switch to encoded content is already smooth. The rendition is chosen when each item begins, so a channel started before encoding plays the original and picks up the encode at the next item boundary β no restart, no interruption mid-item.
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
- [FIX] High: A media file could be deleted while encodes of it were queued. The in-use guard predates the queue, so it checked only what was on air or scheduled. The dispatcher would later pick the work up, find nothing, and log a failure the operator had caused an hour earlier without being told it was related. Queued and running encodes now count as use.
- [FIX] High: Housekeeping could delete a directory an encode was writing into. A file deleted mid-encode makes its output an orphan by the reclaimer's reckoning, and the encoder is still writing there β which fails late, confusingly, and looks like a node fault rather than housekeeping. Output is now left alone while anything is encoding for it, and collected on a later pass.
- [FIX] Medium: Deleting a channel left its queued encodes in the queue. A node would spend a slot encoding for a channel that no longer existed, the output would be reclaimed later, and another tenant waited for that slot in the meantime. Queued work is dropped with the channel; an encode already running is left to finish, because killing it wastes what it has already done.
gpunode 1.24.0 β The Slot Limit Is Per Card, and Now Says So
Shipped with PlayoutGo v2.24.0.
Fixed
- [FIX] High β introduced in 1.16.0: The concurrency control was labelled a total across cards; the limit is applied per card. The scheduler places each job on the least-busy device and holds each device to this figure, so a two-card node set to four runs eight at once. An operator asking for four got double, which on consumer cards can exceed the driver's own NVENC session limit and fail at encode time rather than at the setting where it could be understood.
- The label now says per card and shows what it comes to on this node β "4 per card β 2 cards, 8 at once in total" β because the two figures differ on every multi-card machine and only one of them is what the operator meant. The startup log says both as well.
Confirmed working
- Work does spread across cards. Capacity is tracked per device, each job takes the least-busy one, and the choice reaches FFmpeg through the environment rather than a flag β that last part was G-001, where the right card was chosen and never passed on, so everything ran on card zero while the rest idled.
- A test now covers the whole chain: four jobs across four cards land on four different cards, the next four fill each second slot evenly, freeing one sends the next job there, and the reported capacity is the total rather than the per-card figure β that being what a client scheduling across several nodes compares.
v2.23.0 Β· gpunode 1.23.1 β A Race in This Session's Own Work
Fixed
- [FIX] High: The clock counters were read by the metrics handler while the output path wrote them. Introduced in v2.6.0 with the clock scheduler and exposed in v2.19.0 when the metrics were added β the constant-rate pacer beside it locks, and this did not. The test suite never caught it because no test fetched
/metricswhile a channel was running, so the two goroutines never met. - Under Go's memory model a racing reader can observe a torn value rather than merely a stale one, which for these counters means an alert could fire on a number that was never true β the specific failure a monitoring system exists to prevent.
Regression protection
- [NEW] A probe on each side that deliberately runs the output path and the reporting path at once, because that combination is what the ordinary suite does not produce. The equivalent bookkeeping in gpunode β outstanding work, the measured rate, admission and fair queueing β was checked the same way and is clean.
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
- [NEW] Admission by cost. A slot is a slot whether the job is a six-second clip or a three-hour feature, so a count-based limit let a node accept ten features and report itself healthy while nothing would start for a day. Work is now judged by duration times rungs, and a node holding more than about six hours of it says so and sends the caller elsewhere.
- [NEW] Estimated wait in health.
acceptingsays whether work is taken; it does not say when it will run, and a dispatcher choosing between three nodes needs the second. The figure is measured from what this node has actually finished β the rate differs by an order of magnitude between a 4090 and a CPU fallback β and is reported as unknown rather than guessed when nothing has completed yet. A dispatcher will act on this, so a guess is worse than silence. - [NEW] Completion callbacks. An optional address per job, notified when it reaches a terminal state, so a client need not poll at all. Two attempts, seconds apart: more would be a queue, and a queue of callbacks to an address that is down keeps a dead client's problem alive inside this node.
On the callback address
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
- [NEW] Per-owner fair queueing. Slots were first-come, so a system submitting forty jobs held every one until they drained and everyone else waited behind them β on a node shared between VODOTT, PlayoutGo and an EncodeBox install, that is one customer's batch stopping another customer's channel. Work is now taken from whichever system has waited longest since it last started something. Measured with two systems and one slot: the second system's work interleaves from the first free slot rather than waiting for all four of the first's.
- A node used by a single system behaves exactly as before β with no competition the rule is always satisfied, so nothing gets slower to support something it is not doing.
- [NEW] Batch status.
GET /v1/jobs?ids=a,b,cfor exactly those,?owner=xfor one system's own work, or neither for everything. A client polling forty jobs made forty requests every few seconds; forty clients doing that is sixteen hundred requests every five seconds against a node whose actual work is encoding. The cost was all in the asking. - An id the node has no record of comes back as
lostrather than being omitted β a caller comparing forty ids against what it received should not have to work out which went missing and why. The id list is bounded, because an unbounded one makes a single request cost what the batching was meant to save.
gpunode 1.21.0 Β· PlayoutGo v2.20.0 β The Overlay Size Belongs to the Caller
Fixed
- [FIX] Blocking for the QR feature: The node applied a fixed eleven percent to every rendition. That is about ninety pixels at 1080p and sixty at 720p, both of which decode β and only forty at 360p, roughly one pixel per module once the scaler and the codec have been through it. Reproduced here: at eleven percent a 360p rendition does not decode at all.
- The fraction is now supplied with the job. Only the caller knows what the image is: a QR code has a symbol size and a floor below which it stops working, while a logo has neither and sits happily at three percent. The node cannot tell them apart and must not choose for either. Verified: the same source and overlay at 17.8 percent decodes where eleven does not.
- PlayoutGo sizes the fraction against the shortest rung rather than the tallest, so the code stays readable on the smallest rendition in the ladder β larger than strictly needed on the big ones, which is the right way round. A slightly large code is untidy; a small one does not work.
- The overlay is scaled with nearest-neighbour rather than interpolation. Smooth scaling blurs the edges of a code's modules, which is precisely what stops it decoding once the encoder has compressed it.
- Heights are rounded to even numbers: an odd dimension makes the scaler round and the modules land on half-pixels β how a code that decodes on the master fails on air.
v2.19.0 β Broadcast Health in Monitoring
New
- [NEW] Constant-bitrate and clock health are exported to Prometheus. The endpoint predates this session's broadcast work, so a channel could be padding badly or dropping clock references with nothing outside the interface saying so.
playoutgo_channel_pcr_late_totalcounts clock references that arrived beyond the forty milliseconds an analyser allows. Anything above zero is a feed that will fail an operator's ingest test β which is worth knowing before the operator tells you.playoutgo_channel_pcr_worst_gap_secondsgives the longest gap seen.playoutgo_channel_cbr_padding_bytes_totalalongside the delivered bytes shows how the pipe is being used: rising much faster than content means the rate is far above what the picture needs and bandwidth is being paid for and discarded; barely rising means peaks are being clipped and the rate is too low.
v2.19.0 β Metrics
New
- [NEW] A Prometheus endpoint at
/metrics. A broadcaster watches channels from a monitoring system, not by keeping a web page open β everything here was visible only in the interface, so a channel that stopped delivering at three in the morning was noticed at nine. - What it exposes is deliberately narrow: whether each channel is on air, whether its segments are actually reaching disk, bytes delivered, queue depth, and how many encoding nodes are answering. A metric nobody alerts on makes the useful ones harder to find.
- The pair worth alerting on together is
playoutgo_channel_upandplayoutgo_channel_delivering: a channel that is up and not delivering looks healthy from every other angle, which is the failure this system exists to catch. - Off until an operator names who may scrape it. Channel names and delivery figures are not something to publish by default, and an endpoint that appears without anyone asking is one nobody has thought about. Not behind a session either β a monitoring system is not an API caller, and requiring a token would mean putting a credential in a scrape config.
Hardened
- Channel names are escaped in labels. A name is operator-supplied and can contain a quote, which produces a malformed exposition line β and Prometheus rejects the whole scrape on one bad line, so a single oddly-named channel would blind the monitoring for every other.
- The adaptive-bitrate path now refuses an empty rendition set rather than indexing into it. The only caller checks, but this is a panic that takes every channel down rather than one failing to start, and the call site is one edit away from changing.
v2.18.0 β The Last Finding
Fixed
- [FIX] Medium: A failed load was rendered as an empty library. Anything that was not an array became an empty one, so a network blip, an expired session and a server error all produced the same screen β and that screen invites the operator to create their first channel. On a running system it is alarming and wrong: the channels are on air, the request simply failed.
- When a list is already loaded the failure is a banner over it rather than a replacement, because discarding a known-good list to display an error throws away what the operator had. When nothing is known it says the load failed and offers a retry, instead of the first-run invitation, which would be a lie about the system's state. Both clear once loading works again.
v2.17.0 Β· gpunode 1.20.0 β An Interface That Stays Still
Fixed
- [FIX] Medium: The channel grid was rewritten every three seconds whether or not anything had changed. That throws away focus, closes any open menu, resets scroll inside a card and restarts every transition β an operator typing in a card had the field taken from under them on a timer. The markup is now rebuilt only when something it shows has actually moved.
- [NEW] A phone layout. The interface stopped adapting below 1150 pixels, so on a phone the channel grid demanded 360-pixel columns inside a 390-pixel viewport: cards overflowed and the page scrolled sideways, taking the navigation off the edge. Checking a channel from a phone is a normal thing to do, usually when something is wrong and nobody is at a desk. Tables now scroll within themselves, forms fall to one column, and inputs are sized so that focusing one does not make the browser zoom.
- [FIX] Medium: Dialog close buttons render as a bare Γ β announced as "times" or as nothing β and now carry a label. The notification area is a live region, so a result that appears without focus moving is actually announced rather than shown silently. The tab strip is a navigation landmark.
- [FIX] Medium β gpunode: The GPU charts were built once from the first response. A node whose cards were absent from that very first poll β a driver still loading after a reboot, a card that had briefly fallen off the bus β showed no GPU charts for the life of the page, and reloading was the only way to find they had come back.
v2.16.0 Β· gpunode 1.19.0 β Box Boundaries, Method Defaults, Node Permissions
Fixed
- [FIX] Medium: MP4 box walking did not enforce child boundaries. Only the start of each box was checked against its parent, so one declaring a size that reached beyond its parent was accepted and the walk continued from wherever that landed β reading the next "box" out of whatever happened to be there, which on a crafted file is content it chose. Sizes come from the file and are claims, not facts.
- Three further faults in the same walk: the region's end could overflow to a negative number, which makes every bounds check pass trivially; a container whose declared size was smaller than its own header produced a negative extent that silently held nothing; and the recursion had no depth limit, so a file declaring containers nested a million deep was a stack overflow rather than an error. Real media nests about five.
- [FIX] Medium: Six handlers had no answer for an unexpected method, falling through to a 200 with an empty body β so a typo in a method looked to the caller like a request that worked and did nothing.
- [FIX] Medium β gpunode: The work directories were world-readable. They hold customers' media in transit and encoded output before it is collected, so on a shared host any local account could read everything passing through the node. Playlists, which are meant to be served, are deliberately left alone β and there is a test asserting that, because a blanket permissions change would have swept them up.
v2.15.0 Β· gpunode 1.18.0 β Lockouts That Lift, Polls That Do Not Lie
Fixed
- [FIX] Medium: The emergency-reset lockout never expired. Five wrong guesses locked that address out until the process restarted β and the message said to restart it. On a playout server that means taking every channel off air to lift a rate limit, which is a worse outcome than the attack it prevents. An operator who mistypes their own recovery secret should wait, not fail over. The window is fifteen minutes and the refusal says how long is left.
- [FIX] Medium: Polling could apply an old answer over a new one. Requests overlap whenever a response takes longer than the interval, and they do not return in order β so a slow answer from three seconds ago could overwrite current figures with stale ones. The display then showed the past and the next poll corrected it, which reads as numbers flickering between two values for no reason anyone can see. Requests are now sequenced and late answers dropped, with only one live poll outstanding at a time.
- [FIX] Medium: A failed job request was rendered as an empty job list. A node with work in progress looked idle, and an operator deciding whether to drain it for maintenance decided on that. The last known list now stays on screen under a notice saying it may be out of date β which is true, where an emptiness was not.
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
- [FIX] Medium: The master playlist derived each rendition's width by assuming sixteen by nine. A 4:3 archive, a vertical clip or an anamorphic transfer was described to the player as a shape it is not β and a player choosing a rendition by resolution then chooses on a wrong number. The source's own dimensions are now carried through and the stated width follows the picture. Verified against a real 4:3 encode: the playlist says 480x360 and the file is 480x360, where the assumption would have claimed 640x360.
- When the source could not be probed the resolution is omitted rather than guessed. A player treats an absent resolution as unknown and chooses on bandwidth, which is correct; a wrong one makes it choose on a lie.
- [FIX] Medium: The encryption key URL was written into every playlist unchecked. A newline ends the URI line early and turns whatever follows into a tag the player obeys β an injected key pointing somewhere else, or a discontinuity that desynchronises the stream. That is not a parser quibble: the player is doing exactly what the file tells it. Control characters and quotes are refused, and a scheme no player will fetch is rejected at submission rather than discovered not to work on air.
v2.13.0 Β· gpunode 1.16.0 β Leaks and Limits
Fixed β gpunode
- [FIX] Medium: Every finished rendition left a goroutine and a live six-hour timer behind. The watcher that kills an encode on cancellation waited on a timer it could not stop and had no exit for an encode that finished normally, so a node doing a few hundred encodes a day accumulated thousands of both β each holding its command and its job. The watcher now stops when the encode does.
- [FIX] Medium: Waiting for a slot spawned a goroutine every half second. A job waiting an hour created seven thousand of them, and each one woke every other waiter, which then each spawned another. A release already announces itself; one ticker for the process now covers the case where capacity changes without one.
Fixed β PlayoutGo
- [FIX] Medium: Artifact extraction was bounded per file but not in total. Nothing stopped an archive of ten thousand individually acceptable files from filling the volume the channels on air are writing to. A node is trusted, but a faulty one is not malicious and still does the damage. Totals are now checked on each header, before anything is written β refusing afterwards would leave most of it on disk.
v2.12.0 β Medium Findings: Origins, Passwords, Permissions, Honest Failures
Security
- [FIX] Medium: Every route answered with a wildcard cross-origin header, including the authenticated ones. The wildcard is not what grants access β the token is β but it is what removes a browser's objection to any page on the internet trying. Permitted origins are now configured, matched exactly rather than by suffix, and absent by default: an installation serving only its own interface allows nothing, which is right for an operator who has not thought about it.
- [FIX] Medium: Uploads and encoded output were world-readable. On a shared host any local account could read every customer's media, and on a reseller platform that is one customer reading another's programming. Files are created restricted rather than tightened afterwards β a file that is briefly readable is readable for as long as it takes something to look.
- [FIX] Medium: The password-change path accepted six characters where registration required eight, so anyone could set a password the same system would have refused at sign-up. The rule now lives in one place: written out at each site, it had already drifted.
Fixed
- [FIX] Medium: Deleting a channel or a file reported success whatever the API answered. A refusal β the channel is on air, the file belongs to someone else β was shown as "deleted", leaving an operator believing something was gone that was still running.
v2.11.0 β Paged Listings
Fixed
- [FIX] High: The whole media library was returned on every request. On a small installation that is invisible; on a large one it is megabytes of JSON built, serialised, transferred and parsed so a browser can render the first twenty rows β and the query competes for the same disk the channels on air are reading from.
?limit=and?offset=now return a page with the total, so a caller can show how much more there is. - The response shape is unchanged when no page is asked for, so an existing caller expecting an array still gets one. Only a caller that asks for a page gets the wrapped form β an endpoint that quietly changes shape breaks every integration built against it.
- A request for everything gets a large page rather than the unbounded query this replaced, and the ordering is stable so paging through cannot repeat one file and skip another.
This closes the last High finding from the audit. Twenty-three Medium findings remain.
v2.10.0 β The QR Workflow, Proven
Fixed
- [FIX] Critical to the feature: An encode job never recorded which channel it belonged to, so it could not find that channel's QR code. The overlay was silently dropped and the encode reported success with nothing burned in β the worst shape of failure, because every indicator says it worked and only the picture disagrees. Jobs now carry their channel, which also lets a tenant be shown only their own encodes.
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.
v2.9.0 β The QR Workflow, End to End
New
- [NEW] A channel's QR code and its position are set in the interface, with a live preview rendered as you type β so an operator can scan it with their own phone and check it opens what they expect before committing to re-encoding a library.
- [NEW] One button re-encodes the whole channel. Change the address, press it, and every file the channel plays carries the new code. Each file is queued once however many times it appears in the rundown β a file scheduled four times needs encoding once, and queueing it four times would waste three encodes and delay everyone else's work behind them.
- Work goes through the queue rather than being dispatched immediately, so a large channel does not need every node free at once, survives a restart, and takes its turn fairly against other tenants. Files still being processed are skipped and counted rather than encoded from something nothing has verified.
- A channel that plays original files is told plainly that there is nowhere to burn a code in, rather than queueing work that cannot carry one.
v2.8.0 Β· gpunode 1.15.0 β QR Codes Burned Into the Picture
New
- [NEW] A channel can carry a QR code, generated from an address you enter. Change the address, re-encode the channel's files, and every one carries the new code. The encoder is written here rather than pulled in, so the build stays a single binary.
- Verified with a real decoder rather than by eye: the rendered code reads back as the address that produced it, and still does after H.264 compression at broadcast bitrates β which is the test that matters, since a code that survives on the master and not on air has failed.
On size, which is the whole question
- The code is about a tenth of the picture height. A phone needs roughly ten pixels per module to decode, and a viewer is two or three metres from a television with the code occupying a small part of the camera's frame β below a tenth it simply does not scan.
- Each rendition gets a code sized for it. One sized for 1080p and shrunk to 480p loses the pixels per module a decoder needs; one sized for 480p and stretched is blurred at the edges. Either way it stops scanning.
- Below 480p no code is drawn at all. Nothing would scan at that size, and covering part of the programme for no benefit is worse than leaving it alone. On a phone the picture is already too small for any burned-in code to be read from another phone β that is not a problem to solve, it is a reason not to pretend otherwise.
- The code is inset from the edge rather than flush against it: a television that overscans crops the outer few percent, and a code touching the edge loses the light border a decoder requires β failing on exactly the sets most likely to be showing it.
Fixed while building it
- [FIX] The shared single-decode path for multi-rendition MP4 builds its own filter chain and has no place for an overlay, so taking it silently dropped the code and produced an encode that looked successful with nothing in it. That path is now skipped when an overlay is present, at the cost of decoding once per rendition.
v2.7.0 Β· gpunode 1.14.0 β Broadcast Settings in the Interface, and a Shared Pool That Explains Itself
New β PlayoutGo
- [NEW] Constant bitrate and the DVB service tables are configurable per channel in the interface. They were reachable only through the API, which is not a feature an operator has. A channel now chooses standard output, constant bitrate, or constant bitrate with service tables, and the broadcast fields appear only for the mode that uses them.
- The rate is checked as it is typed against the encoder's own bitrate: too low says so and names the minimum, and a rate far above what the content needs reports how much of the pipe will be padding β bandwidth paid for and unused is a decision worth making deliberately.
- The service identifiers carry a plain warning that an operator allocates them, and that a field left blank is safer than one guessed: wrong values collide with real services in a receiver's channel list.
New β gpunode
- [NEW] An owner tag. With several systems sharing a node, listing jobs showed everyone's work. A caller passing its own tag now sees only what it submitted; an operator on the node passes none and sees everything. The tag is bounded and reduced to letters, digits, hyphen and underscore β it is echoed back and rendered on the console, and an unchecked caller-supplied string is how an injection starts.
- [NEW] An explicit "lost" state. A restart clears the job map, so a client polling an id it had legitimately been given got a 404 indistinguishable from a typo β it could not tell "re-dispatch now" from "your request is wrong", so it waited out a stall timer for work that was never coming back. An id of the shape this node issues is now reported as lost; one that could never have been issued still returns a client error, because answering "lost" to a typo would send a client into an endless re-dispatch of work that never existed.
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
- [FIX] Critical for broadcast: The clock value was read from the wall clock. That carries whatever the scheduler happened to be doing into the reference. Ordinary jitter is milliseconds; TR 101 290 allows five hundred nanoseconds. A wall-clock reference misses the requirement by a factor of thousands, and a receiver locking to it sees a clock that never settles β which shows as picture that breaks up periodically with nothing in the encoder's statistics to explain it.
- On a constant-rate stream the answer is arithmetic rather than observation: the byte at a given position arrives at a time the rate alone determines, so the reference describing it is exact. Measured across a second of stream, the deviation is 0.000 ns against a 500 ns limit β not because the timing improved, but because there is no longer any timing in it.
- Every byte now advances the clock β content, service tables and padding alike. A byte that went out uncounted would shift the clock from the stream by its own duration, and on a lightly loaded channel most of the stream is padding, so omitting it would have made the clock run badly slow.
- Variable-rate channels keep the wall clock, which is correct: there is no byte-to-time relationship to use, and a player de-jitters on arrival and does not care.
Regression protection
- [NEW] A test walks a second of stream and asserts the deviation against what the byte position demands, computed independently; that the clock never runs backwards, which would make a receiver resynchronise visibly; and that all three write paths feed it.
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
- [FIX] Critical for broadcast: The clock reference rode on video frames alone. At twenty-five frames a second that is exactly the forty milliseconds TR 101 290 allows β at the limit, with nothing left for jitter β and far worse whenever a frame is late, a group of pictures is long, or the stream is briefly audio only. An analyser reports that as a repetition error and an operator's ingest refuses the feed on it. The clock now goes out every thirty milliseconds on its own schedule, leaving a quarter of the allowance as margin, and is tied to the stream's timeline when each item begins so a reference sent between frames agrees with the frames either side of it.
- Gaps that do exceed the limit are counted rather than tolerated, so a stream that cannot hold the interval is visible here before an operator's analyser finds it.
New
- [NEW] NIT β the network information some operators require before accepting a feed, and which a receiver performing a network scan uses to find every service rather than only the one it is tuned to. Emitted every eight seconds, inside the ten the standard allows.
- Emitted only when a network has actually been allocated. The delivery descriptor is deliberately absent: the modulation parameters belong to whoever operates the uplink, and inventing them would describe a carrier that does not exist.
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
- [NEW] SDT β what the service is called and who provides it, so a receiver can list it. TDT and TOT β the current time and the local offset, which receivers use to set their clock and some require before they will tune at all.
- Emitted at the intervals ETSI TR 101 290 specifies rather than whatever was convenient: the service description at least every 500 ms, time and date every 25 seconds, both inside their limits with room for jitter. Repetition is not decoration β an analyser measures the gap between occurrences and raises an error when it exceeds the limit.
- The tables are placed on the output path, interleaved with content exactly as a multiplexer would place them. Sending them from a timer would land packets between others and break the continuity counters an analyser checks as a Priority 1 error.
- [NEW] Per-channel service configuration: service id, transport stream id, original network id, service and provider names, service type, country and local offset β with each validated against what a receiver will accept rather than stored and discovered to be wrong on air.
gpunode 1.13.0 β Constant Bitrate Encoding, and Telemetry You Can Trust
Shipped with PlayoutGo v2.4.0.
Fixed
- [FIX] High: GPU telemetry was split on commas while the query includes the card's name β and some cards report one containing a comma. Every numeric column after it shifted, so utilisation, memory and temperature were silently wrong. Those are exactly the figures a scheduler uses to choose a node: wrong telemetry does not make a scheduler fail, it makes it choose badly and confidently, which is worse and far harder to notice. Parsed as real CSV now.
- [FIX] High: A malformed line in the memory file panicked the telemetry goroutine and took the daemon down. In a pool that turns one unhealthy node into a re-dispatch storm across every other node.
New
- [NEW] Constant bitrate encoding. Submit with
cbr=1and the encoder is held to the rate rather than a ceiling β target, minimum, maximum and buffer all equal, with padding on undershoot. Variable rate remains the default because it is better picture for the same average and most delivery is to players that buffer; a fixed-rate pipe cannot carry a peak above its capacity, and the peak is where the picture breaks. - [NEW] The node says plainly whether it will accept work, and why not. A client had to infer it from five separate figures, each combined by every client for itself β and any client whose arithmetic differs from this node's own admission control either oversubscribes it or leaves it idle. With several systems sharing a pool they will not all agree, so the node's own answer is the only one that decides anything.
v2.3.0 β Constant Bitrate Output
New
- [NEW] A channel can hold its transport stream at a constant rate. A variable stream is fine over IP to a player that buffers, but not to the equipment a broadcaster actually connects to β a satellite modulator, an ASI card, a professional receiver, a multiplexer combining services into a fixed pipe. Those expect a constant rate, and a stream that varies either underruns their buffer or overflows it, which shows as picture breaking up at intervals nothing in the encoder's own statistics explains.
- Set
cbr_bpson a channel. Content goes out as it is produced and whatever bandwidth is left in each interval is filled with null packets, so the total never varies. The padding is deliberate waste β that is what the receiving equipment is built around.
How it behaves
- The budget is squared up every twenty milliseconds, not averaged over a second: a rate that is right over a minute but wrong over any given moment still underruns a receiver.
- Each interval budgets for the time that actually passed, so a scheduler that ran late does not leave a permanent shortfall β and a single burst is capped, so an interval that measured very long cannot produce a spike that is itself the problem.
- A stream already over its rate is not padded further: it needs a lower encoder ceiling, and padding on top would push it further past. The excess is carried so the next interval sends less rather than the stream drifting permanently above target, and the debt is bounded so it can always be repaid.
- A rate that cannot carry the encoder's output is refused when it is set, naming the minimum that would work β "a constant rate of 500 kbps cannot carry 2928 kbps of video and audio plus transport overhead β set at least 3220 kbps" β rather than underrunning silently on air.
v2.2.0 β App Keys Stop Competing With Playout
Fixed
- [FIX] High: Every app request performed a database read and a write. The read identified the account; the write recorded when the key was last used. SQLite has a single writer, so on a busy app grid β every client polling a channel list, a thumbnail and a guide β that write serialised behind itself and behind every other write the service needed to make, including the ones keeping channels on air. Keys are now resolved from a short-lived cache, and "last used" is recorded at most once a minute per key: it needs to be roughly right, not exact.
- Revoking a key clears the cache immediately. A cached key would otherwise keep working until its entry expired, which is precisely the window someone revoking a leaked key is trying to close. Failed lookups are cached briefly too, so a client retrying with a bad key cannot turn every attempt into a database read.
Confirmed closed on review
- P-051 β remote job coordination is no longer memory-only. Running jobs are recorded on disk and, after a restart, their node is asked before anything is assumed: work still encoding is left alone, work that finished while the server was down is collected, and only work the node no longer knows about is re-queued. That landed in v1.95.0 and is verified by the state file appearing during a live run.
v2.1.0 β Replacements, Races and Resume Offsets
Fixed
- [FIX] High: Generating a replacement rundown emptied the old one first. A failure part way through β no media matching the filters, a database error, a generator that produced nothing β left the channel with an empty rundown and no way back. On a channel already on air that is dead air caused by an action that failed. The new rundown is now built alongside the existing one and the old items removed only once the replacement exists, with positions renumbered so the result is contiguous.
- [FIX] High: A channel could be started while it was being stopped. The intent to stop was written to the database before the lock was taken, so a scheduler tick already inside the start path could add an engine between the two β leaving a channel on air that the database said was stopped, which nothing would then correct. Stopping is now claimed under the lock and a start is refused while it holds.
- [FIX] High β introduced in v1.60.0: A resumed download did not check where the server actually resumed from. The offset was parsed and discarded, so a response beginning somewhere other than requested was appended at our position: the file ended with a gap or an overlap, at exactly the right length, and the corruption appeared only when something tried to read the archive. A mismatch now starts the transfer again.
gpunode 1.12.0 β Probing Once, and an Honest Capacity Control
Shipped with PlayoutGo v2.0.2. This completes the gpunode findings.
Fixed
- [FIX] High: The encoder was probed on every call. Deciding which encoder to use runs a real test encode, and it was asked by the health endpoint, by the console on every render β twice β and by every job. A node being polled by its caller while someone watched the console was therefore launching several FFmpeg processes a second, each competing for the GPU it was trying to describe. The answer cannot change while the process runs, so it is now decided once. Verified: forty requests produced one probe.
- [FIX] Medium: The capacity control offered a range the server would not honour. The input accepted up to thirty-two while the server silently reduced anything above sixteen, so an operator asking for twenty got sixteen with nothing said. The range now matches, and a value outside it is reported rather than quietly changed.
- [FIX] Medium: The control did not say whether the number was per card or in total. It is a total across every card β the scheduler places each job on whichever is free, so a per-card figure would not describe anything it does β and it now says so.
v2.0.1 β Security Headers
Security
- [FIX] High: No security headers were sent at all. The session token lives in the browser, so anything that can run script on this origin can take it β and the interface is a single page of inline handlers, exactly the shape a content policy exists to constrain.
- Content-type sniffing is now disabled, which is the shortest path from "stored a file" to "ran script on this origin"; framing is refused; referrers are suppressed, because URLs here carry scoped tokens for players and thumbnails and a referrer would carry them onward; and camera, microphone, location and payment are denied, none of which this needs.
- The content policy permits script only from this server and the one library the page loads, and forms may post nowhere else β so an injected script tag or an exfiltrating form does not run. Inline script is still allowed, because the interface is built from it; removing that requires restructuring the page and is separate work. What the policy achieves is nonetheless real.
Regression protection
- [NEW] A test asserts every header is present and meaningful β a wide-open policy would be worse than none, because it looks like protection β and reads the interface to confirm everything it actually loads is permitted, so adding a library without updating the policy fails here rather than by breaking the page.
v2.0.0 β Every Output Write Is Checked
Fixed
- [FIX] High: Gap, ad-marker and slate writes discarded their errors. The sample path already dropped a dead SRT connection on failure, but these three did not β so a connection that had gone away kept being written to indefinitely, every write failing silently while the channel reported itself healthy. These are precisely the paths that run when nothing else is happening, so a channel filling dead air could sit writing into a closed socket for hours. All output now goes through one checked path that drops the connection and counts what it sent.
- [FIX] Medium: A failure sending the program tables was discarded too, leaving a connection that could not receive them looking healthy.
v1.99.0 β Fixed Ad Timestamps, Bounded Requests, Indexed Queries
Fixed
- [FIX] High β introduced in v1.55.0: An ad break's start time moved every time the playlist was rewritten.
START-DATEwas generated at write time rather than recorded when the avail began, and the playlist is rewritten several times a minute β so a player tracking the break saw it shift continuously forward, and an SSAI system correlating by id and time could not match it to anything. The segment now carries the moment its avail actually started. - [FIX] High: Interface requests had no timeout.
fetchhas none of its own, so a hung connection β a network gone away without closing, a server mid-restart β left the promise pending forever. Anything guarded by an "in flight" flag then stopped polling permanently, and the page looked alive while showing figures frozen at whatever they had been. Every request is now bounded, and a timeout says so rather than reporting "the user aborted a request", which explains nothing. - [FIX] High: Common queries had no supporting indexes. Listing a library, resolving a file's renditions on every playback decision, and finding a tenant's channels each scanned their whole table β invisible on a small installation, the difference between a page that loads and one that does not on a large one, and competing for the same disk the channels on air are reading from.
v1.98.0 β The Queue You Can Actually See
New
- [NEW] Waiting work is shown in Admin, with each item's position, how long it has waited, and a marker on anything needed shortly. The queue has existed since v1.93 and was rendered nowhere, so an operator could not tell a queue that was moving from one that had stalled β the difference between patience and a problem.
- [NEW] How long the queue will take to clear, from what encodes have actually cost on this fleet rather than any assumption. "Clears by lunchtime" and "still going tomorrow" call for different decisions, and there was no way to tell them apart.
- [NEW] Queued work can be removed without waiting for it to start. A tenant may only cancel their own, and an item belonging to someone else answers exactly as one that does not exist β so the endpoint cannot be used to discover what other customers have queued.
- [NEW] Finishing an encode announces itself. A user with a long batch had to keep the page open and watch. Notices now appear as they happen, and a first load does not replay the whole backlog at once.
- Polling continues while work is merely queued, not only while something is running β a queue waiting for capacity is exactly what an operator wants to watch.
v1.97.0 β Urgent Work First, and Being Told When It Is Done
New
- [NEW] Work needed sooner is encoded sooner. A file due on air this evening and one being re-encoded as housekeeping were taken in order, so the urgent one waited behind two hundred that nobody was waiting for. Urgency is now derived rather than asked for: a file scheduled within the hour, or on a channel already running, goes first; one scheduled within the day comes next; everything else follows. Fairness between tenants and oldest-first still apply within each level, so an urgent item cannot be used to jump a queue indefinitely.
- Deriving it rather than offering a priority field is deliberate. Someone setting a priority has to predict which encode will matter, which they cannot do reliably β and would then have to keep revising it as the schedule changed. The schedule already knows.
- [NEW] Completion notices. Nothing told anyone an encode had finished, so a user with a batch had to keep the page open and watch or come back and guess β for work that takes hours, that is either wasted attention or wasted time.
GET /api/encode/noticesreports what finished and what failed, with the reason and the channel it belongs to. Each account sees only its own; notices expire after twelve hours, because this is "your encodes finished", not an audit trail β the as-run log is where history belongs.
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
- [NEW] Encodes show how long they have left. A percentage answers "how far along", which is not the question an operator has β "can I schedule this for tonight" is, and without an estimate the only way to find out was to watch. An early figure says so rather than being dressed up as a promise: the first moments include probing and upload, which are nothing like the encode that follows, and a confident wrong answer costs more than an honest vague one. Precision is kept to what an extrapolation can support β "about 2h" rather than "1h58m12s".
- [NEW] Encoded output whose media no longer exists is reclaimed. Nothing ever deleted renditions, so an installation that re-encoded a library a few times filled its volume over months β and the space guard would then pause encoding permanently, which is safe but not self-sufficient. Orphaned output is now removed hourly, and immediately whenever encoding has paused for space, which is precisely when reclaiming might let it resume without anyone being called.
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
- [NEW] Work in flight when the server stops is recovered. The queue survived a restart but running encodes did not, so a deploy during a batch silently lost whatever was mid-encode. Each running job is now recorded with its node and remote id. On startup the node is asked before anything is assumed: a job still encoding is left alone, one that finished while the server was down is collected rather than encoded again, and only work the node no longer knows about is re-queued.
- [NEW] Encoding pauses when the disk is nearly full, and resumes by itself. A ladder is several times the size of its source, so a large batch could fill the volume the channels on air are writing to β turning a housekeeping task into an outage. Dispatch now pauses rather than failing the queue: space is usually recovered, work that waits can still be done, and an outage cannot be undone.
- [NEW] An alert is raised only for what needs a person. No disk space, no reachable node with work waiting, a job that has failed on every node. A queue that never moves looks exactly like an idle one from outside, which is why that case has to be said out loud. Alerts are rate-limited β one that repeats every minute is as unreadable as none.
Fixed
- [FIX] High: Enumerating nodes dereferenced the database without checking it existed. Background watchers can run before it is ready, and a nil dereference there takes the whole service down rather than skipping one check.
v1.94.0 β Robust Encoding Across Nodes and Tenants
New
- [NEW] A node that stops responding mid-encode no longer loses the job. Six failed status checks used to fail the work outright, so one machine rebooting cost every job on it while other nodes sat idle. The job is now returned to the queue, marked with the node that lost it, and dispatched somewhere else. It only fails for good when no other node could take it.
- [NEW] The queue is fair across tenants. Strict age order let one customer queueing two hundred files hold everyone else behind them β their own work unaffected, everyone else's stopped. Work is now taken from whichever tenant has waited longest since their last dispatch, oldest first within each. A large batch still finishes; it interleaves rather than monopolises.
- [NEW] Delivery is a choice per channel, not a text box. An empty field could not distinguish "use the server's CDN" from "serve this channel directly" β both looked the same. A channel now selects one of three: inherit the server's delivery, deliver through a different CDN, or serve straight from this server even when a CDN is configured, which is what a private or low-volume channel wants.
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
- [NEW] Work is queued rather than refused when every node is busy. A batch larger than the fleet's slot count used to lose whatever did not fit, and the operator had to notice and ask again. Submissions are now accepted and held, and dispatched as slots free up.
GET /api/encode/queueshows what is waiting, its position and how long it has waited; a tenant sees only their own work, with the real global position rather than an index into their own subset. - [NEW] A job whose node fails is returned to the queue and tried elsewhere. One dead node used to cost every job on it, even with other nodes idle. Retries prefer nodes that have not already failed the job, back off between attempts so a briefly unreachable node does not consume every attempt in seconds, and give up after four with the operator told which nodes were tried.
- [NEW] The queue survives a restart. It was held only in memory, so a deploy during a large batch abandoned everything still waiting. It is now written to disk and read back at startup; a corrupt file is reported and skipped rather than taking the service down.
- [NEW] Finished encodes are trimmed to the most recent ten. Every completed job used to stay for the life of the process, so an admin page that had run a few batches became hundreds of rows with the one job being watched buried among them. Anything unfinished is always kept.
v1.92.0 β Visible Failures and Bounded Parsing
New
- [NEW] A channel that is on air but delivering nothing now says so on the Channels page, and counts against channel health rather than showing green. v1.91.0 recorded the condition and exposed it through the API; a fault nobody can see is not finished.
Fixed
- [FIX] High: Sample tables were bounded by an arbitrary count rather than by memory. Ten million entries is about ninety hours of thirty-frame video β far beyond any playout asset β and each costs roughly forty bytes before the intermediate tables parsed alongside it, so a single crafted file could claim half a gigabyte per track and more besides. A count read from a file is a claim, not a fact, and
makewith a hostile value takes the process down rather than returning an error: on a machine running channels that is every one of them going down together. Every table is now checked against a memory budget before it is allocated, and a negative count β which an unsigned value read as a signed one can produce β is refused rather than reaching the allocator. - The limit remains over nine hours of thirty-frame video, which is longer than anything that will be scheduled; ordinary media is unaffected, and a test asserts real files still parse.
v1.91.0 β A Channel That Cannot Write Now Says So
Fixed
- [FIX] High: Segment write failures were discarded entirely. Every muxer write and every flush returned an error that was thrown away, so a full disk or a failing volume produced a channel that reported itself running, paced correctly, and delivered nothing β segments simply stopped appearing, players stalled, and no part of the system said why. Failures are now recorded with their cause and surfaced as
delivery_okon the channel's live state, so a channel can be shown as unhealthy rather than merely running. - The first failure is logged with its cause and the directory to check; the rest are counted and reported at most once a minute, because a failing disk produces one per sample and a log nobody can read is as useless as none. A success clears the record, so a transient problem does not mark a channel unhealthy for the rest of its run.
Regression protection
- [NEW] Tests asserting a failure is recorded with its cause and timestamp, that repeated failures accumulate rather than resetting, that recovery clears it, and that the condition reaches the channel's reported state naming the cause, the count and how long it has been failing. Verified live: a healthy channel reports
delivery_ok: true.
v1.90.0 β Segments That Always Start Where They Should
Fixed
- [FIX] High: A fifteen-second ceiling cut segments mid-picture. The intent was to keep the playlist live when keyframes are sparse, but the effect was the undecodable segment the surrounding code was written to avoid: one that begins part-way through a group of pictures, with no parameter sets, and hands the next segment a stream starting mid-frame. Cutting now always waits for a keyframe β a long segment plays, a broken one does not. Content whose keyframes are too far apart for segmented delivery is reported once, naming the channel, because re-encoding it is the real fix and nothing else would say so.
- [FIX] High: The last video or audio track in a file silently won. A file with more than one β an alternate angle, a thumbnail track, a leftover from an edit, alternate languages β aired whichever happened to be parsed last, which is a property of the file's box order rather than anyone's intent, and produced a channel airing the wrong picture with nothing to explain it. The first usable track of each kind is now used and the others are reported.
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
- [FIX] High: Previewing a file downloaded all of it before playing a frame. The whole response was read into memory and assembled before playback began, so previewing a large master meant waiting for the entire file and holding it in memory β which on a big enough one simply crashed the tab. The browser now streams it, fetching ranges as it needs them, and playback starts within a second. Because a video element cannot set a header, it carries a scoped token β the one added in v1.88.0, which opens nothing else.
- [FIX] Medium: Quick as-run ranges were built in UTC. For part of every day "today" resolved to yesterday or tomorrow, so an operator asking for today's log got a window that did not match the day in front of them, and the discrepancy moved with the clock. Local calendar dates are used now.
- [FIX] Medium: The upload space reserve was sized against raw capacity. That includes reserved blocks and quota headroom no upload can reach, so on a filesystem with much of either, a percentage of the total exceeded everything available and refused every upload permanently. It is now sized against what is actually usable, the way
dfreports capacity.
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
- [FIX] High: The full account token was embedded in every proxied playlist URL. A media player cannot set a header, so something has to travel in the URL β but what travelled was a 72-hour credential valid for every API route, and the proxy then rewrote it into every segment and key URI inside the playlist. It reached referrer headers, CDN and proxy logs, browser history, and anyone the viewer shared a link with.
- Playback now uses a scoped token from
/api/hls-proxy/token: it opens the proxy and nothing else, belongs to one account, and lasts six hours rather than three days. Verified β the scoped token reaches the proxy, and returns 401 on/api/channels. - The interface falls back to the account token when the server does not offer the new route, so an upgrade in either order does not break playback.
Regression protection
- [NEW] A test asserts a scoped token resolves to its own user and is refused once expired; that claiming another user, extending the expiry, or forging the signature all fail; that a JWT parser rejects it outright, so it cannot be passed off as an account token; and that a token from another installation does not validate.
v1.87.0 β Proxy Reachability and Connection Reuse
Security
- [FIX] High: The address check was a hand-maintained denylist. Anything nobody had thought to add was permitted, and the list of things that must not be reached keeps growing. It now decides by what an address is β anything that is not ordinary public unicast is refused β which closes the whole class rather than one range at a time. Among the cases a denylist reliably misses: an IPv4 address wearing an IPv6 coat (
::ffff:127.0.0.1) and 6to4 or Teredo addresses encapsulating a private destination.
Fixed
- [FIX] High: A new connection pool was built for every request. Each playlist and each segment paid a full TCP and TLS handshake, and none of those pools were ever closed, so their idle connections accumulated. A player fetches a segment every few seconds per viewer, which made that a steady leak and a great deal of avoidable latency. One transport is now shared.
- [FIX] High: A fifteen-second deadline covered the entire response body. A large segment over a slow link was cut off part way however healthy the transfer was. The parts that can genuinely stall β connection, TLS, waiting for headers, idling β are bounded individually instead, and the request context still cancels when the viewer goes away.
v1.87.0 β Concurrent Reads
Fixed
- [FIX] High: The database pool was pinned to a single connection. Every read queued behind every other read and behind every write β authentication, the interface's polling, media listings and statistics all through one lane. WAL exists precisely so readers do not block one another, and pinning the pool discarded that. The pool now allows concurrent readers; writes still serialise, which is SQLite's design, with the busy timeout absorbing contention rather than surfacing it as an error.
- [FIX] Medium: Connection pragmas were applied by the first query rather than to every connection. That was survivable only while the pool held one connection: with more, a second could open without WAL, without foreign keys β so cascades would not fire β and without a busy timeout, and which behaviour a query got would depend on which connection served it. They are now part of the connection string.
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
- [FIX] High: Deletion paths were not checked against their configured roots. A media or rendition path comes from the database, and removing a directory tree on a wrong value is not a recoverable mistake β whether it arrives through an older bug, a restored backup, or a hand-edited row. Both are now proved to lie inside the uploads or encoded directory first, with symlinks resolved so a link out of the root cannot pass a textual test while the deletion lands elsewhere. The root itself is never deletable.
- [FIX] High: Media probing launched a goroutine per file with no limit. Re-importing a few thousand files started a few thousand concurrent probes, each opening a file and reading its index β competing for the same disk the channels on air are reading from, so routine housekeeping could disturb output. Probes now queue through a shared limiter sized well below the machine, because playout keeps priority.
- [FIX] High: A second bulk re-probe was told it had started. The overlap check ran after the response was written, so the refusal that followed never reached the caller β a response is already committed by then β and the rows they had just marked "processing" stayed that way with nothing running to clear them. The run is now claimed before anything is reported, in one operation, so two simultaneous requests cannot both begin.
Regression protection
- [NEW] Containment tested against parent traversal, absolute paths, the root itself, an unconfigured root and a symlink pointing out; twenty concurrent claims asserted to produce exactly one winner with generations advancing; and the probe limiter checked for both its bounds and the absence of any unbounded launch.
v1.85.0 β Uploads That Tell the Truth
Fixed
- [FIX] High: A rejected upload showed as successful. The browser resolved on any response at all, so a file refused for size, type or disk space displayed the same tick as one that worked β and the operator found out only when it was missing from the library. The status is now checked, the server's own explanation is shown, and the failed row is marked rather than quietly removed. One failure no longer abandons the rest of a batch.
- [FIX] High: Nothing checked free space before accepting an upload. On a playout server filling the disk is not one failed request: it is every channel on air stopping at once, because segments have nowhere to go. A reserve is now kept free β two percent of the volume, at least a gigabyte and at most twenty β so a full disk costs one refused upload rather than an outage. The refusal says what is free, what was asked for, and why the reserve exists.
- [FIX] Medium: The upload directory's creation error was discarded, so a permission problem or a full disk surfaced later as a generic "could not save file" that named neither cause.
gpunode 1.11.0 β Job Records Survive Restarts
Shipped with PlayoutGo v1.84.0.
Fixed
- [FIX] High: Job records lived only in memory. A restart erased them, so a caller polling a job it had legitimately submitted got "not found" β indistinguishable from having sent a bad id β while the encoded output often sat on disk, uncollectable because nothing knew it existed. The job list is now saved periodically and on shutdown, and read back at startup. Anything that was in flight comes back marked interrupted rather than silently resumed: the process doing it is gone and its partial output cannot be trusted. A corrupt state file is reported and skipped rather than taking the daemon down.
- [FIX] High: Detection and probe processes had no deadline. A wedged NVIDIA driver makes
nvidia-smihang indefinitely and a source on a stalled mount does the same toffprobeβ and one of these runs at startup, so the daemon would never finish starting. During a job it meant a slot held by something that would never complete. All of them are now bounded and a timeout is reported as one.
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
- [FIX] High: The console cookie held the full API token. Anything that could read it β a backup, a browser profile, a support screenshot, an extension β got complete control of the node: submit, cancel, drain, and read every job. The cookie now carries a derived value that grants the console only, reveals nothing about the token, rotates daily so a leaked one stops working on its own, and is invalidated when the token is rotated.
Fixed
- [FIX] High: Options sent after the file were ignored. Format and the timing settings were read before the body was streamed, so a caller who put them after the upload β which multipart permits and some clients do β silently got the defaults. Nothing reported it: the job simply encoded to the wrong format with the wrong segment length. They are now re-read once the whole body has arrived.
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
- [FIX] High: Building an artifact held every file open until the traversal finished. The close was deferred inside the walk callback, so it ran when the whole function returned rather than after each file. A large ABR ladder is hundreds of segments across several renditions β enough to reach the process descriptor limit, and once that happens nothing else on the node can open a file either.
- [FIX] High: The console read job fields while encodes were writing them. The server lock protects the map of jobs, not the jobs inside it, so every field the page rendered was a data race β and under Go's memory model a reader can observe a torn value rather than merely a stale one. Job state is now copied under each job's own lock before anything reads it.
- Confirmed already handled on review: a cached source is verified to exist before a job is accepted (G-003), and drain gives queued as well as running jobs a terminal state (G-017).
Regression protection
- [NEW] A test runs four concurrent readers against a job being updated continuously and passes under the race detector; another asserts the tar writer no longer defers its closes. Verified live: an encode completed while the console was fetched twenty times concurrently, with no panic and no race reported.
gpunode 1.8.0 β Crash, Cleanup and Honest Encryption
Shipped with PlayoutGo v1.81.0. Update your nodes.
Fixed
- [FIX] High: A short filename in the source cache crashed the daemon. A log line trimmed identifiers to twelve characters without checking there were twelve, and it ran inside the reaper goroutine β so one oddly named file took every running encode down with it.
- [FIX] High: Cancelled jobs were never cleaned up. Cancellation was not counted as terminal, so such a job never received a finish time and the reaper β which requires one β skipped it forever. Its directory, including the uploaded source, stayed for the life of the process.
- [FIX] High / security: An invalid encryption key was accepted and became zeroes. The key's length was checked and its content was not, so a value that was not hexadecimal decoded to sixteen zero bytes β content encrypted under a key of all zeroes, which is no protection at all, while the caller believed it was protected.
- [FIX] High: MP4 jobs reported themselves encrypted although nothing encrypts MP4 output here. A caller was told their renditions were protected while they were written in the clear. Supplying a key with MP4 output is now refused outright rather than quietly ignored.
- [FIX] High: Upload bodies were unbounded. One caller could fill the work disk β the same disk in-flight encodes write to β so a single oversized upload took every running job down with it. Capped at 64 GB by default, configurable with
-max-upload. - [FIX] High: An absurd
-slotsvalue was accepted. NVENC has a hard session limit and every extra slot adds contention and memory pressure until encodes simply fail; values above 64 are now refused at startup and below 1 corrected.
v1.80.0 β Upload Deadlines and Codec Honesty
Fixed
- [FIX] Critical: The ten gigabyte upload limit was contradicted by a thirty-second read deadline. Anything taking longer than half a minute to send β which is any file of real size over an ordinary link β was cut off mid-transfer, and the client saw a broken connection rather than a limit, so it looked like a network fault. The fixed deadline is gone; upload routes now get one sized to the configured limit, and slow-loris protection comes from the header deadline, which is the part an attacker can stall cheaply.
- [FIX] Critical: Any video track was declared H.264 and any audio track AAC. Only the handler type was checked, never the sample entry β so an HEVC, VP9 or AV1 file went into the transport stream labelled H.264, and AC-3, MP3 or Opus audio as AAC. The result was structurally valid and undecodable, and the failure showed up at the viewer rather than at import. The sample entry is now read: unsupported video is refused with the format named, and unsupported audio is replaced with silence so the picture still airs.
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
- [FIX] Critical: The selected GPU was never passed to FFmpeg. The scheduler chose a card, recorded it on the job and showed it in the console β and then encoded on whatever CUDA considered device zero. On a node with two cards the slot accounting claimed work was spread across them while all of it landed on one: that card throttles under a load it was never meant to carry, the other sits idle, and the device shown against the job is wrong β which makes the symptom impossible to diagnose from the outside. Every encode is now constrained to the card the scheduler picked.
- Both encode paths are covered β a single rendition and the single-pass ladder build separate commands, and one without the other would leave half the jobs on the wrong card. The constraint is applied to the process rather than as an FFmpeg flag, so it holds for every output in a ladder written by one command and cannot be missed by one branch. An inherited setting from the caller's environment is discarded, so the scheduler's choice is the one that counts.
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
- [FIX] Critical: Every fresh install shared a published signing secret and administrator password. Anyone who had read the source could mint a valid token for any account on any install that had not changed them β administrator included, without ever seeing a password. Both are now generated per installation on first run and written to the configuration file, so an operator who never edits anything is still safe. An install still carrying the old values is warned about on every start.
- [FIX] Critical: Changing a password revoked nothing. Every token issued beforehand stayed valid for its full 72 hours, so someone holding one kept access for three days after the operator changed the password precisely to lock them out β the moment it matters most, and the moment it silently did not work. A token now records which password it was issued against and is refused once that changes. The identifier is a truncated digest, never the hash itself, which would otherwise be attackable offline by whoever holds the token.
- [FIX] Critical / gpunode: A rendition label became a directory name unchecked. A traversal sequence or an absolute path escaped the job's output directory and wrote wherever the daemon could reach. Labels are now restricted to letters, digits, hyphen and underscore, must be unique within a ladder, and heights and bitrates are bounded before they reach FFmpeg.
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
- [FIX] Critical: Rewritten playlists carried the original's entity headers. Content length, validators and content encoding were copied before the body was rewritten, so the declared length described a body that was never sent β and a cache keyed on the upstream's validator would hand back the unrewritten playlist, whose segment URLs point straight at the upstream and bypass the proxy entirely. Those headers are now sent only when the body passes through byte for byte; a rewritten one carries its own length.
- [FIX] High: Playlist entries were resolved by string concatenation. A parent reference produced
β¦/ch1/../seg.tsinstead of climbing a level, a protocol-relative URL β common behind a CDN β was glued onto the base as though it were a path,./survived into the result, and a base carrying a signature had it spliced into the middle. Every one of those is a segment that does not play. Resolution now uses the same rules a player applies to the same playlist, which is the point.
Regression protection
- [NEW] Eight resolution cases β siblings, one and two levels up, explicit current directory, absolute paths, protocol-relative, already-absolute, and signed URLs whose query must survive β plus a base carrying its own query. And a test that a rewritten body drops the stale length, validator and encoding while keeping cache directives, and that an unmodified body keeps them all.
v1.76.0 β Uploads and Proxy Correctness
Security
- [FIX] Critical: Logo uploads were effectively unbounded, and an oversized one was silently truncated. The multipart threshold only decides what spills to disk, not how much is read, and the copy stopped at exactly the limit and reported success β so the file was written, the caller was told it worked, and the image was corrupt. The request is now capped, and anything over the limit is refused rather than cut short.
- [FIX] High: A logo's type came from its filename. Anything at all could be stored as
.pngand served back with an image content type β from the same origin the operator's session lives on. The format is now read from the file's leading bytes and everything else is refused. - [FIX] High: The old logo was deleted before the new one was written. A failed upload therefore left the channel with no logo, and a partial write left a broken one that looked real. The new file is written aside and renamed into place; the old one only goes once the new one is safely there.
Fixed
- [FIX] High: The proxy advertised range support and dropped the header. A player seeking asked for a byte range and received the whole segment, re-fetching everything on every seek. Range and conditional headers are now forwarded.
- [FIX] High: Playlist bodies were read without a limit, so a hostile or broken upstream could exhaust memory. Capped at 4 MB β a playlist is kilobytes.
- [FIX] Medium: Signed playlists were not recognised. The check tested the whole URL for a
.m3u8suffix, soindex.m3u8?token=β¦failed it and the body passed through unrewritten β every segment URL inside then pointed straight at the upstream, bypassing the proxy entirely. The path is now tested, ignoring query and fragment.
v1.75.0 β Credentials Out of URLs
Security
- [FIX] High: A full-access app key was embedded in every thumbnail and logo URL. URLs reach referrer headers, proxy logs and browser history, and an app key never expires β so one leaked link handed over permanent read access to that account. Those URLs now carry a scoped token instead: derived from the key, valid only for one channel and one kind of resource, and only for six hours. Responses also set
Referrer-Policy: no-referrerso the URL does not travel onward. - [FIX] Medium: The stream endpoint answered any method as if it were a GET. It is handled before the method switch, so POST, PUT and DELETE to a file's stream all returned the file β a DELETE looked as though it had removed something and had not, which is the kind of confusion that ends with someone deleting the wrong thing next.
Fixed
- [FIX] High: Concurrent thumbnail requests each started their own encode. Freshness was checked under a lock which was then released before generating, so the "at most once every ten seconds" limit held only when requests arrived one at a time β the opposite of when it matters. An app grid refreshing on a dozen devices spawned a dozen encodes writing to the same file. One generation is now claimed before the lock is released and the rest serve what exists.
Regression protection
- [NEW] A test asserts a media token does not contain its key, is deterministic, and changes when the channel, the resource kind, the expiry or the key changes β so a token for one resource cannot open another β and that expired and empty tokens are refused. A second asserts the thumbnail claim is taken before the lock is released and always released again, so one failure cannot block a channel permanently.
v1.74.0 β Storage, Ranges and Rundown Positions
Fixed
- [FIX] High: Deleting an account left its files on disk. The cascade cleared the database, but uploads, encoded renditions and logos stayed indefinitely β a deleted customer's media occupying storage nobody could see or account for. On a playout server a disk quietly filling is an outage. The files are now collected before the rows are removed (afterwards there is no way to find them) and deleted, with the space freed reported and anything that could not be removed named.
- [FIX] Medium: An item could not be inserted at the top of a rundown. Position zero was treated as "append", so the only route to the top was adding at the end and reordering. An absent position now means append; an explicit zero means the top.
- [FIX] Medium: A malformed date silently widened a schedule query to everything. The parse error was discarded and the timestamp became the zero time. Malformed values are now refused, and a range whose end precedes its start is rejected rather than returning nothing.
- [FIX] Medium: "Today" was a UTC-duration truncation, which lands on the wrong day for most of the world for part of every day. It is now local midnight, and the default week is added by date so it does not shift by an hour across a daylight-saving change.
Regression protection
- [NEW] Tests for each: that position zero is no longer conflated with append, that malformed range timestamps are reported and the default day is local, and that an account's uploads and rendition directories are found before deletion, removed, their freed space measured, and that removing an already-absent path is not an error.
v1.73.0 β Protecting What Is On Air
Fixed
- [FIX] High: Media could be deleted while it was on air. The delete cascaded its rundown rows away, so channels silently lost items β and if it was playing, the engine was left reading a file that had just vanished. Deleting a file that is on air or scheduled is now refused, naming the channel and how many items would go.
?force=1proceeds anyway, so nothing is impossible, only deliberate. - [FIX] High: Editing a running channel's output settings changed nothing. The engine reads output mode, SRT host, port, stream id, passphrase, latency and buffering once at start, so a change while on air was saved to the database and ignored β the operator saw the new value while the stream kept using the old one, potentially for days. Such a change now restarts the channel and says so; the name is included because it decides the HLS directory.
- [FIX] Medium: Shuffling a rundown reported success even when the reorder failed, so the interface showed a shuffled order while the stored one was untouched.
Regression protection
- [NEW] A test asserts a file scheduled twice is reported as such, that the on-air channel is named once it is playing, and that an unused file stays freely deletable; and a second that every setting the engine reads only at start triggers a restart, and that the restart is reported rather than done silently.
v1.72.0 β Rejecting Requests That Change Nothing
Fixed
- [FIX] High: A malformed rundown update returned success. The decode error was discarded, so a bad body was treated as an empty one and the request reported that an item had moved when nothing had happened. Malformed bodies are now refused, and so is a well-formed one that names no change β silently succeeding at nothing is worse than saying so.
v1.71.0 β Refusing to Start on a Broken Schema
Fixed
- [FIX] High: A failed schema change only logged a warning and startup continued. The service then ran against a database missing whatever had failed, and the first request touching it died at runtime β on air, with an error pointing at a query rather than at the boot that should have refused. Schema failures are now fatal and name the statement, so the problem surfaces immediately and before anything depends on it. "Duplicate column" is still ignored, because that is the normal case on every restart.
- [FIX] High: Administrator recovery updated every administrator account. With more than one it set them all to the same address, hit the unique constraint and failed outright β exactly when recovery is most needed. Where it succeeded, it silently merged separate administrators into one identity. It now resets a single account: the one already holding that address, or the oldest administrator, and refuses rather than colliding when the address belongs to someone else.
Regression protection
- [NEW] A test creates two administrators, resets one by name, and asserts the other's address and password are untouched β then confirms an address belonging to another account is refused and that account left alone.
v1.70.0 β Signup and Deletion Made Whole
Fixed
- [FIX] High: A failed signup burned an invite code. The code is redeemed before the account is created, so any failure in between consumed a use for a signup that never happened β remaining uses dropped with nothing to show for it, and a single-use code became worthless. The use is now returned when account creation fails.
- [FIX] High: An invite's channel limit was applied best-effort and its error discarded. A failure left the account on the system default, which may be higher than the invite granted β quietly handing out more than intended. The account is now removed and the code returned rather than created with the wrong entitlement.
- [FIX] High: Deleting media removed the files before the record. A failure then left a row pointing at media that no longer existed: the library showed a file that could not play, and every channel using it failed at air time. The record goes first β losing it while the bytes remain is recoverable, since Re-import finds them again, so that is the safer order to fail in. Files that cannot be removed are now reported rather than discarded, because on a playout server a disk quietly filling is an outage.
Regression protection
- [NEW] A test redeems a single-use code, confirms a second attempt fails, returns the use and confirms it works again β and that returning a use to a code that does not exist is reported rather than passing silently. A second asserts the record is deleted before the files and that undeleted files are surfaced.
v1.69.0 β Ordering, Tokens and Limiter Bounds
Fixed
- [FIX] High: Reordering a rundown could scramble it. A duplicate id was applied twice and an id from another channel matched nothing, but both still advanced the position counter β and the items not named in the request were then appended from a gapped offset. Only genuine moves now count, repeats are ignored, ids belonging to another channel are skipped, and the remaining items continue from what was actually placed.
- [FIX] High / security: Token verification accepted any HMAC variant while only HS256 is ever issued. A verification policy wider than the signing policy is precisely what algorithm-confusion attacks look for, and there was no benefit to the latitude. Exactly HS256 is now required.
- [FIX] High / availability: The rate-limiter maps grew without bound. Records were removed only when that exact address came back β after expiry or on a successful login β so an attack rotating source addresses grew them indefinitely, and the recovery limiter had no timestamps at all so its entries never expired. Both keys are caller-controlled, which makes this a denial of service rather than untidiness. Expired records are now swept every five minutes, with a hard ceiling as a backstop.
Regression protection
- [NEW] A test reorders with a duplicated id and an id from another channel, asserting positions come out as a clean sequence with no gaps and that the other channel is untouched; a test that only HS256 is accepted; and one that expired limiter records are swept while a current one survives β pruning that dropped it would reset an attacker's counter.
v1.68.0 β Closing the Recovery Backdoor
Security
- [FIX] Critical: An unauthenticated endpoint could reset the administrator's password using the value in
config.jsonβ permanently. Changing that password in the interface does not rotate the config value, so the original remained a way in long after the administrator believed they had changed it. Recovery is a rare, deliberate act, and leaving the door open permanently to serve it is the wrong trade. - The endpoint is now off by default and returns "not found" rather than acknowledging it exists. Enabling it requires
allow_password_resettogether with arecovery_secretof at least 24 characters that is not the administrator's password β reusing it would recreate the same problem under a different name.
"allow_password_reset": true, "recovery_secret": "a-long-random-value-kept-separately"
Already addressed, confirmed on review
- Sessions are revalidated against the current account on every request, so a deleted account cannot keep working and a demoted administrator loses access β P2-006.
- App keys are stored hashed with only a short prefix retained, and keys written before hashing are rewritten to the hashed form the first time they are used β P2-029.
Regression protection
- [NEW] A test covering every way the endpoint must stay shut: off by default, off when enabled without a secret, off with a short secret, and off when the secret is simply the administrator's password again.
v1.68.0 β Revocation That Works
Security
- [FIX] Critical: A deleted account stayed authenticated for up to 72 hours. Tokens are self-contained and carry email and role, and nothing checked the account still existed or still held that role β so a deleted user kept working, a demoted administrator kept administrative access, and a password change revoked nothing. Every request now confirms the account's live state, cached for ten seconds so this is not a database read per request. Any change to an account clears its entry at once, from inside the data layer, so no caller can forget to.
- [FIX] Critical: App keys were stored in plaintext and returned in full on every list. A read-only database leak, a log dump or a compromised interface handed over permanent working credentials. Keys are now stored hashed; the secret is returned exactly once, when it is created, and the interface says so plainly rather than letting an operator assume they can come back for it. A short prefix identifies a key without exposing it.
Regression protection
- [NEW] Tests asserting the plaintext never reaches the database, that listing returns a prefix and no secret, that a key still authenticates, that a pre-existing plaintext key works and is migrated on first use, and that a token stops working the moment its account is deleted.
v1.67.0 β Day and Week Grids
New
- [NEW] Two grid views on the Rundown, alongside the existing list. A list is precise but hard to read as a day; the grid answers the question an operator actually has β what is on at 8pm on Thursday, and is there a hole?
- Day shows one day by the hour. Week shows seven at a glance. Both are a second view of the same rundown, not a replacement: the list remains where exact editing happens, and the two stay in step.
- Drag media onto an hour to schedule it there, rather than typing a timestamp. Drag an item to move it β between hours in Day view, between days in Week view. Clicking a file still appends to the end as before.
- Overruns are shown in red. An item that runs past the start of the next is the most common scheduling mistake, and a list does not make it obvious. The item on air is marked in green, and past hours are dimmed.
- Times are grouped and displayed in the channel's timezone, so the grid and the list agree about which day an item falls on.
Regression protection
- [NEW] A test asserts every handler the grid wires into the markup is defined β drag and drop that silently does nothing is worse than no grid β and that it groups by the channel's timezone rather than the browser's.
v1.66.0 β Cross-Tenant Isolation
Security
- [FIX] Critical / tenancy: A user could reschedule or delete another tenant's rundown items. Both statements used the playlist item id alone, and item ids are sequential across every tenant β so guessing one was enough to reach it through a channel you owned. The channel was already known in the handler and simply never used to constrain the statement. Both are now scoped to the channel and verify a row was actually affected; a mismatch returns the same answer as a genuinely absent item, so a probe cannot confirm that another tenant's id exists.
- [FIX] Critical / tenancy: App-key responses were publicly cacheable. They were marked
publicwith noVary, so a shared proxy or CDN could cache one tenant's authenticated data and serve it to another caller β the key was not part of the cache key. This matters more since CDN delivery was added. They are nowprivate, no-storeand vary on the credential.
Regression protection
- [NEW] A test builds two tenants and attempts the exact attack β rescheduling and deleting one tenant's item through the other's channel β asserting both are refused, that the victim's item is untouched, and that each owner can still operate on their own.
v1.65.0 β Dead Air Carries Picture and Sound
Fixed
- [FIX] Critical / output correctness: Dead air still carried no media. v1.47.0 gave gaps program tables and a running clock, which made them a structurally valid transport stream β but one with no elementary streams at all. A decoder saw a program with nothing in it, and a monitor watching for picture reported black-and-silent as a fault rather than as intended output. Gaps now play an actual black-and-silence clip, generated once at startup and looped through the normal playback path, so they are segmented and paced exactly as content is.
- The clip carries H.264 video and AAC audio with keyframes every two seconds, so a gap can be cut at the same boundaries as real content. On an ABR channel every rung receives it, because a player switching during a gap must not find one variant empty.
Verified
before: ffprobe β End of file, zero streams
after: h264,video
aac,audio
Regression protection
- [NEW] A test generates the slate and asserts it contains both a video and an audio track β either alone leaves a receiver with a half-empty program β and that the engine's own demuxer can open it and read samples, which probing with an external tool would not prove.
v1.64.0 β Adaptive Delivery, For Real
New
- [NEW] An ABR channel now publishes a genuine variant set. Until now it encoded a whole ladder and aired only the top rung, so the output was a single-variant playlist with nothing for a player to switch between and the other renditions unused on disk. Every rung is now segmented in parallel over the same rundown, and the channel's playlist is a master listing them with bandwidth, resolution and codecs.
- The rungs cut together because they were encoded with keyframes forced at the same instants, so a player can change bitrate at any segment boundary without a gap. Discontinuities are marked on every variant at once, so a switch across an item junction behaves the same wherever it happens.
- The variant set is built once per channel rather than per item β tearing it down between programmes would restart every media sequence and make players re-buffer at each junction.
Safeguards
- An incomplete ladder is refused and the channel airs a single rendition instead. A master listing a rung with no segments makes a player stall when it switches to it, which is worse than serving one variant that works.
- The master is written atomically: a player fetching a half-written one sees a truncated variant list and may pick nothing at all.
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
- [FIX] High: The build reported the wrong version. The version lived in three separate literals β the startup line, the manual header and a constant β and two of them stopped being updated around v1.43. The binary announced v1.42.2 while the changelog documented v1.62.0, so there was no reliable way to tell which build was actually deployed. All three now read from one constant, and a test fails if a hard-coded version reappears anywhere outside the changelog.
v1.62.0 β Per-Channel Delivery Accounting
New
- [NEW] Egress is now counted per channel. Nothing measured it: the existing byte counter records what the engine produces, which is one figure however many viewers are watching β useful for diagnosing output, useless for knowing what a channel actually delivered. Each channel now reports bytes and whole segments served, so there is a figure to bill and plan against.
- Bytes are counted as they leave, not from the file size, so a range request or an aborted fetch is measured for what it really sent. A segment counts only when the whole thing was delivered β a player fetching a range has not received a segment, and counting it would overstate delivery on exactly the requests that are most common.
Regression protection
- [NEW] Tests asserting a whole segment counts once, a hundred-byte range counts a hundred bytes and no segment, playlists count toward bytes but not segments, and that one channel's egress never lands on another's figures. Attribution refuses rather than guesses when a directory name cannot be resolved.
v1.61.0 β Watching an Encode Actually Work
Fixed
- [FIX] High: The encoding panel never refreshed. It loaded once when the page was opened and then sat there, so an operator watched 0% and a two-second elapsed time frozen in place and reasonably concluded nothing was happening. It now updates every three seconds while work is running, and stops when there is none β polling only while there is something to watch.
- [FIX] High: Every node reported zero slots in use while jobs were plainly encoding. The panel showed the node's own figure, which lags by a poll interval and knows nothing of work this server has just dispatched. The count now includes it, and shows the breakdown β "0 reported Β· +1 sent" β so a discrepancy reads as timing rather than a fault.
- [FIX]: Node health is polled every five seconds rather than twenty, so the node figures no longer visibly lag the job list beside them.
- [FIX]: A node named after its own address rendered as
162.212.179.53:9099 (162.212.179.53:9099). The host is now shown once unless the name adds something.
v1.60.1 β Saying What ABR Mode Actually Delivers
Fixed
- [FIX] Documentation and labelling: ABR mode implied adaptive delivery it does not provide. The ladder is encoded and stored, and the channel airs its top rung β PlayoutGo emits a single variant playlist, so a player never switches bitrate from it. The mode was described only as "several renditions, frame-aligned", which reads as though viewers receive them. The selector now states plainly that the channel still emits one stream, and explains that the other renditions exist for a packager in front to serve.
v1.60.0 β Resumable Artifact Collection
Fixed
- [FIX] High: A failed artifact download started again from zero. An ABR ladder is gigabytes, and the transfer was streamed straight into the unpacker β so a connection dropped at ninety percent discarded everything already fetched. Over a link between data centres that is minutes of transfer thrown away, repeatedly, with no progress being made. The artifact is now fetched to a file that survives a failure and continued with a range request, across up to four attempts.
- [FIX]: A truncated transfer looked complete. A connection closed part way is indistinguishable from a clean end of stream, so a short download was unpacked as if whole and failed β after the partial file had already been discarded. The transfer is now checked against the length the node declares, and for a server that declares none, an archive that refuses to unpack is resumed rather than abandoned.
- [NOTE] The node side already cached the archive, wrote it atomically, and served it with an identifier and range support, so the audit's concern about rebuilding on every collection was already addressed.
Regression protection
- [NEW] A test serves an archive, cuts the first attempt a third of the way through, and asserts the transfer resumes, that the unpacked content matches the original byte for byte rather than being spliced, that more than one request was genuinely needed, and that no partial file is left behind.
v1.59.0 β Edit Lists Honoured
Fixed
- [FIX] High: Edit lists were ignored. A file's edit list says which media to skip, and phone recordings and most editors write one routinely. Ignoring it played frames the editor had removed β and because video and audio are very often edited differently, applying nothing to either left them offset from each other. Both tracks are now trimmed to what the file asks for.
- [NEW] An empty edit asks for a delay rather than a skip. Honouring that would mean generating silence or a held frame, which this engine cannot do while remuxing, so it is reported with the offset it implies rather than silently ignored β an operator can then decide whether that file is fit to air.
- [NEW] Rotation flagged in the track matrix is read and reported. Playout remuxes rather than re-encodes, so a rotated file airs as stored; saying so lets an operator re-encode it upright before it reaches a schedule instead of discovering it on air.
- [NOTE] Composition offsets were already handled, so files with B-frames were never mistimed.
Regression protection
- [NEW] A test asserts the right samples are dropped, that video and audio are trimmed together so they cannot drift apart, that an edit list which would consume a whole track is ignored rather than leaving nothing to play, and that a file without one is untouched.
v1.58.0 β Encoding You Can Actually Follow
Fixed
- [FIX] High: A burst of encodes all went to one node. Node health is polled every twenty seconds, so every job submitted together read the same "nothing running" figure and piled onto whichever node sorted first β four jobs onto a three-slot machine while a thirty-two-slot machine sat idle. Work this server has dispatched but not yet seen finish now counts against a node's capacity, and nodes are compared by proportion of capacity rather than raw job count, so a large node takes proportionally more.
- [FIX]: A job showed a node's job id and nothing about which node. An identifier alone tells an operator nothing about where to look when something goes wrong. Each job now names the machine doing the work, by node name and host.
- [FIX]: Progress showed only as a bar with no figure, and elapsed time appeared only for stuck uploads. Both are now shown for every running job.
- [NEW] A job claiming to encode while its node reports nothing running is flagged as possibly lost, rather than sitting at "encoding" indefinitely. That contradiction β jobs encoding, every node idle β was previously invisible.
Regression protection
- [NEW] A test dispatches six jobs back to back across a three-slot and a thirty-two-slot node and asserts they spread, that the larger node takes more, and that the smaller is never pushed past its capacity.
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
- [FIX] High: The interface showed times in the browser's timezone while the server decided day boundaries in its own. An operator in Vienna running a server in Atlanta read 18:00 on screen and got a schedule the engine placed six hours away; repeating a day copied a window that did not match what was displayed. A channel now states its own timezone, and everything that needs to know what a day is uses that one answer.
- [NEW] The rundown header shows the channel's zone, and says so explicitly when your computer is in a different one β rather than letting the difference pass unnoticed, which is how the original problem stayed invisible.
- [NEW] A Schedule timezone field on each channel, taking an IANA name. An unrecognised value is refused when entered, naming what a valid one looks like.
Regression protection
- [NEW] Tests covering fallback for unset and unrecognised zones, that a real zone is honoured, that the same instant genuinely falls on different days in New York and Vienna β the property the whole problem rests on β and that repeating a day uses the channel's zone rather than the server's.
v1.56.0 β SCTE-35 Completed, and a Regression Caught
New
- [NEW] The program map now declares the SCTE-35 stream. A consumer that does not see the descriptor never inspects the PID, so sections were being carried and simply not looked at. The declaration appears once a channel carries breaks and stays for the life of the channel β a program map that gains and loses a stream between programmes forces receivers to re-acquire, and some drop the audio while they do. The table version is bumped so a receiver already tuned in re-reads it.
Fixed
- [FIX] High β introduced in v1.54.0: Ad breaks corrupted HLS output. Raw SCTE-35 packets were pushed into the HLS segmenter, which builds segments with its own muxer and cuts them on sample boundaries. Each marker produced a 188-byte segment containing nothing but itself, and left the following segment without its parameter sets, so the picture failed to decode. In-band markers now go only to the transport stream output, where the muxer owns the stream; HLS carries breaks in the playlist, which is what an SSAI system reads. Verified: segments are full size and decode with video and audio, and the playlist tags are unaffected.
Regression protection
- [NEW] A test emits the program map with and without breaks and asserts the SCTE-35 stream type and PID appear only when declared, and that the table version changes between the two.
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
- [NEW]
EXT-X-CUE-OUTandEXT-X-CUE-INmark the start and end of an avail in the playlist, at segment boundaries β a player can only act on a tag that precedes a segment. - [NEW]
EXT-X-DATERANGEcarries the SCTE-35 section itself asSCTE35-OUT, for systems that decode it rather than relying on the simpler tags. Both forms are emitted because both are in wide use. - [NEW]
EXT-X-CUE-OUT-CONTfor segments inside an avail, so a player joining mid-break knows how far through it is and how much remains.
Regression protection
- [NEW] A test decodes the exact bytes a live playlist carried β verifying the CRC, a non-zero event id, out-of-network and immediate flags, and a break_duration of exactly four seconds with auto_return set. A marker that looks signalled but does not decode is worse than none, because the break appears handled and is not.
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
- [NEW] Ad breaks per rundown item.
POST /api/playlist/item/{id}/breakstakes a list of{at_ms, duration_ms}. The engine emits an out-of-network splice at each break and a return-to-network splice when it ends, with a break_duration carrying auto_return so a downstream splicer returns by itself. - [NEW] Break positions are measured from the start of the item as aired β after any trim in-point β so adjusting a trim does not silently move every break with it.
Safeguards
- A break at or past the end of an item is refused with both durations named, rather than accepted and never fired.
- Overlapping avails are dropped: the second would start before the first returns, which is meaningless to a splicer. The response reports how many breaks were requested and how many will actually be emitted, so a silent difference is visible.
- A malformed stored value yields no breaks rather than an error β a bad row must never stop a channel going to air.
Regression protection
- [NEW] Tests covering ordering, breaks past the end, zero-length breaks, overlapping versus exactly adjacent avails, malformed input, and validation limits. Verified on air: a six-second avail three seconds into a twelve-second item fired at exactly three seconds.
v1.53.0 β SCTE-35 Conforms to the Standard
Fixed
- [FIX] Critical: SCTE-35 output was malformed and unusable by any real ad system. The failures compounded: the splice event id was never written at all, because the result of appending it was discarded; the timestamp argument was ignored entirely, so a scheduled splice carried no time; both length fields were fixed guesses rather than the actual lengths; the flags byte set the wrong bit for an immediate splice; and unique_program_id, avail_num, avails_expected and the descriptor loop were missing. A splicer reading this would reject the section or act on an event with no identity and no time.
- [NEW] The section is now built to ANSI/SCTE 35, with lengths computed from the content and a CRC that verifies.
splice_time()carries the full 33-bit PTS on a scheduled splice and is correctly omitted on an immediate one. - [NEW]
BuildSCTE35SpliceInsertFulladds what a real avail needs: abreak_duration()with auto_return so a downstream splicer knows how long the break lasts and returns by itself, out-of-network signalling for both the start of an avail and the return from one, and a settable unique_program_id.
Regression protection
- [NEW] A test decodes the generated section field by field β table id, section length against actual size, CRC, command length, event id, each flag bit, the 33-bit PTS, the break duration and unique_program_id β and a second covers the immediate form, where splice_time must be absent and the command exactly ten bytes.
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
- [FIX] High: An ambiguous title silently bound to the wrong file. Substring matching took the longest candidate, so
Newsresolved to whichever of "News at Ten", "Breaking News Special" and "Newsround" happened to be longest β and the schedule then looked correct while airing the wrong programme. A reference matching more than one file is now left unmatched and reported, naming the candidates so the operator can be specific. Exact, case-insensitive and extension-free matching are unchanged. - [FIX] High: Ambiguous dates were guessed at. Both day/month and month/day layouts are accepted and the first to match won, so
03/04/2026was read as 3 April when 4 March may have been meant β moving a programme by a month with nothing to notice. A slash date that could be read either way is now reported and left unanchored rather than guessed;25/12/2026and ISO dates are unaffected because they cannot be misread. - [FIX] High: Two rows in the same file could land hours apart. A timestamp with a date but no zone was read as UTC, while a time-only value used local time β so
2026-08-05 18:00and18:00anchored to different moments in a single import. All zone-less formats now use the server's timezone, which is what the schedule is displayed in.
Regression protection
- [NEW] Tests asserting an ambiguous reference resolves to nothing and names its candidates while unambiguous ones still match by each supported route, that ambiguous slash dates are detected while unambiguous and ISO dates are not, and that two timestamp formats in one import agree on their zone.
gpunode 1.5.0 β Graceful Shutdown and TLS Integrity
Shipped alongside PlayoutGo v1.51.3. Update your nodes.
Fixed
- [FIX] High: A restart killed running encodes outright. There was no signal handling at all: a deploy or a
systemctl restartterminated in-flight work, so the caller waited out its own timeout, partial output stayed on disk, and the node came back reporting slots free that had just lost hours of GPU time. The node now drains on SIGTERM β refusing new work, finishing what is running, then stopping. Anything still running when the grace period expires is marked interrupted so a caller polling it gets a definite answer rather than a job that stops responding. Configurable with-shutdown-grace, five minutes by default. - [FIX] High / security: A half-configured TLS setup silently served plaintext. Supplying a certificate without a key, or a key without a certificate, fell through to plain HTTP β so an operator who believed the token was protected in transit was sending it in the clear with nothing to notice. Either alone now refuses to start and says why, and the pair is loaded at startup so a bad certificate fails immediately rather than on the first request.
Regression protection
- [NEW] Tests asserting both partial-TLS cases are detected, the pair is validated at startup, the serve branch cannot fall through to plaintext, and that shutdown drains, bounds itself, and gives interrupted jobs a terminal state. Verified live: SIGTERM during an encode drained for the job's remaining duration and the rendition completed.
gpunode 1.4.0 β Credentials and Honest Capability
Shipped alongside PlayoutGo v1.51.2. Update your nodes.
Fixed
- [FIX] High / security: The token had to be passed on the command line, where every user on the machine can read it with
ps. It can now come fromGPUNODE_TOKENor-token-file. The flag still works for compatibility but warns, and a token file readable by other users is reported with its mode. - [FIX] High: An explicitly configured encoder was never verified. A node started with
-encoder h264_nvencon a machine without a working one reported itself healthy and failed at encode time β after the caller had uploaded its source. The encoder is now proved with a real one-frame encode at startup; a failure logs the actual cause and degrades to the CPU rather than claiming a capability it does not have.
# 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
- [NEW] Tests asserting an explicit NVENC setting is probed rather than trusted and falls back on failure, and that both private token sources exist with warnings for the insecure paths.
gpunode 1.4.0 β Resumable Artifact Collection
Performance and reliability
- [FIX] High: The artifact was re-packed on every request. It streamed fresh each time with no size, no validator and no resume, so a client whose multi-gigabyte download was interrupted started again from zero β and the node re-read and re-packed every rendition each time. It is now built once on first collection and served as a file, giving
Content-Length, anETagand byte-range resume. Verified: a range request returns HTTP 206 with exactly the bytes asked for, and a second collection reuses the existing archive rather than rebuilding it. - It is built lazily rather than after every encode: a job nobody collects should not pay for packing, and a node with many finished jobs should not hold a second copy of each. The archive is written under a temporary name and renamed, so an interrupted build cannot leave a truncated file that later looks complete.
gpunode 1.3.0 β Streaming Uploads and a Quieter Disk
Shipped alongside PlayoutGo v1.51.1. Update your nodes.
Fixed
- [FIX] High / performance: Every source was written to disk twice. The body was parsed in full first, spilling anything large to a temporary file, and the handler then copied that to its destination. For a multi-gigabyte master that is gigabytes of avoidable I/O on a node whose disk is simultaneously feeding live encodes. The upload is now streamed straight to its destination while hashing. Form fields are collected in whatever order they arrive, so no caller has to change.
- [FIX] High: Every upload reported zero bytes. The copy redeclared the byte counter inside its block instead of assigning to the outer one, and an unused-variable silencer hid it. Verified: an 8,242,345-byte source now reports exactly that.
- [FIX] Medium-high: The reaper deleted job directories while holding the global job lock. Recursive deletion takes seconds on a slow or busy disk, and holding that lock stalled every request the node was serving β including health checks, which made a healthy node look unreachable. Candidates are now chosen under the lock and deleted outside it.
- [FIX] High: Each cache probe wrote to the filesystem. Marking a source as recently used touched the file on every query, and PlayoutGo asks before each submit β so a busy node did metadata writes purely to record interest. Use is now recorded in memory and flushed on the reaper's own cycle, immediately before it decides what to evict.
Regression protection
- [NEW] Tests asserting the submit path streams rather than buffers, that the byte counter is not shadowed again, that fields after the file part are still read, that no deletion happens inside the reaper's locked region, and that deferred use times reach the filesystem before eviction is decided.
gpunode 1.3.0 β One Decode Per Ladder
Performance
- [NEW] An MP4 ladder is now encoded from a single decode. Previously each rung ran its own ffmpeg, so a three-rung ladder demuxed, decoded and read the source three times. A filter_complex graph splits the decoded frames once and feeds one scaler per rung. If ffmpeg will not accept the graph the job falls back to encoding rung by rung, so the change costs a retry rather than the job.
- Verified: geometry correct per rung (1280×720, 854×480, 640×360) and keyframes at identical times across all three β 0/2/4/6/8/10 s β which is the requirement for ABR switching. On a short CPU-encoded clip the wall-clock difference was within noise (18 s versus 19 s), because encoding dominates there; the saving grows with source length and resolution, and with NVENC, where encoding is fast and decode is proportionally larger.
v1.51.0 β Overlapping Runs and Boundary Losses
Fixed
- [FIX] High: Starting a second bulk re-probe corrupted its own totals. The shared progress object was reset while the previous run's goroutines were still reporting into it, so old completions landed in the new counts,
donecould exceedtotal, the run appeared to finish early, and recovered and failed figures were mixed between runs. Each run now carries a generation that every completion must match, late reports from a superseded run are discarded, and an overlapping run is refused rather than silently trampling the first. - [FIX]: A watch-folder import counted toward whatever bulk run happened to be in progress. Single-file imports now report separately.
- [FIX] Medium: As-run exports lost entries in the final fractional second of a day. A bare date became 23:59:59 and the query compared inclusively, so anything between that and midnight was omitted. The window is now half-open and ends at the start of the next day β a small gap, but as-run is the one log where a silent gap is unacceptable.
Regression protection
- [NEW] A test runs the exact production sequence β start a run, complete some, start a second, let eight stragglers from the first report late β and asserts none are counted,
donenever exceedstotal, and the outcome figures stay uncontaminated.
v1.50.0 β Startup Order, Feed Identity and Quieter Failures
Fixed
- [FIX] Medium / startup race: The scheduler ran before recovery finished. Its first tick could act on state the recovery pass had not yet repaired β stale filler references, unclosed as-run records, channels not yet resumed β and race the resume itself for the same channels. Recovery now completes before the scheduler starts.
- [FIX] Medium / feed correctness: The M3U published caller-mode SRT addresses. In caller mode that address is where PlayoutGo dials out to, not somewhere a client can connect β so those entries silently never played. Caller-mode channels are now listed with a comment explaining why they have no pullable address, and listener-mode entries carry
mode=callerso the client knows to call in. - [FIX] Medium / feed stability: Machine identifiers were derived from the display name. Two channels sharing a name collided, and renaming one broke every client mapping and scheduled recording built against it. XMLTV and the M3U now use a stable identifier and agree with each other, with the name kept as display metadata.
- [FIX] High: A channel update could panic the request after committing. The read-back error was discarded and a possibly nil result passed to the renderer, so a transient database error crashed the handler and left the client unable to tell whether the change had applied. The update is now reported as saved even when the read-back fails.
- [FIX] Medium: The database handle was leaked when migration failed, so every failed start under a restarting supervisor consumed a descriptor and an SQLite connection.
- [FIX] Medium / observability: Runtime settings collapsed every database error into "absent", so an outage quietly reverted delivery and encoding to defaults with nothing to indicate why. Failures are now logged and distinguished from a genuinely missing value; a truncated read is reported rather than returned as if complete.
- [FIX] Medium / interface: The three-second poll had no in-flight guard. Slow responses overlapped, an older reply could land after a newer one and win, and a hidden tab kept rebuilding DOM nobody was looking at. Polling is now single-flight, sequenced, and paused while the page is hidden.
Regression protection
- [NEW] A test asserts the feed identifier survives a rename, does not collide for two channels sharing a name, and does not embed the display name at all.
v1.49.0 β Request Limits, Redirects, Exports and SRT Bounds
Fixed
- [FIX] High / availability: No request had a write deadline. The server-wide limit was disabled so streaming responses would not be cut off mid-flight, but that left every route unbounded β one slow or malicious reader could hold an ordinary API goroutine and its descriptor open indefinitely. Streaming and media routes remain unbounded by design; the other 31 API routes now have a 30-second ceiling.
- [FIX] Medium / security: The HTTP-to-HTTPS redirect trusted the request's own Host header. A crafted request produced a redirect to an attacker's origin, which the browser follows carrying whatever the operator was about to do. The target is now checked against the configured TLS domains; an install with none configured is unaffected.
- [FIX] High / export security: As-run exports could execute on open. Channel names, media names and error text were written into CSV cells unaltered, so a value beginning with
=,+,-or@was run as a formula by Excel, Numbers and LibreOffice. Such cells are now made literal; the value reads identically to a person and is inert to the application. - [FIX] High / resource safety: SRT latency, buffer and bandwidth were unvalidated. They are cast into the library's configuration, where a negative or oversized JSON integer wraps into a large unsigned quantity β silently requesting an absurd buffer or disabling a control entirely. They are now bounded where the operator can still be told why, and the documented sentinels (
-1for unlimited,0for default) still work.
Regression protection
- [NEW] Tests covering formula neutralisation across six dangerous prefixes with safe values untouched, redirect targets accepted and refused including a suffix-confusion host, and SRT bounds across six invalid combinations with the sentinels still accepted.
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
- [FIX] High / tenancy: Ownership was read from the immediate parent directory. A file at
user_7/subdir/show.mp4was assigned to whoever ran the scan rather than to user 7, because the parent wassubdir. Ownership now follows the first path segment under the uploads root. - [FIX] High: Files in subdirectories became unreachable. Only the bare filename was stored, so the serving and probing paths could never find them again. The path relative to the owner's root is now stored.
- [FIX] High / tenancy: De-duplication was global by filename. Two tenants each holding
promo.mp4collided and one asset was silently skipped. Identity is now the owner plus the stored path. - [FIX] High / tenancy: Any failure was retried as the administrator. A busy database, a disk error or a constraint violation was treated as a missing owner and quietly reassigned the asset β a tenancy change disguised as error recovery. Failures are now reported, not reassigned.
- [FIX]: Unreadable directories disappeared from the result. Per-entry walk errors were discarded, so a permission or I/O failure left the scan reporting success while omitting whole directories. Each failure is now recorded and listed in the response, and the result carries whether the scan was complete.
Regression protection
- [NEW] A test builds two tenants β one with a nested file, both with a same-named file β and asserts ownership follows the account, the subpath survives, both same-named assets are imported, and nothing leaks to the administrator. A second test fails if the retry-as-administrator path returns.
v1.47.0 β Dead Air, Config Integrity and Interface Robustness
Fixed
- [FIX] Critical / output: Gaps emitted an undecodable stream. Dead air was PID 0x1FFF null packets only β no program tables, no clock. An HLS segment built from that contains zero streams, and a receiver on a long SRT gap sees the clock stop and declares signal loss. Gaps now carry the program tables roughly every 100 ms and a continuously advancing PCR, so the stream stays valid and players ride through it. The clock continues from where content stopped, so the only discontinuity is the missing picture.
- [FIX] High: Failed requests could look like successes. The interface parsed every response as JSON regardless of content type, so an HTML or plain-text gateway error became an empty object β indistinguishable from a successful empty response. Responses are now parsed by content type and non-2xx statuses always carry an error.
- [FIX] High: Configuration changes could be reported as saved when they were not. The live value was changed before persisting, and a failed write was only logged while the API returned success β memory and disk diverged and the setting reverted at the next restart. The write now happens first; a failure leaves runtime state untouched and returns an error. Access is serialised, closing a genuine data race between this endpoint and the registration handler.
- [FIX] High: A listener failing at runtime skipped shutdown entirely. The serving goroutine called a fatal log, which exits the process immediately β channels not stopped, database not closed, in-flight segments left half written. The error is now handed to the main goroutine and takes the same orderly path as a signal.
- [FIX] Medium / security: Re-import results were written into the page unescaped. They contain filesystem paths and error text an operator does not control, and this runs in the origin holding the session token.
- [FIX] Medium: One malformed value in browser storage stopped the whole inline script at parse time, leaving a blank page recoverable only by clearing storage by hand. Stored session state is now parsed defensively and discarded if it does not have the expected shape.
Regression protection
- [NEW] A test parses generated gap output packet by packet and asserts it contains a PAT, a PMT and PCR-bearing packets β not nulls alone.
v1.46.0 β Boot, Config and Login Hardening
Fixed
- [FIX] Release blocker: Every authenticated boot threw before live polling started. The session-refresh timer referenced a function that had been lost in an earlier edit, so the page rendered but automatic channel and live updates never began. Both the refresh and the expiry handler are restored.
- [FIX] High / security: A successful login cleared the whole failure counter for the address. An attacker holding one valid account could guess against every other account, log into the known one to reset the counter, and continue indefinitely. A rolling fifteen-minute window now counts failures per address and per account, and does not reset on success β so the pattern still reaches the ceiling. An unrelated account from the same address stays reachable until the address limit is genuinely hit.
- [FIX] High / security: Saving configuration left it world-readable and could truncate it.
os.WriteFile's mode applies only when it creates a file, so an existingconfig.jsonat 0644 stayed readable by other users β and it holds the signing secret and administrator password. It is now written to a temporary file, flushed, permissions set explicitly, then atomically renamed, so a crash mid-write cannot leave a truncated file the service will not start from.
Regression protection
- [FIX] The interface test passed while the boot was broken. It examined only click handlers, so a function passed to a timer could be missing entirely and the suite stayed green. It now checks timer and callback references too β verified by removing the function again and watching the test fail, then pass once restored.
- [NEW] Tests for the login bypass (guess, succeed elsewhere, confirm still throttled), and for configuration saves ending at 0600 with complete contents and no temporary files left behind.
v1.45.0 β Closing the HLS Proxy
Security
- [FIX] High: The HLS proxy was unauthenticated. It fetches arbitrary URLs on the server's behalf, so on a public host anyone who found it could use it as a bandwidth relay and an outbound-request amplifier. It now requires a token, accepted in the query string because a
<video>element and hls.js cannot set an Authorization header β the same accommodation/m3uand the event endpoints already make. Rewritten playlists carry the token into their segment URLs, so a stream that loads does not then fail on every segment. - [FIX] High: Upstream response headers were copied by denylist. Anything unforeseen was forwarded β including
Set-Cookie, which let an upstream set a cookie on this origin, the same origin that holds the operator's session. Only headers a player actually needs are now passed: content type, length and range, accept-ranges, and the cache validators.
Regression protection
- [NEW] Tests asserting the route is registered behind authentication, that the header copy is an allowlist containing nothing that can carry state, and that rewritten segment URLs carry the caller's token.
v1.44.0 β Audit Fixes, First Pass
Fixed
- [FIX] High: A file used more than once in a rundown showed every occurrence as live. The live row was identified by file id, which duplicates share. The engine now publishes which playlist occurrence is playing, and gap filler clears it β no row highlights while filler is on air.
- [FIX] High: Any playback failure marked the media asset invalid. A full disk, a segmenter hiccup or a network blip took a perfectly good file out of every channel using it, and it stayed out until someone re-probed it by hand. Only faults in the file itself β missing, unreadable, corrupt, no usable track β now condemn an asset; environment failures do not.
- [FIX] High: A failed as-run insert corrupted the previous item's record. When opening an as-run record failed, the engine kept the previous item's id, so every subsequent stats update was written onto the previous item's row β silently corrupting the log that billing and compliance reporting are built on. The id is cleared first, the failure is logged, and the data layer treats a missing record as a no-op rather than a write to row zero.
gpunode 1.2.0
- [FIX] High: Draining rejected work only after parsing the upload, so a client could send a multi-gigabyte source to a node being taken out of service. It now returns 503 with
Retry-Afterbefore the body is read. - [FIX] Queued work was unbounded. A node with 24 jobs waiting now refuses new submissions rather than accepting uploads that will sit for hours.
- [FIX] High: The client registry was keyed by caller-controlled values with a reverse DNS lookup per new identity, so anything able to reach the port could grow it without limit. It is now capped with least-recently-seen eviction, and user agents are truncated.
Regression protection
- [NEW] Tests for occurrence-based live identification, media-fault classification across seven genuine faults and six environment failures, as-run writes with no open record leaving the previous row untouched, drain refusing before the body is read, and registry bounding under three times its cap.
v1.43.1 β Migration That Reaches Existing Installations
Fixed
- [FIX] Critical: Every encode failed on an upgraded installation. The
video_kcolumn was added insideCREATE TABLE IF NOT EXISTS, which does nothing once the table exists β so the column reached fresh databases only. The encode ran, the artifact downloaded, and the job failed at the final insert with "table renditions has no column named video_k". The column is now added by migration, and because SQLite cannot alter a UNIQUE constraint, the table is rebuilt so an upgraded installation gets the same shape as a fresh one. Encoded files on disk are untouched. - [FIX] High: Resuming into a trimmed item overplayed it. After a restart the engine seeks past the in-point, but the play length was still the full trimmed duration β so the item ran past its out-point by exactly the resume offset, overrunning the schedule after every restart. The remaining length is now measured from where playback actually starts, and an item whose resume point is already past its out-point is skipped rather than played wrongly.
Regression protection
- [NEW] A test builds a database with the old schema, opens it, and performs the write that was failing β so a column added without a migration fails the suite instead of reaching an installation.
- [NEW] Resume-plus-trim arithmetic is checked across no resume, partial resume, a resume that lands just before the out-point, and one past it.
v1.43.0 β Refusing an Incompatible Node
Fixed
- [FIX] High: An older gpunode encoded successfully and produced output PlayoutGo could not play. Builds before gpunode 1.1.0 return HLS whatever is asked for, and the playout engine reads MP4 β so the encode ran, the artifact downloaded, and the job failed with "found no playable rendition", which pointed nowhere useful. The node's version is now checked before any work is submitted: an incompatible node is refused with its version named, and the panel marks it in red rather than showing it as available.
- [FIX]: When an artifact really does contain nothing playable, the failure now says what it did contain β HLS instead of MP4, an empty archive, or unrecognised files β instead of only reporting absence.
Regression protection
- [NEW] Tests covering version comparison across older, equal and newer builds including an absent version, refusal naming the cause, and artifact diagnosis distinguishing HLS output, an empty archive and a valid one.
v1.42.2 β A Job That Stalls Says So
Fixed
- [FIX] High: A job whose node stopped answering sat at "encoding Β· 0%" for six hours. Every status poll failed and was retried silently, so the interface showed five jobs encoding while the node itself reported no slots busy β and nothing anywhere said the polls were failing. A job now fails after roughly thirty seconds of no status, naming the node's own job id so it can be found in that node's log, and the interface shows each failed attempt as it happens.
- [FIX]: When a node reported a state without a message, the interface kept showing PlayoutGo's last local message β so a job queued on the node still read "encoding". It now reports the node's own state.
- [NEW] Each job shows the node's job id, so a job in PlayoutGo can be matched to the same job on the node.
Regression protection
- [NEW] A test stands up a node that accepts a job and then goes silent β the exact production symptom β and asserts the job fails in well under a minute with a message naming both the cause and the node's job id.
v1.42.1 β One View of a Node
Fixed
- [FIX] High: Every encode failed with "no reply" while the panel showed the same node healthy at the same moment. Two code paths held two opinions: the panel read the background poller's cached result, while node selection ran its own live probe. "Encode All" therefore fired one probe per file at the same instant β five files, five simultaneous requests β and a node that answers one request comfortably can fail five. Node selection now uses the poller's result, which is the single source of truth; it probes only for a node that has never been polled. Encodes also start immediately instead of waiting on a network round trip.
- [FIX]: A single slow reply condemned a node. The poller now retries once before marking one unreachable, so a GPU driver reinitialising or a momentarily busy node no longer fails every encode queued in that window. Reachability changes are logged as transitions rather than a line every 20 seconds.
- [FIX]: One cause produced one alert per file β five identical messages for a single node going away. Repeats are now folded into one entry with a count, so a genuinely different problem is not buried.
Regression protection
- [NEW] A test asserts the panel and node selection agree about a node, that selection does not probe when the poller has an answer, that five concurrent selections stay instant, and that repeated alerts collapse while distinct ones do not.
v1.42.0 β CDN Per Account, and Visible in the Interface
Fixed
- [FIX] High: A configured CDN did not appear anywhere in the interface. The server was rewriting URLs correctly all along, but the channel cards built their own link from the browser's origin and ignored it β so the CDN worked for M3U, XMLTV and the App API while the screen an operator actually looks at kept showing the origin. Every viewer-facing link in the interface now uses the URL the server computed.
New
- [NEW] A CDN per account β Admin β Users β π CDN. Every channel that account owns is delivered from its own edge. This is the level a reseller platform needs: one setting per customer rather than repeating it on every channel. Resolution is channel, then account, then global, then this origin.
- [NEW] The channel dialog's URL preview now reflects the CDN that will actually deliver it, including an override being typed at that moment.
- [NEW] The built-in player deliberately stays on the origin β it is for checking what the server is producing now, and a CDN may lag by its cache TTL or be unreachable from inside your network.
Diagnostics
- [NEW] Encode jobs log node selection and upload timing, and the interface flags a job that has sat in queued or uploading for more than 45 seconds. A stalled job previously showed "choosing a node" indefinitely with nothing in the log to explain it.
Regression protection
- [NEW] A test walks the whole resolution order, checks a path-prefixed CDN survives into the final URL, and asserts one account's CDN never leaks onto another's channels.
v1.41.1 β Buttons That Did Nothing
Fixed
- [FIX] High: Encode All and Repeat Day did nothing when clicked. Both referenced a variable that was never declared, so the click threw a
ReferenceErrorβ which anonclickhandler swallows silently. They now read the channel from the rundown's own selector. - [FIX] High: Test, Edit, Disable and Remove on an encoding node did nothing. Their handlers embedded JSON in the
onclickattribute, and the raw double quotes closed the attribute β the buttons rendered perfectly and were inert. Handlers now take an id and look the record up themselves. The same defect affected Remove on a watch folder.
Regression protection
- [NEW] A test for both silent-failure classes: a handler guarding on an undeclared variable, and an
onclickembedding unescaped JSON. Neither is visible to a compiler and both shipped. - [FIX] The interface tests were passing vacuously. Script extraction skipped any block whose body contained
src=β true of nearly every block, because of image tags in template strings β so the checks examined almost nothing. Extraction now inspects only the opening tag, and the test fails outright if it extracts implausibly little script. Verified by reintroducing the bug: the guard now fails, and passes once fixed.
v1.41.0 β Watch Folders in the Interface
New
- [NEW] Watch folders are managed in Admin, the last setting that required editing
config.jsonand restarting. Add, pause, resume and remove; a new folder is picked up within one poll. The panel shows each folder's state, its import count and last scan, so a folder on an unmounted share is visible rather than silently idle. - [NEW] The path is validated when added: it must exist, be a directory, and not be inside the uploads directory β which would make PlayoutGo re-import its own files in a loop.
v1.40.0 β Delivery Settings in the Interface
New
- [NEW] Admin β Delivery & Encoding. The CDN base URL and the encode fallback policy were config-file settings that required a restart of a service that is on air. Both are now edited in the interface and take effect immediately. A setting made here wins over
config.json, so existing installations are unaffected until an operator changes something. - [NEW] A per-channel CDN override in the channel dialog, for a channel delivered from a different edge or a customer's own CDN.
- [NEW] The fallback selector refuses a policy this server cannot honour β choosing local encoding on a host without ffmpeg is rejected when you save it, rather than failing at the first encode.
v1.39.0 β Encode Per Channel
New
- [NEW] π Encode All in the Rundown encodes every file in a channel using that channel's quality settings. Distinct files only β a clip used ten times is encoded once β and anything already encoded for those exact settings is skipped, so pressing it again after adding content does only the new work. This is the workflow channels are actually built in; encoding files individually from the Media tab still works for one-offs.
Fixed
- [FIX] High: Two channels using the same file at the same height but different bitrates overwrote each other's renditions. A file in one channel at 720p 2000k and another at 720p 2800k produced a single rendition β whichever encoded last β and one of the two channels silently aired the wrong bitrate. Renditions are now identified by height and bitrate, and each channel selects the one matching its own settings.
- [FIX]: The interface was still sluggish with an unreachable node. Health was probed inside the request, so the Admin panel waited on the node's full timeout. Nodes are now polled every 20 seconds in the background and the panel is served from memory. Measured with a dead node: 5s β 0.1s. The explicit β» Refresh still probes, since that is the one moment an operator expects to wait.
Regression protection
- [NEW] A test builds the exact collision β one file, two profiles wanting the same height at different bitrates β and asserts both renditions survive and each channel selects its own.
v1.38.4 β Tolerating a Warming GPU Driver
Fixed
- [FIX]: The node health timeout was 2.5 seconds, which is right for a node that is unreachable but too tight for one whose GPU driver is briefly reinitialising. With NVIDIA persistence mode off the driver unloads while idle, and the first
nvidia-smiafter that takes seconds β a node answering correctly in 4.2s was reported as dropped packets. Now 5 seconds: still short enough that an unreachable node cannot tie up the browser connection pool, long enough not to condemn a healthy node.
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
- [FIX]: A node that could not be reached reported Go’s raw transport error β
context deadline exceededβ which is accurate and tells an operator nothing. Failures now explain what they mean and what to check, and distinguish the two cases that need completely different fixes: a timeout means packets are being dropped or the node is overloaded, a refusal means nothing is listening on that port. - [FIX]: The per-node action buttons ran together as
TestEditDisableRemovewith no spacing.
Regression protection
- [NEW] A test asserts each transport failure is explained, that a timeout and a refusal do not give identical advice, and that an unrecognised error is passed through rather than swallowed.
v1.38.2 β Panels Confined to Their Own Screens
Fixed
- [FIX] High: The Encoding Nodes and App API Keys panels appeared on every screen. Both were added outside every page container, so they rendered permanently β above the rundown, above the player, below the channel grid β on Channels, Media, Rundown and HLS Player alike. They now live inside Admin and EPG respectively, where they were always documented to be.
Regression protection
- [NEW] A test locates every page container and asserts each screen-specific panel sits inside the page it belongs to, so a panel cannot escape onto every screen again.
v1.38.1 β Interface Responsiveness
Fixed
- [FIX] High: Pages taller than the window could not be scrolled. The page container is a flex item, and a flex item defaults to
min-height:autoβ it refuses to shrink below its content, sooverflow-y:autonever engaged and the content simply ran off the bottom. Latent since the layout was written; the Encoding Nodes panel made Admin tall enough to expose it. - [FIX] High: One unreachable encoding node made the whole interface feel frozen. Node health was probed with an 8-second timeout on every Admin visit and after every node edit. Browsers allow only about six connections per host, so two or three of those in flight starved every other request β including live stats β and clicks appeared to do nothing. The probe now times out in 2.5 seconds (a healthy node answers in milliseconds) and results are cached for 15 seconds. Measured against a dead node: first probe 2.5s, subsequent panel loads ~1Β΅s.
- [FIX]: The Encoding Nodes panel showed nothing while probing, which looked like a hang. It now says it is checking, and β» Refresh re-probes immediately rather than serving the cache. Editing or removing a node clears its cached result, so a node you have just fixed appears healthy at once.
Regression protection
- [NEW] A test asserts the health timeout stays short and that repeated panel loads are served from cache, using a TEST-NET address that times out rather than refusing β reproducing the production case rather than a faster failure.
v1.38.0 β Trim Points, Repeating Days, Watch Folders
New
- [NEW] Trim points β play part of a file without re-encoding it. In and out points per rundown item, enforced by the engine and reflected everywhere a schedule is laid out: air times, the EPG, the App API and day totals all use the trimmed length, so nothing after a trimmed item drifts. Verified on air: a 12-second file trimmed 2sβ8s logged "Trim out reached" exactly four seconds after starting.
- [NEW] Repeating days β copy one day's rundown onto others, with times shifted by whole days so an 18:00 programme stays at 18:00. Accepts a count, "weekdays N", or explicit dates; replaces or appends. Order and trim points are carried across, since a copy that dropped them would quietly change what airs.
- [NEW] Watch folders β directories polled for new media, so content arriving by rsync or a mounted share imports itself. Polling rather than inotify, because watch folders are usually network mounts where inotify silently does not fire for another host's writes. A file is imported only once its size has stopped changing, so a partial upload is never taken.
Security
- [FIX]: The admin recovery endpoint compared its secret with a plain
!=, leaking the secret's length and prefix through timing on an endpoint that is deliberately reachable without a session. Now constant-time. - [AUDIT] Every route was checked for authentication. The twelve that run without a session are all intentional: sign-in, invite-only registration, rate-limited recovery, app-key endpoints that authenticate internally, and public delivery (
/hls/,/m3u,/epg,/viewer,/docs). The HLS proxy validates its target and re-checks the resolved address at connection time, closing the DNS-rebinding gap.
Regression protection
- [NEW] Effective-duration arithmetic across nine cases including clamping and degenerate trims; trim persistence through both the item and list paths, and clearing restoring full length; a day repeat reproducing order, times and trim points; repeat refusing malformed dates, empty targets, unanchored days and unbounded ranges; and a watch folder refusing to import a growing file, a non-MP4, or a dotfile.
v1.37.0 β Session Handling & Interface Audit
Fixed
- [FIX] High: An expired session produced confusing errors instead of a sign-in prompt. The interface ignored HTTP status entirely, so once the 72-hour token lapsed every action returned an unexplained error toast with nothing indicating the operator had simply been signed out. A 401 now clears the session and returns to the sign-in screen, once β not once per in-flight request.
- [NEW] The session is refreshed every six hours while a tab is open, so a long shift no longer ends in a surprise logout. The refresh endpoint existed but nothing ever called it.
- [FIX]: The channel dialog always read "New Channel", even when editing. It looked up
cm-modal-titlewhile the element isch-modal-title; because the lookup was guarded, the mismatch failed silently rather than erroring.
Regression protection
- [NEW] A test checks that every element id the interface looks up actually exists. This class of bug is invisible at runtime β the lookups are guarded, so the feature simply does nothing β and a compiler cannot catch it.
v1.36.0 β CDN Delivery & Encode Fallback
New
- [NEW] CDN delivery β one
cdn_urlsetting, or a per-channel override, rewrites every viewer-facing URL together: channel list, M3U, XMLTV and the App API. Only delivery URLs move; the authenticated control API stays on the origin. Values that would produce broken links β a path containing/hls/, a.m3u8suffix, a query string, or a scheme with no host β are refused. - [NEW] Encode fallback policy when no GPU node is available:
fail(default β stop and notify, never encode on the playout host),local_gpu(encode here only with a real GPU), orlocal_cpu. The active policy, this server's actual capability, and recent alerts are shown in Admin β Encoding Nodes.
Fixed
- [FIX] High: Every media upload failed with "17 values for 16 columns" β a stray placeholder in the
media_filesINSERT. A test now checks every INSERT in the data layer for column/value agreement, counting literal values as well as placeholders; this class of drift has now occurred twice. - [FIX]: With no nodes configured, encoding was refused outright even when the fallback policy explicitly allowed encoding on this server β so an operator who deliberately chose local encoding could not encode at all.
- [FIX]: The fallback policy names in the encode path did not match those the configuration accepts (
localvslocal_cpu/local_gpu), so a valid configuration was rejected at startup.
Regression protection
- [NEW] Tests for CDN precedence (channel over global over origin) and rejection of malformed bases; fallback policy validity and self-description; the zero-node decision under each policy including an unset one defaulting to refuse; alert recording bounded so an unreachable node cannot fill memory; and column/value agreement across every INSERT.
v1.35.0 β Encoded Renditions Are Actually Played
Fixed
- [FIX] Critical: Encoding produced output that nothing played. The pipeline encoded correctly and collected the results, but the playout engine still opened the original file β so every encode was wasted GPU time and disk. Two causes, both now fixed: the node returned HLS, which the engine (which reads MP4 and does its own segmenting) cannot open; and nothing recorded what had been produced, so there was no way to find it. Renditions are now requested as MP4, recorded against the media file, and selected by the engine at play time. Verified on air: a 1920Γ1080 source encoded to 480p went from
1920,1080to854,480in the transmitted segments. - [FIX]: Deleting a media file left its renditions on disk. They are derived from the file and useless without it, so they are now removed with it.
New
- [NEW] gpunode gained an output
formatofhls(default, unchanged for VODOTT) ormp4for callers that play files. A master playlist is only written for HLS, since it would describe variants that do not exist alongside plain MP4. - [NEW] Rendition selection falls back to the original whenever an encode has not run or a file has gone missing, so enabling a mode can never take a channel off air.
Regression protection
- [NEW] Tests covering selection for each mode, the fallback to the original when no rendition exists, refusing a rendition whose file has been deleted, and a re-encode replacing rather than duplicating a rendition.
v1.34.0 β Performance
Improved
- [PERF] 5Γ faster channel list. Rendering the channel grid issued one database query per channel to fetch its gap-filler pool β 24 round trips for a 24-channel grid, paid continuously because the interface polls this endpoint. The pools are now fetched in a single query and shared across the list. Measured on 24 channels: 512 Β΅s β 97 Β΅s.
- [PERF] Encoding nodes are health-checked concurrently. Choosing a node probed each one in turn, so an unreachable node cost its full timeout before the next was tried β with three nodes and the first two down, an encode waited roughly 24 seconds before starting. All nodes are now checked at once, so the wait is one timeout regardless of fleet size.
Regression protection
- [NEW] A test asserts the batched channel list stays materially faster than the per-channel path, so a refactor that reintroduces the N+1 fails the suite instead of quietly slowing a polled endpoint. A second test asserts the batched and per-channel queries return identical data.
v1.33.0 β Encoding Fully Managed from the Interface
New
- [NEW] Encoding nodes are added, tested, edited, disabled and removed from Admin β no config file, no restart of a service that is on air. A node is checked before it is saved, so a wrong URL or token is reported at once, and a node that answers without a usable GPU is flagged rather than silently accepted.
- [NEW] π Encode button on every ready media file, using a channel's settings so what is encoded matches what will air.
- [NEW] Encode jobs can be cancelled while running β which also stops the work on the node rather than leaving it burning GPU time β and cleared from the list once finished.
- [NEW] Nodes declared in
config.jsoncontinue to work, so upgrading changes nothing for an existing install.
Security
- [NEW] Node tokens are stored server-side and never returned to the browser; the interface sees only whether a token is set. Editing a node without supplying one keeps the stored value, since the interface cannot round-trip a secret it was never given.
- [NEW] Node management is administrator-only on every method.
Regression protection
- [NEW] Tests covering token non-exposure, admin-only access across GET/POST/DELETE, an edit preserving the stored token, disabled nodes being excluded from selection, and cancellation semantics β repeat cancels safe, finished jobs refused, and a running job never silently cleared from the list.
v1.32.0 β Encoding Modes & gpunode Integration
New
- [NEW] Three source-quality modes per channel β Original files (default, no encoding), Single bitrate (conform everything to one adjustable rendition), and ABR ladder (several frame-aligned renditions, defaulting to 1080p/720p/480p and fully editable). See Encoding & Quality Modes.
- [NEW] gpunode client. Encoding runs on external GPU nodes, never on the playout host, so channel density is unaffected. PlayoutGo picks the least-loaded node with a free slot and prefers nodes with a real GPU over one that has fallen back to CPU.
- [NEW] Sources are content-addressed on the node, so a file VODOTT has already encoded costs no upload and no GPU time.
- [NEW] Admin β Encoding Nodes: per-node encoder type, slots, free disk and availability, plus encode jobs with live progress. Health is polled concurrently so one dead node cannot stall the page.
- [NEW]
GET /api/encode/nodesandPOST /api/encode/start.
Fixed during implementation
- [FIX] High: The artifact unpacker's path-traversal guard did not work.
filepath.Clean("/"+name)collapses../../etc/evilto/etc/evil, which then joins inside the destination β so a malicious archive was silently written to the wrong place instead of being rejected. Entries that are absolute or contain a parent reference are now refused outright. - [FIX]: Adding the encode-profile column updated the channel INSERT statement but not the shared argument helper, so every channel creation failed with "missing argument with index 16". A test now asserts the column list and argument slice stay in step.
Regression protection
- [NEW]
encode_test.goβ mode defaults, impossible segment/GOP geometry corrected, profile validation (unknown mode, absurd heights and bitrates, duplicate or missing rendition labels), an absent profile not overwriting a stored one, archive traversal refused, normal artifacts unpacking intact, node selection skipping full/draining/unreachable nodes, and INSERT column drift.
v1.31.0 β App API for Client Applications
New
- [NEW] Read-only JSON API for client apps β Roku, Fire TV, tvOS, Android TV, mobile, web grids and partner integrations.
GET /api/app/channelsreturns everything needed to draw a channel grid in one call: names, descriptions, HLS and SRT stream URLs, thumbnail and logo URLs, live/off-air state, now-playing with progress, next-up, and the airing item's resolution and frame rate. - [NEW] App keys β long-lived, read-only, per-user, individually revocable credentials (
pgk_β¦). The interface's JWT expires after 72 hours, which a television app cannot renew. Keys are accepted as?key=or theX-API-Keyheader, and are managed under EPG β App API Keys with last-used timestamps. - [NEW] Live thumbnails β
/api/app/channels/{id}/thumbnail.jpgextracts a frame from the channel's most recent segment, scaled to 640 px, so a grid shows what is genuinely on air. Regenerated at most once per 10 seconds per channel and served from cache thereafter; falls back to the channel logo, then a 404 that states the reason. - [NEW] Channel logos β upload a PNG or JPEG per channel; served to apps and used as the thumbnail fallback.
- [NEW] JSON EPG β
/api/app/channels/{id}/epgreturns programmes with start and end times using the same anchor-and-flow model the Rundown shows, so apps and operators never disagree. XMLTV remains at/epg/{id}for Plex, Jellyfin and IPTV clients. - [NEW] Filenames are humanised for display (
morning_habits%3A_part_1.mp4β morning habits: part 1).
Fixed during implementation
- [FIX]: Thumbnail extraction failed with a bare
exit status 234. Two causes: the newest segment is still being written and cannot be decoded β now up to three recent segments are tried β and the temporary output path ended in.new, so ffmpeg could not infer the format and refused to start. The endpoint also reported only "no thumbnail available"; it now returns the actual reason and logs it.
Documentation
- [NEW] App API chapter: obtaining and revoking keys, full response schemas with every field explained, the JSON EPG and how its times relate to the Rundown, thumbnails and logos including the ffmpeg dependency, a worked JavaScript grid example, platform notes for Roku/tvOS/Android/web, error codes, polling etiquette, and a statement of exactly what an app key can and cannot do. Testing Guide step 25 added.
Regression protection
- [NEW]
app_api_test.goβ key required and header form accepted, complete payload with scheme-correct URLs and correct SRT parameters (stream id present, latency in microseconds), tenant scoping on both the list and by-id paths, JSON EPG contents, immediate effect of revocation, and filename humanisation.
v1.31.0 β App API for Client Applications
New
- [NEW] Read-only JSON API for client apps β Roku, Fire TV, tvOS, Android TV, mobile, web grids and partner integrations.
GET /api/app/channelsreturns everything needed to draw a grid in one call: names, descriptions, HLS and SRT stream URLs, thumbnail and logo URLs, JSON and XMLTV EPG links, now-playing with progress, next-up with times, and the current item's resolution and frame rate. - [NEW] App keys (
pgk_β¦) β long-lived, read-only, per-user, individually revocable credentials, managed under EPG β App API Keys. The interface's JWT expires after 72 hours, which a television app cannot renew. Accepted as?key=or anX-API-Keyheader, with last-used tracking so stale keys are identifiable. - [NEW] Live thumbnails β a frame extracted from the channel's most recent segment and scaled to 640 px, so an app grid shows what is genuinely on air. Regenerated at most once per 10 seconds per channel and then served from cache. Falls back to the channel logo, then to a 404 whose body states the reason.
- [NEW] Per-channel logos β upload a PNG or JPEG for use as a static tile image or thumbnail fallback.
- [NEW] JSON EPG at
/api/app/channels/{id}/epg, using the same flow model as the Rundown so apps show exactly what the operator sees. XMLTV remains at/epg/{id}for Plex, Jellyfin and IPTV clients. - [NEW] Extensive App API chapter: authentication, full field reference, a worked channel-grid example, platform notes for Roku/tvOS/Android/web, error semantics, polling guidance and the security model. Testing Guide step 25 added.
Fixed during development
- [FIX]: Thumbnail extraction failed with a bare "exit status 234" because the temporary output file ended in
.new, leaving ffmpeg unable to infer the output format. An explicit format is now specified, the generator tries several recent segments rather than only the newest (which is usually still being written), and failures report the real reason instead of a generic message.
Regression protection
- [NEW]
app_api_test.goβ payload completeness including scheme-correct URLs and microsecond SRT latency, key scoping across tenants (401/403/empty list), immediate revocation, JSON EPG contents, and filename-to-title conversion.
v1.30.0 β Honest Capacity Projections
Fixed
- [FIX]: Capacity extrapolated without limit from a small sample. On a healthy 8-core host running 24 channels, per-channel CPU fell below the resolvable floor, and dividing the planning ceiling by that floor "proved" room for 384 channels β a 16Γ projection that contention, scheduler overhead and per-channel goroutine costs would never sustain. The estimate is now capped at 4Γ the number of channels actually measured, and says so, pointing at
benchmark.shfor a figure verified against real output health. Genuinely constrained hosts still report their real limit rather than the cap.
Regression protection
- [NEW] Tests reproducing the production case (24 channels, load 0.20 β capped projection with an explanatory note) and its opposite (a memory- and CPU-constrained host must report the real constraint, not the cap).
v1.29.0 β Load Figures That Understand Stuck I/O
Fixed
- [FIX]: Capacity was misled by processes stuck in uninterruptible sleep. Each D-state task adds 1.0 to the load average while consuming no CPU, so a failing disk elsewhere on the machine can show as a huge load on an otherwise idle host. Found in production: nine
mdadmprocesses hung on a failed RAID array gave load 9.00 across 8 cores with 0% CPU, and the panel concluded there was no room for a single channel. Capacity is now judged on runnable load, excluding blocked tasks; genuine CPU saturation still caps it.
New
- [NEW] The System panel reports blocked-task count and names the responsible commands, with an advisory explaining that such tasks cannot be killed and clear only when the I/O completes or the machine reboots β plus the commands to find the offending device.
Regression protection
- [NEW] Tests reproducing the production case (load 9.00, nine blocked tasks, 0% CPU β real headroom reported, with the advisory raised) and its opposite (genuine load with no blocked tasks β zero headroom).
v1.28.0 β Density Benchmark & Idle-Host Capacity Fix
Fixed
- [FIX] High: Capacity ignored run-queue pressure when no channels were running. The load-average bound added in v1.11 only applied once channels were on air, so an idle PlayoutGo on a host already saturated by other work advertised "+29 more channels" while load sat at 9.00 across 8 cores with 75% I/O wait. The bound now applies whether or not channels are running: a host past the planning ceiling on load alone reports zero headroom.
New
- [NEW]
benchmark.shβ measures real channel density instead of projecting it. Scales channels in steps, settles, records CPU/load/iowait/memory/disk/egress per step to CSV, and stops when any channel's HLS output degrades, reporting the previous step as the practical limit. Cleans up its channels on exit or interrupt. Requires only bash, curl and python3 β no jq. - [NEW] Measuring Channel Density chapter: how to run it, why it stops on output health rather than a resource threshold, how to read the verdicts, and the two ways to get a misleading number (unrepresentative content, or a host that is already busy).
Regression protection
- [NEW] Tests covering both directions: a saturated idle host must report zero CPU headroom, and a genuinely quiet host must still report usable headroom.
v1.27.1 β Accurate Overlap Wording
Fixed
- [FIX]: The overlap warning claimed the preceding item "will be cut short". That is not what this engine does: an item always plays to completion, and the anchored item simply starts late by the overlap amount. An operator reading the old text could reasonably have expected a hard cut and planned around a truncation that never happens. The row, the tooltip and the documentation now state the real behaviour, and add that the delay does not accumulate β the next anchor is an absolute wall-clock time, so slack before it puts the day back on schedule.
- [FIX]: The as-run Duration note carried the same implication and has been corrected: a shortfall against a file's nominal length means the channel was stopped or the item failed mid-play, not that playout truncated it.
Regression protection
- [NEW] A test asserts the interface and documentation never reintroduce "cut short" wording, and that both continue to state what actually happens. Verified against the engine: the playback loop exits only on end-of-file or an explicit channel stop, with no mid-file truncation path.
v1.27.0 β Multi-Clip Bin-Packed Gap Filler
New
- [NEW] Gap filler now accepts a pool of clips and fits the best combination into each hole, instead of looping one slate and leaving the remainder as black. A 3:20 gap with a 2:00 / 1:00 / 0:20 / 0:10 pool is covered exactly; the same gap with a single 2:00 slate left 1:20 of dead air. Verified live: a 29 s gap covered as 8+8+4+4+2+2 with 1 s padded, anchor still on its exact second.
- [NEW] Greedy longest-fit packing that avoids playing the same clip twice in a row when an alternative fits, and repeats only when nothing else does β repetition beats dead air.
- [NEW] The log now names exactly what covered each gap and how much was padded, so filler behaviour is auditable.
- [NEW] Unusable filler clips (still probing, failed, or missing a duration) are reported once to the channel error log rather than silently degrading to black, and are greyed out in the selector.
- [NEW] The single-clip setting still works unchanged, so existing channels need no reconfiguration.
Documentation
- [NEW] Rewritten Gap Filler chapter: exactly what happens during a gap, how bin-packing works and why a mixed pool beats one clip, guidance on choosing filler, an honest comparison with professional playout techniques (including what PlayoutGo does not do β elastic items and under/over compensation), and troubleshooting. Testing Guide step 24 added.
Regression protection
- [NEW]
filler_test.goβ tight packing of a mixed pool, remainder-only padding with a single clip, refusal to schedule unusable clips, no-immediate-repeat behaviour, repeating when no alternative exists, and degenerate inputs (empty pool, zero gap, gap shorter than every clip).
v1.26.0 β Overlaps as Visible as Gaps
Changed
- [NEW] Overlaps now get a full-width red row naming how far content runs past the anchor and what to do about it. Previously a gap produced a prominent amber row while an overlap produced only a small badge beside the time β the wrong emphasis, since an overlap means an anchored programme starts late, whereas a gap merely means dead air that a gap filler can cover.
- [NEW] The rundown footer now counts problems: "5 items Β· 4:10 content Β· 3 anchors Β· β 2 gaps Β· β 1 overlap", so conflicts are visible without scrolling a long day.
Verified
- Overlap detection was re-checked against a real rundown shape: an over-long floating item pushing past a later anchor, two anchors closer together than the first item's duration, and the no-overlap case β all correct, with no false gaps reported on an overlapping anchor and later anchors still reporting their own gaps.
v1.25.0 β Re-probe Progress Reporting
New
- [NEW] Live progress for bulk re-probe. The panel previously said "Re-probing N files in background" and then went silent β no indication of how far it had got, how many files were recovered, or whether it had finished. It now shows a progress bar with done / total, a running count of files recovered, and any that are still broken, finishing with a summary such as "Re-probe finished: 187 of 195 recovered Β· 8 still unusable" and the elapsed time.
- [NEW]
GET /api/admin/probe-statusreports the same figures for scripted use. - [NEW] Progress distinguishes recovered (now ready with a duration) from still unusable, so a file that genuinely cannot be parsed is visible rather than being silently counted as processed. Those need re-encoding β see the conform recipe.
Regression protection
- [NEW]
probe_progress_test.goβ counts and completion state through a full run, an empty run that must not leave the interface polling forever, and a second run resetting rather than accumulating.
v1.24.0 β Unknown Durations Made Visible
Fixed
- [FIX] High: Items with no recorded duration silently corrupted the whole schedule. They were treated as zero-length, so a rundown could show two consecutive items both starting and ending at the same second, and every air time after the first such item was wrong β with nothing on screen indicating a problem. Gap and overrun figures derived from those times were equally unreliable. The rundown now marks each affected item β unknown, shows a banner naming how many there are and what it means, and reports the count in the footer totals.
- [FIX]: The NOW countdown rendered waits over a day as raw hours ("81:12:22"). Long waits now read as "3d 9h".
Verified, not changed
- The gap/overrun arithmetic itself was checked independently against the live implementation: gaps, overruns, exact fits, floating-chain accumulation and the zero-duration case all compute correctly. The defect was never the maths β it was that a missing input was indistinguishable from a real zero.
v1.23.0 β Generation and Re-probe for Files Missing Durations
Fixed
- [FIX] High: Generate refused to run when media had no recorded duration. The pool required both
readystatus and a known duration, so a library whose files probed without recording one produced "no ready media files to build a playlist from" β while the media list beside it showed those very files. Generating by item count no longer needs durations at all; the result notes how many scheduled files lack one, since air times after them cannot be calculated until they are recovered. - [FIX] High: Files marked ready but missing a duration could not be repaired from the interface. Re-probe only picked up
errorandprocessingfiles, so these were stuck permanently: visible, unschedulable, and with no button that would fix them. Re-probe now includes them (the control is relabelled Re-probe Broken Files). - [FIX]: Generation failure messages now distinguish "nothing uploaded yet", "nothing has finished probing", and "durations unknown, so a duration-based fill is impossible" β each naming the action that resolves it, instead of one message that fitted none of them.
Regression protection
- [NEW]
gen_duration_test.goβ reproduces the reported failure (ready files with no duration must generate successfully and warn), verifies duration-based fill fails with an actionable message, and checks each empty-library message is specific.
v1.22.0 β Stream URLs Follow the Request Scheme
Fixed
- [FIX] High: Generated stream and EPG links were hardcoded to
http://host:8700. With HTTPS enabled on 443 the interface loaded correctly, but every HLS URL it produced still pointed at plain HTTP on the old port β so browsers blocked the segments as mixed content and the built-in player showed only "Error". The interface now derives links from the origin it was loaded from, so scheme and port always match. - [FIX]: Server-side URL generation (M3U playlists, the EPG index and channel API responses) assumed
http://as well. All of them now follow the actual request scheme, honouringX-Forwarded-Protoso the links are also correct behind a reverse proxy that terminates TLS.
Regression protection
- [NEW]
url_scheme_test.goβ scheme detection for direct TLS, plain HTTP, and proxied requests including a forwarded chain; plus a check that the M3U feed advertiseshttps://stream URLs when served over HTTPS and never emits plainhttp://ones.
v1.21.0 β Dangling Gap-Filler References
Fixed
- [FIX] High: Deleting media that a channel used as its gap filler locked that channel out of all further edits.
filler_file_idhas no foreign key, so the deleted file's id stayed on the channel. Because a channel update re-validates the whole record, every subsequent save β renaming it, changing a port, anything β failed with "filler file not found", referring to a field the operator had never touched and could not see was broken. Deleting a media file now clears the reference from every channel using it, and reports how many were affected. - [FIX]: References left dangling by earlier builds are repaired automatically at startup, so existing installations are unlocked without manual database surgery. The log records how many channels were repaired.
Audited, no defect found
- Concurrent playlist writes: 30 simultaneous additions produced 30 items with no losses, and simultaneous generate + import runs produced exactly the expected number of rows with no position collisions. (Noted honestly: this environment has a single CPU and SQLite serialises writes, so absence of a collision here is weak evidence rather than proof; the check is kept as a permanent guard.)
Regression protection
- [NEW]
filler_ref_test.goβ deleting filler media clears the reference and leaves the channel editable; the startup repair fixes dangling references while leaving valid ones untouched. - [NEW]
playlist_concurrency_test.goβ concurrent additions and concurrent generate/import must neither lose rows nor duplicate positions.
v1.20.0 β Full API Parity in the Interface
New interface controls for existing API features
- [NEW] Remove SRT encryption from the channel dialog. Clearing a passphrase was previously only possible through the API (
clear_srt_passphrase), because a blank field means "keep the existing one". The checkbox appears only when a channel actually has encryption set, and the field's placeholder now says whether one is stored. - [NEW] Generate dialog replaces the single text prompt and exposes every option the endpoint supports: fill by item count or by hours, replace or append, the no-repeat guarantee, and an optional seed for reproducible ordering.
Fixed
- [FIX]: Generating in append mode could place the same file back to back at the join. The no-repeat check only looked within the newly generated batch, so the first new item could duplicate the last existing one β contradicting the documented guarantee. The seam is now checked against the existing rundown, and the first pick of every pass is guarded too.
Regression protection
- [NEW] Twelve consecutive single-item appends from a two-file library β every one a seam β must produce no back-to-back repeats.
- [NEW] Generation must never schedule media that is still probing or marked
error, and must tell the operator how many files it skipped.
v1.19.0 β Playlist Generation & Schedule Import
New
- [NEW] π² Generate β build a randomised rundown from the media library by item count or target duration. Only ready files are used, the library is reshuffled for each pass instead of repeating one fixed order, the seam between passes is checked so nothing plays twice in a row, and an optional seed makes the ordering reproducible.
- [NEW] β¬ Import β load a schedule from CSV or XML, replacing or appending. Air times in the source become anchored items; entries without one flow normally. Unmatched entries are reported individually by line number rather than failing the import.
- [NEW] Three XML dialects, detected by content rather than namespace: PlayoutGo native, a practical subset of SMPTE ST 2021 (BXF) β the broadcast standard used by traffic systems β and XMLTV, which PlayoutGo already publishes, so a guide can round-trip.
- [NEW] Forgiving media matching (exact β case-insensitive β extension-agnostic β substring) and flexible timestamps covering RFC-3339, common date/time layouts, XMLTV's compact form and bare times meaning "today".
Fixed
- [FIX]: The previous
randomendpoint did not filter by file status, so it could schedule media that was still probing or had failed to parse β producing dead air at transmission. It also repeated a single fixed shuffle when asked for more items than the library held, and always destroyed the existing rundown with no option to append. The new implementation fixes all three;randomremains as an alias with its old replace-by-default behaviour. - [FIX]: The import result returned
nullrather than an empty array forwarnings, forcing clients to null-check.
Regression protection
- [NEW]
import_test.goβ CSV with and without headers and across column synonyms, all three XML dialects (BXF tested with its real namespace present), rejection of documents with no entries, the media-matching ladder, and every accepted timestamp layout.
v1.18.0 β Data-Race and Input-Handling Fixes
Fixed
- [FIX] High: Two data races between the playout engine and the statistics collectors, both introduced by recent monitoring work.
e.channelwas assigned while an engine started and read concurrently by the SRT-health check;e.hlsSegwas assigned the same way and read by the HLS egress accounting and the bitrate sampler. Under Go's race detector these are genuine unsynchronised accesses that can return torn or stale pointers, and on a busy multi-core host could crash the process. Both fields are now atomic pointers, which keeps the per-sample playout path lock-free. - [FIX]: Upload filenames were truncated on a byte boundary, splitting multi-byte characters and writing filenames containing invalid UTF-8 to disk. Because the admin re-import scanner reads names back from disk, that corruption could propagate into the database. Truncation now happens on a rune boundary, control characters and invalid sequences are stripped, and a name that reduces to nothing (or to only dots) becomes "upload" rather than an empty or dot-only stem.
Audited, no defect found
- Path traversal: upload names are always prefixed with a nanosecond timestamp and separators are replaced, and the HLS handler rejects
..and verifies the resolved path stays inside the segments directory. A traversal attempt in an upload filename was confirmed to land harmlessly inside the user's own directory. - As-run parameters: absurd limits and malformed dates are rejected or clamped (400 for bad input, never a full-table scan).
- Non-MP4 content with an .mp4 extension is accepted for upload but correctly probed and marked
error, notready.
Regression protection
- [NEW]
concurrency_test.goβ 40 interleaved Start/Stop calls on one channel, and continuous stats collection during 20 start/stop cycles, both under the race detector. These are what exposed the two races above. - [NEW]
sanitize_test.goβ filename handling must always yield valid UTF-8, stay within the length limit, contain no separators or control characters, and never be empty.
v1.17.0 β Atomic Channel Allowances
Fixed
- [FIX]: The per-user channel allowance was enforced with a check-then-act sequence. The handler counted a user's channels and inserted the new one afterwards, leaving a window in which several concurrent requests could each observe the count below the limit and all succeed β taking a user past the allowance their invite code granted. Counting and inserting now happen inside one transaction, so the allowance holds no matter how many requests arrive at once.
Honest note: this could not be reproduced in the test environment, whose single CPU and SQLite write serialisation hide the window. The fix removes the window by construction rather than relying on that timing; a multi-core production host is far more exposed. - [FIX]: The limit error now carries the counts as structured data instead of a formatted string, so the API reports "channel limit reached (2/2)" from a single source of truth.
Audited, no change needed
- Invite-code redemption was reviewed for the same class of bug and is already safe: it consumes a use with a conditional
UPDATE β¦ WHERE uses < max_usesand checks the affected row count, which is an atomic compare-and-swap.
Regression protection
- [NEW]
limit_test.goβ 25 concurrent creations against an allowance of 3 must yield exactly 3, unlimited users must never be blocked, and the error must report usable numbers.
v1.16.0 β HTTPS on 443 with Automatic Certificates
New
- [NEW] Built-in HTTPS with Let's Encrypt. PlayoutGo can now serve port 443 directly with certificates obtained and renewed automatically β no reverse proxy, certbot or cron job. A companion listener on port 80 answers ACME HTTP-01 challenges and 301-redirects everything else to HTTPS, preserving path and query.
- [NEW] Safety rails around issuance: a domain allowlist is required when TLS is enabled (an open host policy would let anyone pointing DNS at the server exhaust your rate limits), the certificate cache is created mode 0700 and tightened automatically if found readable by others, and a staging mode is provided for proving a deployment without burning production limits.
- [NEW] Bind failures on privileged ports print the exact
setcapcommand needed rather than a bare permission error. - [NEW] TLS is off by default and the block is written into new config files, so existing reverse-proxy deployments are unaffected by upgrading while operators can still discover the option.
Fixed
- [FIX]: Adding the ACME dependency silently downgraded
gosrtfrom v0.9.0 to v0.8.0 and raised the Go directive to 1.25. Both were caught and pinned back; the SRT stack builds and tests against the intended version.
Verified (already present, re-tested end to end)
- Invite-code sign-up with a per-code channel allowance: refusing sign-up without a code when required, granting the allowance on redemption, enforcing it at channel creation ("channel limit reached (2/2)"), and rejecting an exhausted code.
Regression protection
- [NEW]
tls_test.goβ empty domain list refused, port/cache defaults backfilled for pre-TLS configs, domain casing and whitespace normalised, HTTPS redirect correctness including non-standard ports, and TLS remaining off by default.
v1.15.0 β Resource Cleanup & No More Silent Failures
Fixed
- [FIX] High: Deleted channels leaked their HLS segment directories forever. The segments volume is normally a modest tmpfs, so a site that regularly creates and removes channels would slowly fill it with directories belonging to channels that no longer exist. Deleting a channel now reclaims its directory, and orphaned directories are swept at startup β covering deletions that happened while the service was down, and crashes. Verified live: four orphans cleaned on boot, and a deleted channel's directory removed immediately.
- [FIX]: A channel whose rundown became empty looked perfectly healthy. It stayed green and "running" while transmitting only padding β correct behaviour for a linear channel, but completely silent. It now reports "Playlist is empty β channel is on air but transmitting padding only" to the error log, the Stats page and the issues badge, once rather than every second.
- [FIX]: Segment directory naming is derived in one place, so the segmenter, the HTTP handler and the cleanup routines cannot disagree about which directory belongs to a channel.
Regression protection
- [NEW]
cleanup_test.goβ a deleted channel's directory is reclaimed, orphans are swept while live channels are preserved, and directory naming is stable everywhere it is derived.
v1.14.0 β Honest System Metrics
Interface
- [NEW] The CPU card now leads with load per core rather than instantaneous usage, and colours by whichever is worse. A host at 1% CPU with a load of 9 across 8 cores is saturated β the old card showed that as healthy green.
- [NEW] I/O wait is measured and displayed. High iowait with low CPU usage is the signature of a storage bottleneck and was previously invisible; at 20% or more it raises an advisory that names the disk, not the processor, as the constraint.
- [NEW] Channel Output is expandable into a per-channel breakdown with each channel's current egress and a β marker for failing SRT output.
- [NEW] Cards distinguish "no measurement yet" (a dash and "awaiting a second sample") from a genuine zero. The two were indistinguishable, so a panel that had not sampled yet looked like a dead system.
Fixed
- [FIX]: The Channel Output breakdown did not sum to its own headline. The total came from an independent sampler with a different window, so the expanded rows disagreed with the figure above them by roughly 50%. The headline is now derived from the same samples as the breakdown and reconciles exactly.
- [FIX]: Final unchecked
rows.Scanin the playlist reorder path now returns its error, completing the audit begun in v1.10.0.
Regression protection
- [NEW] Tests asserting the per-channel breakdown sums to the headline, and that iowait is sampled separately and stays within the non-busy share of CPU time.
v1.13.0 β Correct System Figures
Fixed
- [FIX] High: Channel Output always read 0.0 Mb/s on HLS hosts. Aggregate egress summed only SRT socket writes, so a server running many HLS channels reported no output at all. HLS segment bytes are now included, and the per-channel average derived from them is correct.
- [FIX] High: The capacity panel contradicted itself. Measured figures required CPU usage above 1%, so an efficient host could show "13 running of 23" beside the message "No channels are running, so per-channel cost is assumed". Capacity is now measured whenever channels are running; when per-channel CPU is too small to resolve from one sample it says exactly that and applies a conservative floor rather than pretending the host is idle.
- [FIX]: The PROCESS card showed host uptime, so a freshly redeployed service appeared to have been running for months. Service uptime and host uptime are now shown separately.
- [FIX]: Disk and network rates are guarded against unsigned counter underflow. If the set of measured devices changed between samples (a disk added, removed or remounted) the aggregate could decrease and produce an absurd throughput figure.
- [FIX]: The running-channel count now comes from live engines rather than stored channel status, which can go stale if a channel dies without updating the database β that inflated the count and skewed every per-channel projection.
Regression protection
- [NEW] Test asserting the capacity basis can never claim an idle host while channels are running, and that an idle host still reports honestly.
v1.12.0 β Channel Health at a Glance
New interface
- [NEW] SRT health indicator on every channel card, distinguishing the three states that previously all looked the same: green SRT live (a client is pulling), amber SRT waiting (listener up, nobody connected β normal), red SRT error with the reason on hover (e.g. a port/settings clash). Previously a failing SRT output was indistinguishable from an idle one.
- [NEW] Issues badge in the navigation β a red count on the Channels tab whenever any channel has an SRT failure, a stalled HLS output, or an error state, visible from any page. Hovering lists each channel and its problem.
- [NEW] Output-bitrate sparklines on running channel cards: roughly two minutes of egress history with the current rate, smoothed over a trailing six-second window so segment flushes don't render as a sawtooth.
- [NEW] Expandable Disk I/O card in System Utilisation β click for a per-device breakdown showing throughput, IOPS and utilisation for each disk, labelled with what it holds (media uploads, HLS segments, application).
Fixed
- [FIX]: HLS output was not counted as egress. Byte accounting only tracked SRT socket writes, so HLS-only channels β the common case β reported zero output bitrate everywhere. The segmenter now accounts for the bytes it writes, through both of its segment-write paths.
- [FIX]: Two defects in this release's own new code, caught before shipping: the issues badge read a mis-named JSON field (
hls_last_segment_agorather thanhls_last_segment_ago_s) so stalled-HLS detection never fired, and the raw-packet segment path was missing byte accounting, which left sparklines empty for padded output.
Regression protection
- [NEW] Test asserting the segmenter accounts for the bytes it writes, so a silent return to flat sparklines fails the suite.
v1.11.0 β Correct Disk Attribution, Honest Capacity & SRT Diagnostics
Fixed
- [FIX] High: Disk I/O was attributed to the wrong device. The busiest disk was chosen by the largest cumulative
io_tickscounter, so an idle or hung device β one not even mounted, parked at 100% utilisation by the kernel with zero throughput β was reported as the system's disk load, permanently and alarmingly. PlayoutGo now resolves the actual block devices backing the uploads directory, the HLS directory and the application directory (via/sys/dev/block) and measures only those. tmpfs correctly resolves to no block device. - [FIX]: Utilisation is now reported as 0% when no requests were served in the sampling interval, instead of trusting a counter that some devices increment while idle.
- [FIX] High: Capacity ignored run-queue pressure. Instantaneous CPU% does not count processes blocked on I/O, so a saturated host could read 1% CPU and be told it had room for ~29 more channels while its load average was 9 across 8 cores. The estimate is now additionally bounded by load per core and reports the lower figure.
- [FIX]: SRT failures were invisible in the interface. When an SRT listener could not bind β most commonly two channels sharing a port with different latency/passphrase/buffer settings β the channel kept serving HLS and logged the reason only to stdout, so operators saw "HLS works, SRT doesn't" with no explanation. The reason is now written to the channel's error log and shown in Stats (once per failure, not every retry).
Regression protection
- [NEW]
sysinfo_test.goβ verifies devices resolve to the volumes we actually use, that tmpfs is not attributed to a block device, that an idle device reports 0% utilisation, and that a host with load β₯ 1 per core is not credited with spare CPU capacity.
v1.10.0 β As-Run Logs & Data-Integrity Fixes
New: as-run logging
- [NEW] π§Ύ As-Run tab β the record of what actually aired, with channel and date-range filters, quick 1/7/30-day ranges, and a summary of item count, total airtime and bytes delivered.
- [NEW] CSV export of exactly the filtered rows, with UTC RFC-3339 timestamps for unambiguous import into billing and reporting systems. Downloaded via an authenticated fetch, so the JWT never appears in a URL or server log.
- [NEW]
GET /api/asrunwithchannel_id,from,to,limitandformatparameters. Admins see all channels; other users are scoped to their own and receive 403 for another tenant's channel. - [NEW] Play records orphaned by an unclean shutdown are closed as
interruptedat startup, so the log never contains items that claim to still be on air and airtime totals stay accurate.
Bugs found and fixed
- [FIX] High: Play history lost its status column.
rows.Scan's error was discarded andlast_erroris NULL by default, so every scan aborted at that column β leavingstatus(the next column) permanently empty on every play-history row. Statuses now read correctly (completed,interrupted, β¦). Fixed withCOALESCEplus a checked scan. - [FIX]: Two further query loops discarded their scan errors (user list, channel error log), which would silently return blank records on any NULL column. Both now check the error, and nullable columns are coalesced.
Production-readiness audit
- [TEST] Every navigation item was exercised against a live server with a running channel: Channels, Media, Rundown, HLS Player, EPG (page, XMLTV and M3U), Stats, As-Run, Admin (users, sysinfo, invites, global stats, channels) and Help β all return HTTP 200 with valid payloads.
- [NEW]
asrun_test.goβ verifies records are written with correct names and statuses, tenant scoping including the 403 path, CSV structure and headers, date validation and empty-window handling, and orphan closure.
v1.9.0 β Automatic Restart Resume & Accurate Disk Reporting
New: channels survive restarts
- [NEW] Automatic resume after restart or crash. Previously nothing came back after a reboot β shutdown wrote "stopped" to every channel and startup restored nothing, so a 24/7 installation stayed dark until an operator pressed Start on each channel individually. PlayoutGo now records operator intent separately from live status, preserves it across shutdown, and restores every channel that was on air.
- [NEW] Resumed channels rejoin at the correct scheduled timestamp, seeking mid-file into the item that should be airing now. Verified with
kill -9: resumed into item 2 at +12.5 s, exactly the scheduled position. - [NEW] An explicit βΉ Stop clears the intent so deliberately stopped channels stay stopped; queued channels show an β³ auto-resume badge.
Fixed
- [FIX] High: Disk usage was measured against raw capacity rather than usable space. On any filesystem with reserved blocks or quotas this understated pressure badly β a volume
dfreports as 54% full displayed as 3.9%, making the capacity estimate and "disk is healthy" advice dangerously optimistic. Now matchesdf. - [FIX]: System Health listed the same physical volume up to three times (uploads, HLS directory and application directory commonly share one filesystem). Volumes are de-duplicated by device.
- [FIX]:
desired_runningwas missing from channel API responses, so the interface could not show resume state.
Interface
- [NEW] Hover help throughout the Rundown: every column header explains itself, the anchor hint expands on how waiting works, and the auto-resume badge explains what it means.
- [NEW] New Restart & Resilience chapter and Testing Guide step 16.
Regression protection
- [NEW]
resume_test.goβ proves intent survives a simulated shutdown, that an explicit stop clears it, and that the resume position lands on the right item at the right offset.
v1.8.0 β System Monitoring, Capacity Planning & Invite Codes
New: system utilisation & capacity
- [NEW] System Utilisation panel in Admin: host CPU (with load average), memory (and swap pressure), disk I/O throughput/IOPS/utilisation for the busiest device, per-volume disk usage, host network throughput, aggregate channel egress, and process stats. Optional 5-second live refresh.
- [NEW] Capacity estimate β "how many more channels can this box run?" computed three ways (CPU, RAM, tmpfs headroom) with the tightest reported as the limiting factor. Measured from real per-channel cost when channels are running, and honestly labelled as an estimate when not.
- [NEW] Automatic tuning advice β severity-graded advisories with concrete actions: CPU/memory pressure, swap usage, disks filling, storage as the bottleneck, HLS directory not on tmpfs, runaway goroutines.
- [NEW]
GET /api/admin/sysinforeturns the whole snapshot as JSON for external monitoring.
New: invite codes & signup control
- [NEW] Invite codes with a per-code channel limit applied to accounts created with them, plus use count (single/multi/unlimited), optional expiry and a note. Codes are generated with
crypto/randfrom an unambiguous alphabet (no O/0/I/1) inABCD-EFGH-JKLMform. - [NEW] Redemption is atomic β verified under concurrency that a 5-use code grants exactly 5 of 25 simultaneous attempts.
- [NEW] Revoke / re-enable / delete, with live status (active, used up, expired, revoked).
- [NEW] "Require an invite code to sign up" toggle closes open registration; persisted to config.json. Guarded so it cannot be enabled while no usable code exists (which would leave no route to an account).
- [NEW] Signup form gained an invite-code field; codes are accepted case-insensitively.
Friendlier interface
- [NEW] Explanatory tooltips across the primary controls and the Rundown column headers (what π/π mean, how Start/Ends are derived, what each status value means).
- [NEW] Contextual ? help on the new Admin panels, linking to the System Utilisation and Invite Codes manual sections.
Bugs found and fixed during this release's testing
- [FIX]: Invite codes were initially generated with
math/rand, which the compiler accepted silently because that package also exposesRead. Codes are credentials, so this would have made them predictable from a known PRNG sequence β switched tocrypto/rand. - [FIX]: Creating an invite code failed with a foreign-key error whenever the creating account no longer existed. Attribution is now stored as NULL rather than a dangling id, so code creation cannot break.
- [FIX]: The first call to the metrics endpoint reported CPU, disk I/O and network as "unavailable" because rate figures need two samples to compare. Sampling is now primed at startup, with a short inline sample as a fallback, so the very first view shows real numbers.
Regression protection
- [NEW]
invite_sysinfo_test.goβ code lifecycle, case-insensitive redemption, expiry and revocation, atomic redemption under 25 concurrent goroutines, usable-code counting, generated-code shape/uniqueness/alphabet, metric range sanity, and concurrent metric collection. - [TEST] Full suite now 20 tests, all passing under the race detector.
v1.7.0 β Security Hardening & Extensive In-App Documentation
Security fixes
- [SEC] High: Cross-tenant media via playlist. Adding an item to a playlist did not verify that the media file belonged to the channel's owner. Direct file reads correctly returned 403, but this path bypassed that check entirely β any user could add and broadcast another tenant's content by id. Now returns 403 "media file belongs to another user".
- [SEC] High: Cross-tenant media via gap filler. The same gap existed for
filler_file_id: it was stored unvalidated and played by the engine. Filler references are now checked for existence, ownership and ready status on channel create and update. - [FIX]: A dangling
filler_file_id(deleted or nonexistent file) was accepted silently and degraded to dead air at broadcast time; it is now rejected at configuration time with a clear message.
Functional fixes
- [FIX]: Admin user creation never worked.
POST /api/admin/usershad no handler β the request fell through the method switch and returned an empty HTTP 200, creating nothing, while the Admin Guide described the feature as available. Implemented with email and password validation (min 8 characters), role assignment, duplicate detection (409) and a proper 405 for unsupported methods. The Admin tab now has a + New User button.
New: extensive in-app documentation
- [NEW] Five step-by-step tutorials β Your First Channel (10 min), Build a Broadcast Day, a 24/7 FAST Channel, Multi-Tenant Setup, and Diagnose a Dead Stream (a layered top-to-bottom diagnostic path).
- [NEW] Recipes & Examples β encoding commands that conform sources for reliable playout, building a branded slate, restreaming to RTMP platforms, SRT relaying, scripting a schedule via the API, and backup/restore.
- [NEW] "How PlayoutGo Compares" β an honest positioning table against ffmpeg scripting, OBS, CasparCG, Flussonic/Nimble, commercial playout suites and cloud FAST platforms, including an explicit choose something else whenβ¦ section.
- [NEW] FAQ (re-encoding, channel density, SRT rejections, Safari vs VLC strictness, restart behaviour, silent audio, storage, internet exposure, shared SRT ports) and a Glossary of broadcast terms (anchor, floating, gap, overrun, GOP, IDR, discontinuity, null packets, caller/listener, FAST, as-run).
- [NEW] Help tab opens with a one-click launcher bar for every major topic.
Friendlier interface
- [NEW] First-run banner on Channels that adapts to your state β prompts you to upload media if you have none, or to create your first channel if you do, with a link into the 10-minute tutorial.
- [NEW] Actionable empty states: an empty rundown now explains the next step and links to the relevant tutorial, and detects whether the real blocker is that no media has been uploaded yet.
Regression protection
- [NEW]
tenant_isolation_test.goβ proves foreign media is rejected from both the playlist and filler paths, that dangling filler ids are rejected, that owners are not blocked from their own media, and that admin user creation works including duplicate/weak-password handling. - [NEW]
TestHelpAnchorsResolveβ every in-app help link and internal documentation link is checked against real section ids, so a renamed heading fails the test run instead of silently mis-navigating. - [TEST] Audited all SQL SELECT column lists against their Scan argument counts (a past source of bugs): all aligned.
v1.6.1 β Critical UI Repair
Fixed
- [FIX] Critical:
loadChSels()β the function that fills the per-page channel dropdowns β was deleted during the v1.4 Playlist/Rundown merge while five call sites still referenced it. Every visit to Rundown or Stats threw ReferenceError: loadChSels is not defined, aborting the page load: the channel selector stayed empty, the rundown never rendered, and the Add Files panel stayed blank. Restored, and now also fetches the channel list on demand, preserves the operator's selection across refreshes, and falls back to the first channel if the previous selection no longer exists. - [FIX] Critical:
renderPlFiles()(removed with the old Playlist page) was still called on every media load, throwing insideloadFiles(). Now calls the Rundown's file renderer. - [FIX]:
previewHLS()called a non-existentloadHLSPlayer()and targeted a stale input id, so the βΆ preview button on channel cards did nothing. Fixed to use the real player input and play routine. - [FIX]: Navigation tabs overflowed on narrower windows, scrolling the π Help tab out of reach. Tabs now compact progressively and collapse to icons on small screens so every tab stays clickable.
- [FIX]: Rundown and Stats no longer dead-end when a channel isn't selected β they explain what to do ("No channels yet β create one on the Channels tab first") and the media panel still works.
New: regression protection
- [NEW]
ui_static_test.goships with the source: it parses the interface, cross-checks every inline event handler and app-level call against the defined functions, and verifies that all element IDs the JS depends on exist. This class of failure β invisible to the Go compiler because the UI is plain inline JS β now fails the test run instead of reaching production. Verified by deliberately re-deletingloadChSelsand confirming the test fails. - [NEW] Tests are now included in the distributed source archive; run the whole suite with
go test ./...before deploying.
v1.6.0 β Gap Filler & Schedule-Engine Fix Release
New
- [NEW] Gap filler content β per-channel slate/filler clip that loops during schedule gaps before anchors (whole plays + null-padded remainder), replacing dead air. Configured in channel settings; picked up live; GAP rows in the Rundown show which clip will cover each gap. Verified live: 26 s gap covered by 4Γ6 s plays + 2 s pad with the anchor starting on its exact second.
- [NEW] Rundown bulk bar: β§ Duplicate β append floating copies of the selection to repeat a block later in the day.
- [NEW] Idempotent additive schema migrations (filler_file_id added automatically on upgrade; safe to re-run).
Bugs found by live testing and fixed
- [FIX] High: Starting a channel before its first anchor skipped all pre-anchor floating items (marked them played and jumped to the anchor). Start now begins at the top, plays the floating content, and waits at the anchor.
- [FIX] High: Future-anchored items were withheld from the engine until 5 s before air, so long gaps idled in a generic null-packet loop β bypassing the schedule-wait path (and the new filler). The engine now receives the anchored item immediately and owns the entire wait.
v1.5.0 β In-App Documentation & Field-Validation Release
New: documentation lives in the interface
- [NEW] π Help tab β the complete operations manual (this document) is embedded in the web UI; no context switch to read it.
- [NEW] Contextual ? buttons on every page (Channels, Media, Rundown, HLS Player, EPG, Stats, Admin) jump directly to that page's manual section.
- [NEW] UI Tour β five-minute onboarding walkthrough for new operators.
- [NEW] Admin Guide β user management, roles & scoping, re-import / fix-status maintenance, backup and upgrade practice.
- [NEW] Testing Guide extended with the in-app-help acceptance step.
Fixes
- [FIX]: M3U playlists and the EPG index emitted a non-canonical HLS URL (
/hls/<slug>/β¦without the channel-ID suffix). Both now emit the canonical/hls/<slug>-<id>/index.m3u8, matching the UI. The old form still resolves.
Field validation performed for this release
- [TEST] Full live end-to-end on the release binary: real multipart uploads β automatic probe (correct dimensions/fps/duration persisted) β rundown β running channel β HLS fetched over HTTP with per-segment ffprobe/ffmpeg validation (keyframe start, 48 kHz stereo audio on video-only sources, clean decode, discontinuity at file boundaries).
- [TEST] Loop semantics: after a full pass the playlist resets to pending and replays; note that anchors are one-shot wall-clock events β on loop, past anchors play immediately in flow order.
- [TEST] Deleting a media file while its channel is on air: playlist entries cascade away and the engine continues seamlessly with remaining content.
- [TEST] XMLTV and token-scoped M3U verified live.
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
- [NEW] Playlist + Schedule merged into a single Rundown with the media library alongside β one view for what plays and when.
- [NEW] Anchored π vs floating π items: anchors pin exact air times; floating items chain after the previous item and reflow live on reorder/add/remove.
- [NEW] Gap & overrun detection β amber dead-air rows and red "+over" badges before/on anchors.
- [NEW] NOW line with countdown + auto-scroll, day separators, timezone label, footer totals (items Β· duration Β· anchors Β· span).
- [NEW] Multi-select with shift-click ranges; bulk Move Top/Bottom, Clear times, Remove.
- [NEW] Undo (10 levels) for schedule and order mutations; on-air guard prompts before touching the playing item.
- [NEW]
POST /api/playlist/{id}/schedule_bulkβ transactional bulk schedule writes; any failure rolls back completely. - [NEW] Flow-aware restart: CalcSchedulePosition chains floating-item durations after anchors, so restart resumes mid-block in the right item at the right offset.
Streaming correctness
- [FIX] Critical: Silent-audio synthesis for video-only files. Mixed playlists produced an empty AAC track (0 channels, no sample rate) that Safari rejected wholesale; video-only sources now get a real silent 48 kHz stereo AAC-LC track, giving one stable audio layout across the stream.
- [FIX] Critical: SRT clients joining mid-GOP saw "non-existing PPS / no frame". New connections are now keyframe-gated β output starts on the next IDR (with SPS/PPS).
- [FIX]: HLS segments cut only on keyframes (4 s target, 15 s sparse-GOP cap) so every segment starts decodable; removed the EXT-X-INDEPENDENT-SEGMENTS tag that conflicted with EXT-X-VERSION:3 on strict players.
- [FIX]: EXTINF durations include the final sample (segments no longer under-report by one frame).
- [FIX]: Documentation/UI/logs standardized on the working SRT URL form:
streamid=(not srt_streamid) with microsecond latency; the listener log prints a paste-ready connect URL. - [FIX]: SRT listener routing is exact-match only β empty/wrong stream IDs can no longer capture a channel's single output slot; rejects are throttled in logs.
Data & API fixes
- [FIX] Critical: Probe metadata (duration, size, codecs, width/height, fps, bitrate) is now persisted β files no longer re-probe on every restart and the UI shows real properties.
- [FIX]: tkhd v1 width/height offsets corrected in both the main parser and the fallback scanner; corrupt/audio-less-moov uploads are marked
errorinstead ofready; uploads insert asprocessing. - [FIX]: Channel PUT merges onto the stored record β partial updates no longer zero unspecified fields.
- [FIX]: Playlist item PUT rejects malformed RFC-3339 with 400 (was silently ignored).
- [FIX]: Time-aware skip uses playlist position, not row id (correct after reorders).
Security
- [SEC]: Raw SRT passphrase removed from channel create/update responses and from
/api/stats/{id}; explicitclear_srt_passphraseflag added. - [SEC]:
/m3unow requires a token and is scoped per user (admins see all). - [SEC]: HLS proxy: IP-pinned dialing (DNS-rebinding) + allowlist re-check on every redirect hop; empty-DNS guard.
- [SEC]: SRT URL building uses URLSearchParams/url.Values (correct encoding); UI escapes stream/HLS URLs in HTML and onclick contexts; M3U attribute escaping covers backslashes and strips CR/LF.
- [SEC]: Remaining MP4 table allocations bounded (ctts/stss/stsc/co64/stsz) with stsc-empty panic guard.
- [FIX]: SRT buffer/max-bandwidth now apply in listener mode and participate in shared-listener compatibility checks; auto-restart back-off is cancelable by Stop.
v1.3.1 β Critical Streaming Fix Release
- [BUG] Critical: Root cause of all OOM crashes found and fixed.
writePES()intsmuxer.gohad an infinite loop when the final TS packet required stuffing with 1β183 bytes. The adaptation-field stuffing calculation setmaxPayload=0, sotoCopy=0andremainingnever advanced β writing empty 188-byte packets forever at RAM speed until OOM. This caused the process to crash after 0β60 seconds of play depending on RAM and PTS pacing speed. Every single audio and video sample triggered this loop for its last partial TS packet. - [FIX]: Rewrote
writePESwith correct MPEG-TS stuffing: determine how many payload bytes fit first, then compute the adaptation-field size from the remaining space β never the other way around. The loop now always advancespayloadby at least 1 byte per iteration. - [FIX]: HLS streams now produce segments reliably β first segment flushes on the first keyframe after 0.5s (was: first keyframe after 4s minimum).
- [FIX]: SRT listener rejection log spam reduced β identical rejections now batched and logged once per 5 seconds instead of per-connection (100+ per second).
- [FIX]: Playout goroutine now recovers from panics via
defer/recover; a crash in one channel no longer kills the entire server. - [FIX]: PSI (PAT+PMT) injected immediately when an SRT client connects mid-stream, so the decoder gets programme map without waiting for the next 100ms PSI interval.
- [FIX]: Per-sample size validation added to mp4reader (max 50MB) to guard against OOM from corrupted STSZ box entries.
- [FIX]: Explicit
buf.Reset()added before first-sample write inplayFilefor belt-and-suspenders safety. - [FIX]: 20MB per-sample TS output sanity guard β skips the sample if the muxer produces unreasonably large output, rather than crashing.
- [FIX]: HLS force-flush reduced from 12s to 8s for faster stream recovery after a GOP without keyframes.
v1.3.0 β Robustness & Leak-Fix Release
- [BUG] Critical: MP4 parser OOM β malformed MP4 with crafted sample count (up to 4 billion) caused instant out-of-memory; capped at 10 million across STSZ, STTS, and STCO box parsers
- [BUG] High: SRT connection leak on cancel/timeout β goroutine running
srt.Dialcould leave a live socket open; now drained and closed in both exit paths - [BUG] Medium: Scheduler goroutine leaked on shutdown β now context-aware; exits cleanly when server stops
- [BUG] Medium: SQLite
busy_timeoutnot set β concurrent writes could return "database is locked"; set to 5s. Also addedsynchronous=NORMALand 32MB page cache - [BUG] Low: HLS tmp playlist file left on disk if
os.Renamefails;os.Removeadded to both WriteFile and Rename error paths - [SEC] Medium: XSS β
lv.current_fileinjected raw into stats gridinnerHTML; now escaped - [SEC] Medium: XSS β
u.roleraw in admin user badge and meta div; now escaped - [SEC] Medium: XSS β
editUseronclick embedded email in single-quoted JS attribute; replaced withdata-*attributes and dispatcher - [SEC] Low: M3U channel name not escaped in
tvg-nameattribute β quotes in channel name broke the M3U format;m3uAttrEsc()added - [SEC] Low: SRT stream ID not encoded in M3U URI β special chars broke SRT URL parsing; percent-encoded via
strings.NewReplacer - [BUG] Low: Missing HTTP 405 guards on
handleMe,handleStats,handleStatsPoll,handleAdminStats,handleAdminChannels - [BUG] Low: HTTP server missing
IdleTimeoutandReadHeaderTimeoutβ added 120s and 10s respectively to guard against slow-loris and zombie connections
v1.2.0 β Hardening & Polish Release
- [SEC] High: Login brute-force protection β 20 consecutive failed attempts per IP triggers a block on
/api/auth/login - [SEC] High: Role validation in admin user update β role must be exactly
"admin"or"user"; arbitrary strings rejected - [SEC] Medium: Admin self-demotion prevention β cannot demote own account to
"user"via the admin API - [SEC] Medium: Removed JWT token from public M3U link in EPG index β token was passed as query param to a public endpoint that ignored it
- [SEC] Medium: File preview popup no longer embeds JWT in video src attribute β now fetches via Authorization header and creates a Blob URL
- [BUG] Medium: Channel rename now returns HTTP 409 Conflict on duplicate name (was silent 500)
- [BUG] Low: output_mode and srt_mode validated on create and update β arbitrary strings no longer accepted
- [BUG] Low: srt_port range enforced (1024β65535); srt_latency capped (0β60 000 ms)
- [BUG] Low: srt_passphrase length validated (10β79 chars) per SRT protocol requirement
- [FEAT]: Added
POST /api/auth/refreshβ renew JWT without re-entering credentials - [FEAT]: Email format validation on registration
- [FEAT]: Minimum password length raised to 8 characters
- [FEAT] multiplayout: Upload extension whitelist (MP4/M4V only) + MaxBytesReader on uploads
- [FEAT] multiplayout: Login brute-force protection (20 attempts/IP)
- [BUG] multiplayout: Fixed pre-existing undefined variable
ninensureSRTDial(caused build failure)
v1.1.0 β Security & Correctness Release
- [SEC] Critical: Fixed HLS path traversal β
/hls/slug//etc/passwdno longer escapes hls_dir - [SEC] Critical: Fixed data race on SRT output pointer β added RWMutex; removed "acceptable race" workaround
- [SEC] Critical: Fixed SSRF in HLS proxy β private/loopback IPs blocked; optional host allowlist added
- [SEC] High: Fixed stats endpoints leaking other users' channel data β now filtered by ownership
- [SEC] High: Fixed admin password reset on every restart β EnsureAdmin() no longer overwrites existing credentials
- [SEC] High: Added rate limiting (5 attempts/IP) to
/api/auth/reset - [SEC] High: config.json now written with 0600 permissions to protect secrets
- [SEC] Medium: Removed ?token= from main API middleware β header-only auth for REST; SSE retains it (browser limitation)
- [BUG] Medium: Fixed proxy URL encoding β signed segments with query params now survive rewrite
- [BUG] Medium: Playlist position in PUT /item/{id} was silently dropped β now applied correctly
- [BUG] Medium: AddToPlaylist now shifts existing items to prevent duplicate positions
- [BUG] Medium: ReorderPlaylist now appends omitted items β partial reorders no longer leave stale positions
- [BUG] Medium: GetNextPlaylistItem now blocks at future-scheduled items β unscheduled items can no longer jump past them
- [BUG] Medium: started_at no longer overwritten on stop/error transitions
v1.0.0 β Initial Release
- Pure-Go MP4 demuxer (H.264 + AAC, ISO-BMFF)
- MPEG-TS muxer with PAT/PMT/PES, continuity counters, SCTE-35 pass-through
- HLS segmenter: keyframe-aligned 4s segments, rolling 6-segment window, atomic playlist writes
- SRT output via datarhei/gosrt β caller and listener modes, auto-reconnect
- Multi-tenant REST API with JWT auth, bcrypt passwords, per-user channel limits
- XMLTV EPG generation and M3U playlist feed
- Background scheduler for time-based playout
- Time-aware restart: seeks to correct playlist position after restart
- Seamless multi-file stitching via global PTS offset tracking
- Embedded SPA web UI with live stats dashboard