π¬ PlayoutGo Operations Manual v2.42.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.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.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.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.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.{"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
DELETE /api/encode/jobs/{id} clear a finished job
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./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
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