π¬ PlayoutGo Operations Manual v3.46.0
A pure-Go multichannel broadcast playout platform. Demuxes MP4 (H.264/AAC), muxes live MPEG-TS, and streams via SRT or HLS β with no FFmpeg, no GStreamer, and no external dependencies beyond the Go standard library.
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.
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.
Architecture
βββ main.go # HTTP server, router, graceful shutdown
βββ config.go # JSON config loader, 0600 file permissions
βββ database.go # SQLite schema, CRUD, playlist/schedule logic
βββ models.go # Data structs: User, Channel, MediaFile, etc.
βββ auth.go # JWT generation/validation, bcrypt, middleware
βββ handlers.go # REST API handlers
βββ playout.go # PlayoutEngine, PlayoutManager, Scheduler
βββ mp4reader.go # Pure-Go MP4/ISO-BMFF demuxer (H.264+AAC)
βββ tsmuxer.go # MPEG-TS muxer: PAT/PMT/PES, continuity counters
βββ hlswriter.go # HLS segmenter: keyframe-aligned, rolling window
βββ hlsproxy.go # CORS proxy for external HLS feeds + SSRF guard
βββ srtcaller.go # SRT caller + listener via datarhei/gosrt
βββ epg.go # XMLTV EPG generation, M3U playlist
βββ docs.go # This documentation page + viewer HTML
βββ static/ # Embedded SPA (index.html, app.js, style.css)
Data flow
When a channel starts: the PlayoutEngine reads MP4 samples via mp4reader.go, wraps them in MPEG-TS packets via tsmuxer.go, paces output to wall-clock time, and simultaneously feeds both the HLSSegmenter (for browser playback) and the SRTCaller (for downstream ingest). SRT reconnection happens in a background goroutine so a dropped SRT client never interrupts HLS output.
Quick Start
Build and run
cd src go build -o playout . ./playout
Opens on http://localhost:8700. Default admin: admin@playout.local / admin!123 β change immediately.
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.
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 (your token is embedded automatically).
- π 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 next to the binary. If missing, it is created with defaults. File permissions are 0600 (owner-only) to protect secrets.
{
"port": 8700,
"admin_email": "admin@playout.local",
"admin_password": "CHANGE_THIS",
"jwt_secret": "CHANGE_THIS_TO_A_LONG_RANDOM_STRING",
"database_path": "playout.db",
"uploads_dir": "uploads",
"hls_dir": "/dev/shm/playout_hls",
"max_upload_mb": 10240,
"hls_proxy_allowed_hosts": []
}
| Key | Default | Description |
|---|---|---|
| port | 8700 | HTTP listen port |
| admin_email | admin@playout.local | Initial admin account email (used only on first run) |
| admin_password | admin!123 | Initial admin password β also the emergency reset secret |
| jwt_secret | (weak default) | HMAC-SHA256 signing key for JWTs β must be changed in production |
| database_path | playout.db | SQLite database file path |
| uploads_dir | uploads | Base directory for uploaded media files |
| hls_dir | /dev/shm/playout_hls | Where HLS segments are written. tmpfs (/dev/shm) is strongly recommended for performance and to reduce SSD wear |
| max_upload_mb | 10240 | Maximum single-file upload size in MB |
| hls_proxy_allowed_hosts | [] | Optional allowlist for the HLS CORS proxy. Empty = any public host allowed (private IPs always blocked) |
POST /api/auth/reset with the config secret for emergency recovery.
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 so a slate loops instead of dead air.
- β 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 token-scoped M3U into your IPTV app, and the XMLTV URL into Plex/Jellyfin/Emby for a programme guide.
- Monitor. π Stats shows live throughput and the error log. Run the server under systemd so it restarts on reboot:
[Unit] Description=PlayoutGo After=network.target [Service] WorkingDirectory=/opt/playout ExecStart=/opt/playout/playout Restart=always RestartSec=5 [Install] WantedBy=multi-user.target
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=β¦and/epg/my?token=β¦return only their channels, so the links are safe to hand out individually.
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 with ffmpeg
# PlayoutGo has no RTMP output yet; relay its HLS or SRT with ffmpeg
ffmpeg -re -i "http://SERVER:8700/hls/mychannel-1/index.m3u8" \
-c copy -f flv "rtmp://a.rtmp.youtube.com/live2/YOUR-KEY"
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 with no encode step, 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), 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 static binary, no runtime dependencies, 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 on-air graphics overlays β OBS, vMix, CasparCG.
- You need an ABR ladder, DRM or SDI output β a transcoding platform downstream (PlayoutGo outputs a single 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?
No. It remuxes H.264/AAC into MPEG-TS in real time. 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 there's 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?
Put it behind a reverse proxy with TLS (there's no built-in HTTPS yet), change the default admin password, 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 null packets (dead air). |
| 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 that keep a stream alive with no content β the dead-air fallback when no gap filler is set. |
| 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 (visible under Stats as play history). |
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 system accepts MP4 / M4V files with:
- Video: H.264 (AVC) β HEVC/H.265, VP9, AV1 are not supported
- Audio: AAC-LC β other audio codecs (MP3, Opus, AC-3) are not supported
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 pads with null packets 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 browser timezone; 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.
Schedule engine semantics
The engine walks the rundown in position order. If the next pending item is anchored in the future, it pads with null packets and waits β 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 black.
What happens during a gap, precisely
- The stream never stops. Even with no filler configured, the engine keeps transmitting null packets: the TS/HLS output stays alive, segments keep being written, SRT clients stay connected and the channel stays running. Viewers see black β dead air, not a dropped stream.
- With a filler configured, the engine fits your clips into the hole and plays them, null-padding only whatever is left over.
- 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.
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 null-padded, 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 that gets null-padded can never be smaller than your shortest clip.
- 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 β include a slate in the pool; null packets are the last resort |
| 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 delivered during the item. Populated for SRT output; HLS-only channels write segments to disk rather than a socket, so this reads 0. |
| Status | completed aired in full Β· playing on air now Β· error failed Β· interrupted the process died mid-item (closed out automatically on the next start, so airtime totals stay honest). |
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.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.
{"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
All API endpoints (except EPG feeds and /api/auth/reset) require a JWT Bearer token. Obtain a token via login.
POST /api/auth/login
{"email": "admin@playout.local", "password": "admin!123"}
β {"token": "eyJ...", "user": {...}}
Pass the token in the Authorization header:
Authorization: Bearer eyJ...
?token= query parameters (except for SSE streams, which cannot set custom headers). Always use the Authorization header for REST calls.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.current_password and new_password. Changes persist across restarts.{"secret": "config_admin_password"}. Rate-limited to 5 attempts per IP. Resets admin email/password to values in config.json.Public Endpoints (no token)
Three things are served without authentication, deliberately, because the systems that fetch them cannot hold a credential.
no-store: a headline correction that a proxy holds for
five minutes is a correction that did not happen.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-GCM under a key derived from the server’s own secret, 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 that go to SRT and HLS, so there is only ever one quality to reason about.
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.
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.
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.
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.
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 4
gop_seconds 2
audio_hz 48000 # the source rate, so the node need not guess
overlay <overlay.png> # the rendered code, as a file part
overlay_pos W-w-20:H-h-20 # an ffmpeg x:y expression
overlay_scale 0.15
file <source.mp4> # or sha, when the node already has it
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. Note the scale is relative to the overlay’s own height, not the frame’s, so 0.15 on a 200 px image gives a 30 px badge.
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",
"source_url": "https://playout/hls-clean/news-24-7/index.m3u8",
"layout": "lshape",
"headline": "BREAKING",
"crawl": "β¦scrolling textβ¦",
"bar_background": "#101820",
"bar_accent": "#2ea0ff",
"graphics": {"type":"html","url":"https://playout/graphics/7.html","refresh_seconds":5},
"renditions": [{"name":"720p","height":720,"bitrate_kbps":2500,"video_k":2500}]
}
The two colour fields are sent when a channel uses built-in branding; the graphics
block is sent only when an operator supplied HTML or a URL, because a page outranks
the node’s own drawn bar and sending one unconditionally means every channel takes the page
path.
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.
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."}
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":"running"} # 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/ch7","key":"ch7", "connected":false,"ready":false,"why":"nothing is publishing to this point"} # 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/ch7 # 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/api/epg/xmltv # whole system, XMLTV curl -s $BASE/api/epg/xmltv?channel=7 # one channel curl -s $BASE/api/epg/now-next?channel=7 -H "Authorization: Bearer $TOK" β {"now":{"title":"The Nine O'Clock News","starts":"2026-09-01T21:00:00Z","ends":"β¦"}, "next":{"title":"Weather","starts":"β¦"}}
What to expect: XMLTV is public by design so a partner can poll it without a
key. 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. |
409 | The state does not allow it β taking a live feed that is not arriving, starting a channel already running. | The body says which condition failed. |
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.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.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}.Schedule API
scheduled_at within the given time window. Defaults to today through 7 days from now.Stats API
?token= since EventSource cannot set headers.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.
tvg-id, tvg-name, and catchup-source attributes.Admin API
?limit= (200 default, 2000 maximum),
?user= and ?action=. Records channel starts and stops, deletions and
suspensions β the actions that alter what goes to air or who can reach it. 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.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./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.All admin endpoints require both authentication and the admin role.
max_channels limit (0 = unlimited).processing status.running or connecting after a server restart.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 after 4 seconds (hard cap 15 s for sparse-GOP content), 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.
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. |
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, not on the playout host. Add them 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.
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
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
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.
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 β the top rung. PlayoutGo emits one stream, so the lower rungs exist for a downstream packager rather than for playout itself.
- 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. Use ABR for HLS and treat SRT as a single-rendition contribution feed.
- 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.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?key=pgk_β¦",
"logo_url": "https://utopia.bozztv.com/api/app/channels/1/logo?key=pgk_β¦",
"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. Response is cacheable for 10 seconds (Cache-Control is set), which is the right poll interval for a grid.
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?key=pgk_β¦
GET /api/app/channels/{id}/logo?key=pgk_β¦
- 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, // already includes the key
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&t=+timestamp when you want a fresh frame. - 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 β matching the cache headers. Faster gains nothing and only adds load. |
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 lists all channels with their HLS or SRT URLs. Import in VLC, Kodi, or any IPTV app:
vlc http://your-server:8700/m3u
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.
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 / disable: change email, role, or reset a password from the user list. Disabling a user blocks login but keeps their channels and media intact.
- Deleting a user cascades: their channels, playlist items, and media records are removed (files on disk under uploads/user_<id>/ remain β clean up manually if desired).
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 β Reimport, 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 (POST /api/admin/fixstatus) β re-probes files stuck in processing or wrongly marked, refreshing their metadata and status.
- Admin stats β global dashboard: per-user channel/file counts, disk usage, running engines.
Operational practices
- Backups: everything lives in two places β
playout.db(all users/channels/rundowns/metadata) and theuploads/tree (media). Copy both; 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 privileges. Either run as root, or grant the binary the capability once:
sudo setcap 'cap_net_bind_service=+ep' /opt/playout/playout
PlayoutGo prints this hint automatically if the bind fails.
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.Security Hardening
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 UI's copy link includes your token automatically.
- 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 and reject-log lines are throttled to one per 5 s per ID.
| 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 | 5-attempt rate limit per source IP added |
| 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 (EventSource limitation) |
| Data race on e.output (SRT goroutine) | π΄ Critical | outputMu RWMutex added; all accesses use getOutput()/clearOutput() helpers |
| Login endpoint brute-forceable | π‘ High | Per-IP failure counter; 20 consecutive failures = block |
| 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 | Token stripped from public M3U URL β endpoint needs no auth |
| JWT in video src attribute (file preview popup) | π Medium | Preview now uses fetch() + Blob URL; token stays in Authorization header |
| 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 | First segment flushes on any keyframe after 0.5s |
| SRT client connecting mid-stream misses PAT/PMT | π Medium | PSI injected immediately on SRT connect via srtNewConn atomic flag |
Production checklist
- Change
admin_passwordandjwt_secretin config.json before first deployment - Put PlayoutGo behind a reverse proxy (nginx/Caddy) with TLS β never expose it directly on port 80/443
- Firewall
/api/auth/resetat the reverse proxy layer (allow only from management IPs) - Set
hls_proxy_allowed_hostsif you know which external HLS servers you need to proxy - Set
hls_dirto tmpfs (/dev/shm) for performance - Run as a non-root user; set
GOMEMLIMIT=3584MiBin the systemd unit - Back up
playout.dbanduploads/regularly
Deployment (systemd)
[Unit] Description=PlayoutGo Playout Server After=network.target [Service] Type=simple User=playout WorkingDirectory=/opt/playoutgo ExecStart=/opt/playoutgo/playout Restart=always RestartSec=5 Environment=GOMEMLIMIT=3584MiB # Ensure HLS tmpfs dir exists ExecStartPre=/bin/mkdir -p /dev/shm/playout_hls [Install] WantedBy=multi-user.target
sudo systemctl enable --now playoutgo
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"
Run POST /api/admin/fixstatus to reset channels that are stuck in running or connecting after an ungraceful shutdown. Or stop/start the channel from the UI.
HLS stream shows "Stream starting, please waitβ¦"
The channel is running but no segments have been flushed yet. Wait 4β5 seconds for the first keyframe boundary, 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/reimport to re-trigger probing on stuck files. 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 βΊ curl -s "http://SERVER:8700/m3u?token=$TOKEN" β 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/disable 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: β¦ looped N time(s); padding Xs to anchor" β 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 β Gap transmits null packets (dead air) as before βΊ 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" βΊ 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
21 Β· 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
22 Β· 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"
23 Β· 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
24 Β· 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 β Gap transmits null packets; 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)
25 Β· 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
25 Β· App API 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
26 Β· 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
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.Encode this job's file again. Used by β» Encode again on a failed row. The settings repeated are the channel's current profile when the job came from a channel, and the job's own recorded profile otherwise β so a file encoded from the Media page can be retried too. The failed row is cleared on success, and the retry is written to the audit trail.
Answers 200 with the new job id, or 202 when every node is busy and
the work was queued. 404 means the job has already been cleared from the list β
start it again from the Media page.
/api/encode/nodelist/{id} to remove.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
playoutgo_channel_pcr_late_total above zero is a feed that will fail an operator's ingest
test β the figure worth alerting on.Changelog
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