🎬 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.

Quick links:  Web UI  Β· HLS Viewer  Β· EPG Index  Β· M3U Playlist

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 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

src/
β”œβ”€β”€ 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

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

Configuration (config.json)

Config is loaded from config.json 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": []
}
KeyDefaultDescription
port8700HTTP listen port
admin_emailadmin@playout.localInitial admin account email (used only on first run)
admin_passwordadmin!123Initial admin password β€” also the emergency reset secret
jwt_secret(weak default)HMAC-SHA256 signing key for JWTs β€” must be changed in production
database_pathplayout.dbSQLite database file path
uploads_diruploadsBase directory for uploaded media files
hls_dir/dev/shm/playout_hlsWhere HLS segments are written. tmpfs (/dev/shm) is strongly recommended for performance and to reduce SSD wear
max_upload_mb10240Maximum 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)
First-run behaviour: The admin account is created from config on first run only. Subsequent restarts do not overwrite the admin password β€” so password changes made through the UI persist across restarts. Use 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.

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

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

Tutorial 2 Β· Build a Broadcast Day 15 min

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

Step 1 β€” flow everything from a start time

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

Step 2 β€” pin a hard start

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

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

Step 3 β€” repeat a block

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

Step 4 β€” safety net

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

Step 5 β€” survive a restart

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

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

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

  1. Create the channel with Output mode = Both (HLS for viewers, SRT for a downstream CDN or transcoder) and Loop playlist ticked.
  2. Load a deep rundown β€” several hours of content. With looping on, when the last item finishes the whole playlist resets to pending and starts again.
  3. Assign a gap filler (channel settings β†’ Gap filler content): a 10–30 s branded slate. Anything that would have been dead air becomes on-brand.
  4. Decide on anchors. For pure looping, use no anchors β€” content just cycles. For appointment programming, anchor only the few must-hit slots and let the rest flow.
    Anchors and looping interact: an anchor is a one-shot wall-clock event. When a looping playlist resets, anchors already in the past fire immediately in flow order rather than waiting for tomorrow. For a repeating daily grid, re-anchor per day (or leave the day unanchored and let it cycle).
  5. Publish. From πŸ“‘ EPG copy the token-scoped M3U into your IPTV app, and the XMLTV URL into Plex/Jellyfin/Emby for a programme guide.
  6. 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.

  1. As admin, open πŸ›‘ Admin and create a user with an email, a password of at least 8 characters, and role user.
  2. They log in and see only their own channels, media, rundowns, EPG feeds and stats. Admins see everything.
  3. Isolation is enforced server-side, not merely hidden in the UI: a user cannot read another tenant's files, add another tenant's media to their playlist, or point their gap filler at it β€” all return 403/400. (Both playlist and filler paths were hardened in v1.7.0; see the changelog.)
  4. Give each tenant a distinct SRT port, or share a port and give each channel a distinct stream ID β€” the listener routes by exact stream-ID match.
  5. Each tenant's /m3u?token=… 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)
The server tells you the answer: when an SRT listener starts it logs a complete, paste-ready connect URL. Copy that rather than composing one by hand.

Recipes & Examples

Encode source files for reliable playout

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

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

Make a branded slate for gap filler

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

Restream a channel to YouTube/Facebook 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.

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

Choose PlayoutGo when

Choose something else when

Frequently Asked Questions

Does PlayoutGo re-encode my files?

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

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

Channels

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

Channel statuses

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

HLS URL format

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

Media Files

The system accepts MP4 / M4V files with:

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

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

The Rundown β€” Playlist & Scheduling v1.4

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

Core concept: anchors and floating items

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

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

Gap and overrun detection

Orientation aids

Operator tutorial: building a broadcast day

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

Multi-select, bulk edits, undo

On-air safety

Removing, moving, or re-scheduling the item that is currently playing prompts for confirmation. The playing row is highlighted green with a β–Ά marker.

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

Bin-packing: why a pool beats a single clip

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

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

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

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

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

Configuring it

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

Choosing good filler

How this compares with professional playout systems

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

TechniquePlayoutGo
Filler/interstitial pool β€” fit promos, idents and PSAs into the holeβœ… Yes, with bin-packing across a multi-clip pool
Slate fallback β€” guarantee something legal always goes outβœ… Yes β€” 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.

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

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

⬆ Import β€” CSV or XML

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

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

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

CSV

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

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

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

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

XML β€” three dialects accepted

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

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

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

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

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

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

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

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

API

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

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

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

Restart & Resilience v1.9

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

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

Where does it resume from?

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

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

Automatic at the OS level

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

Measuring Channel Density v1.28

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

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

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

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

Why it stops on output health, not a resource threshold

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

Reading the results

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

As-Run Logs v1.10

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

Reading the log

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

Started / EndedWall-clock times playback actually began and finished.
DurationMeasured on-air time β€” not the file's nominal length. A shortfall against the file's nominal length means the channel was stopped or the item failed mid-play β€” not that playout truncated it.
SentBytes delivered during the item. Populated for SRT output; HLS-only channels write segments to disk rather than a socket, so this reads 0.
Statuscompleted 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
Scoping: administrators see every channel; other users only ever see their own, and an explicit request for another tenant's channel is refused with 403.

Output Modes

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

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...
Security note: The API no longer accepts tokens via ?token= query parameters (except for SSE streams, which cannot set custom headers). Always use the Authorization header for REST calls.
POST /api/auth/register
Create a new user account. Returns JWT token.
POST /api/auth/login
Authenticate and receive a JWT token (72-hour expiry).
POST /api/auth/change-password
Change own password. Requires current_password and new_password. Changes persist across restarts.
πŸ” Requires auth
POST /api/auth/reset
Emergency admin reset. Body: {"secret": "config_admin_password"}. Rate-limited to 5 attempts per IP. Resets admin email/password to values in config.json.
⚠️ No token required β€” protected by config secret only. Restrict access in production.
GET /api/auth/me
Returns the authenticated user's profile.
πŸ” Requires auth
POST /api/auth/refresh
Issue a fresh 72-hour JWT without re-entering credentials. Call this before the current token expires to stay logged in. Requires a currently valid token.
πŸ” Requires auth

Channels API

GET /api/channels?summary=1
The fields the channel grid renders and nothing else β€” about a fifth of the full record. Intended for polling: an interface refreshing every few seconds does not need forty fields to draw six. Without the parameter the whole record is returned, unchanged, so anything reading the rest keeps working.
πŸ” Requires auth
POST /api/channels/{id}/reencode
Re-encode every file in this channel's rundown with the channel's current QR code. Set qr_url on the channel, call this, and the code appears on all of its video. Change the URL and call it again for a new code. Each unique file is queued once however many times it appears; files already encoding are skipped. Errors on a channel playing original files, which have nothing to burn a code into.
πŸ” Requires auth Β· gpunode 1.21.0 or later
GET /api/channels/{id}/qr.png?url=…
A preview of the code that would be burned in, so the URL can be checked by scanning it before an encode is spent on it.
πŸ” Requires auth
GET /api/channels
List channels. Admins see all; regular users see only their own. Includes live stats if channel is running.
πŸ” Requires auth
POST /api/channels
Create a channel. Required fields: name. Optional: description, output_mode, srt_mode, srt_host, srt_port, srt_stream_id, srt_passphrase, srt_latency, loop_playlist.
πŸ” Requires auth
GET /api/channels/{id}
Get a single channel with live stats.
πŸ” Requires auth + ownership
PUT /api/channels/{id}
Update channel settings. Only the owner or admin may update.
πŸ” Requires auth + ownership
DELETE /api/channels/{id}
Stop and delete a channel (cascades to playlist items and stats).
πŸ” Requires auth + ownership
POST /api/channels/{id}/start
Start the playout engine for this channel. Calculates schedule position if items have scheduled_at set.
πŸ” Requires auth + ownership
POST /api/channels/{id}/stop
Stop the playout engine. Status set to stopped. The channel's original start time is preserved.
πŸ” Requires auth + ownership
GET /api/channels/{id}/stats
Returns playout history (last 50 sessions), error log, and current live stats.
πŸ” Requires auth + ownership
GET /api/channels/{id}/diag
Diagnostic endpoint. Returns playlist item status, file existence check, HLS directory status, and recent errors. Useful for debugging.
πŸ” Requires auth + ownership

Files API

GET /api/files
List media files owned by the authenticated user (admin sees all).
πŸ” Requires auth
POST /api/files
Upload a media file. Use multipart form: field file (MP4/M4V), optional short_desc and long_desc. Max size configured by max_upload_mb. File is probed asynchronously β€” status transitions from processing to ready or error.
πŸ” Requires auth
GET /api/files/{id}
Get file metadata including codec, duration, resolution, and probe status.
πŸ” Requires auth + ownership
PUT /api/files/{id}
Update short_desc and long_desc.
πŸ” Requires auth + ownership
DELETE /api/files/{id}
Delete the file record and the uploaded file from disk.
πŸ” Requires auth + ownership
GET /api/files/{id}/stream
Serve the raw MP4 file for browser preview. Supports HTTP Range requests.
πŸ” Requires auth + ownership

Playlist API

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

Schedule API

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

Stats API

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

EPG / XMLTV API

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

GET /epg/{channelID}
XMLTV EPG for a single channel. Public access allowed for media player compatibility. If a token is provided and the channel belongs to another user, returns 403.
GET /epg/my?token=JWT
XMLTV for all of the authenticated user's channels (admin sees all).
GET /m3u
M3U playlist with all channels. Use in VLC, Kodi, Jellyfin, etc. Includes tvg-id, tvg-name, and catchup-source attributes.

Admin API

GET /api/admin/audit
Who changed what, and when. Optional ?limit= (200 default, 2000 maximum), ?user= and ?action=. 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.
πŸ” Administrator only
GET /api/admin/backups
What database snapshots exist and how old the newest is. The newest is opened and read to confirm it is restorable, and reported as verified with what it holds. A warning appears when the newest is older than expected β€” a backup system that has silently stopped looks exactly like one that is working.
πŸ” Administrator only
POST /api/admin/backups
Take a snapshot now, before a risky change. Written from inside the database connection rather than copied, verified before it is kept, and discarded if it cannot be read.
πŸ” Administrator only
POST /api/admin/users/{id}/suspend
Body: {"suspended": true, "reason": "..."}. Stops the account's channels immediately and refuses to start them again, with the reason shown to whoever tries. Nothing is deleted β€” lifting the suspension brings the account back as it was. Enforced in the start path, so it holds across a restart. An administrator cannot suspend themselves or another administrator.
πŸ” Administrator only
GET /api/admin/settings
Whether registration requires an invite code. GET returns require_invite_code and how many usable codes exist; PUT {"require_invite_code": true} closes registration. Turning it on with no usable code is refused β€” that would leave nobody able to sign up and no way to invite them. Registration is open by default, which is right for an installation on a private network and wrong for one reachable from outside it.
πŸ” Administrator only
GET /api/admin/sysinfo
Host figures: disk, memory, load, and the encoded and upload directory sizes.
πŸ” Administrator only
GET /api/admin/delivery
Per-channel delivery health across the installation β€” whether segments are reaching their destination, not merely whether the channel is running.
πŸ” Administrator only
GET /api/admin/watch
Watch folders. POST to add one, DELETE /api/admin/watch/{id} to remove it. Media dropped into a watch folder is imported and probed automatically.
πŸ” Administrator only
GET /api/admin/invites
Outstanding invitations. POST to issue one, DELETE /api/admin/invites/{id} to revoke it.
πŸ” Administrator only
GET /api/admin/probe-status
Progress of media probing, for files still being examined after upload.
πŸ” Administrator only
GET /api/admin/usercdn
Per-account CDN hostnames, so each customer's playback URLs carry their own domain.
πŸ” Administrator only

All admin endpoints require both authentication and the admin role.

GET /api/admin/users
List all user accounts.
πŸ” Admin only
PUT /api/admin/users/{id}
Update a user's email, role, and max_channels limit (0 = unlimited).
πŸ” Admin only
DELETE /api/admin/users/{id}
Delete a user and all their data (channels, files, playlist items cascade).
πŸ” Admin only
GET /api/admin/channels
List all channels across all users with owner info.
πŸ” Admin only
GET /api/admin/stats
System-wide stats: user count, channel count, total media files, total bytes stored.
πŸ” Admin only
POST /api/admin/reimport
Re-probe all media files that are stuck in processing status.
πŸ” Admin only
POST /api/admin/fixstatus
Reset channels stuck in running or connecting after a server restart.
πŸ” Admin only

SRT Output

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

Caller mode (default)

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

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

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

Listener mode

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

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

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

Connecting a player (listener mode) v1.4

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

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

SRT parameters

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

HLS Output

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

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

Segments are keyframe-aligned MPEG-TS files: a segment closes on the first keyframe 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.

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

XMLTV / Plex Integration

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

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

Trim Points v1.38

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

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

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

What is refused

How it works

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

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

Repeating Days v1.38

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

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

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

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

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

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

Watch Folders v1.38

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

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

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

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

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

Why polling, not inotify

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

Half-written files

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

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

Encoding & Quality Modes v1.32

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

The three modes

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

Setting a mode

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

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

Encoding nodes

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

Encoding runs on one or more gpunode servers, 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.

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

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

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

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

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:

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

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

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

Adaptive delivery v1.64

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

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

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

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

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

What actually goes to air

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

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

What this does not do

CDN Delivery v1.36

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

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

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

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

Which CDN delivers a channel

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

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

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

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

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

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

Only delivery URLs are rewritten. The control API stays on the origin: a CDN in front of an authenticated API either caches responses it must not cache, or needs rules that are easy to get wrong. Point the CDN's origin at this server, and cache /hls/ only.

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

App API β€” Building Client Applications v1.31

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

1 Β· Get a key

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

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

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

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

2 Β· The channel list

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

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

      "thumbnail_url": "https://utopia.bozztv.com/api/app/channels/1/thumbnail.jpg?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
    }
  ]
}
FieldUse it for
stream_hlsWhat you hand to the player. Absent if the channel is SRT-only.
stream_srtOnly for listener-mode SRT channels, and only useful to SRT-capable clients. Remember SRT here is single-consumer β€” one client at a time. Consumer apps should use HLS.
liveWhether the channel is on air right now. Grey out or hide the tile when false.
progress_pctDraw a progress bar on the now-playing tile.
width/height/fpsOf the item currently airing β€” useful for choosing a player profile.

Scoping: a key sees its owner's channels; an admin's key sees all of them. 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_…

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

8 Β· Errors and etiquette

401Missing or invalid key β€” it may have been revoked. Do not retry in a loop.
403The key does not own that channel.
404Channel or thumbnail unavailable. The body explains which.
Polling10 s for the channel list, 30 s for EPG β€” matching the cache headers. Faster gains nothing and only adds load.
Security: the API is read-only β€” an app key cannot start channels, change schedules, upload media or read other tenants' data. Stream URLs themselves are unauthenticated (players need them that way), so treat channel availability as public and use the key to control who can enumerate and describe your channels.

M3U Playlists

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

The M3U feed at /m3u 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.

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

Admin Guide v1.5

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

User management

System monitoring & capacity

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

Account creation

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

Maintenance operations

Operational practices

System Utilisation & Capacity Planning v1.8

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

What each metric means

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

How the capacity estimate works

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

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

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

Tuning suggestions

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

Invite Codes & Signup Control v1.8

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

Creating a code

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

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

Requiring codes for signup

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

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

Lifecycle

Changing a user's limit later

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

HTTPS with Automatic Certificates v1.16

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

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

Requirements

First run

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

Security Hardening

v1.4 security behaviors

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

Production checklist

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:

SRT does not connect but HLS works

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

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

The Help tab (or another tab) is not visible

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

Channel stuck in "connecting"

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
PUT /api/playlist/item/{id}
Change one rundown entry β€” its position, ad markers or per-item settings. DELETE removes it. Scoped to the channel that owns it, so an item id from another account is refused rather than acted on.
πŸ” Requires auth

Encoding API

GET /api/encode/queue
Work waiting for a node, with position, estimated wait and whether an item was moved up because it is due on air soon. DELETE /api/encode/queue/{id} cancels one.
πŸ” Requires auth
GET /api/encode/nodes
Encoding nodes with their health, capacity and free disk. ?fresh=1 re-probes now rather than using the cached figure, which is what the Refresh button sends β€” the only time an operator expects to wait.
πŸ” Requires auth
POST /api/encode/start
Encode one media file. Body: file_id, an encode profile, and optionally channel_id β€” which is what lets the job find that channel's QR overlay.
πŸ” Requires auth
GET /api/encode/jobs/{id}
One encode's state and progress.
πŸ” Requires auth
GET /api/encode/notices
Completion notices for this account, kept twelve hours β€” so an encode that finished while nobody was watching is still reported.
πŸ” Requires auth
GET /api/encode/nodelist
Configured nodes. POST to add, DELETE /api/encode/nodelist/{id} to remove.
πŸ” Administrator only

Application Keys

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

Playback, As-Run and Metrics

GET /api/hls-proxy/token
A token scoped to the playback proxy alone, valid six hours. The account token is not placed in playlist URLs: those are handled to players, logged by caches and shared in support threads, and a token that grants only playback is worth far less if it escapes.
πŸ” Requires auth
GET /api/asrun
What actually played, and when β€” the record a broadcaster reconciles against. Filterable by channel and date range; quick ranges use the channel's local calendar dates rather than UTC, because a broadcast day is a local thing.
πŸ” Requires auth
GET /metrics
Prometheus format. Channel state, delivery, encoding capacity and queue depth, plus constant-rate padding and clock health for broadcast channels. playoutgo_channel_pcr_late_total above zero is a feed that will fail an operator's ingest test β€” the figure worth alerting on.
πŸ” Requires auth

Changelog

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

Shipped with PlayoutGo v2.42.0.

Fixed

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

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

Fixed

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

v2.41.0 β€” Three Features Nobody Could Reach

Fixed

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

gpunode 1.30.0 β€” The Node Documents Itself

Shipped with PlayoutGo v2.40.0.

New

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

v2.39.0 β€” Twenty-Eight Undocumented Endpoints

Documentation

routes documented   49 / 49
endpoints rendered  73
markup balanced     yes

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

Fixed

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

v2.37.0 β€” Writes Queue Instead of Colliding

Performance

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

What the work nearly introduced

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

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

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

Security

Verified both ways

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

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

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

Performance

Measured

20 operators Γ— 20 polls, 200 channels

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

gpunode 1.28.0 β€” The Source Cache Gets a Ceiling

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

Fixed

Cross-client deduplication, verified

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

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

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

New

Verified

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

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

Fixed

What else was checked

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

v2.31.0 β€” An Audit Trail That Works

Fixed

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

v2.31.0 β€” An Audit Trail

New

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

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

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

New

What the tests found

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

v2.29.0 β€” Automatic Database Snapshots

New

Why it is not a file copy

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

gpunode 1.26.0 β€” Console Hardening

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

Security

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

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

Shipped with PlayoutGo v2.27.0.

Fixed

New

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

New

Confirmed already correct

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

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

Fixed

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

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

Shipped with PlayoutGo v2.24.0.

Fixed

Confirmed working

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

Fixed

Regression protection

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

gpunode 1.23.0 β€” Cost, Wait and Callbacks

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

New

On the callback address

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

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

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

New

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

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

Fixed

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

v2.19.0 β€” Broadcast Health in Monitoring

New

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

v2.19.0 β€” Metrics

New

Hardened

v2.18.0 β€” The Last Finding

Fixed

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

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

Fixed

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

Fixed

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

Fixed

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

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

Shipped with PlayoutGo v2.14.0.

Fixed

v2.13.0 Β· gpunode 1.16.0 β€” Leaks and Limits

Fixed β€” gpunode

Fixed β€” PlayoutGo

The goroutine test asserts the count stays flat while four callers are blocked, then that a release genuinely wakes them β€” removing the periodic nudge would otherwise have been a silent hang rather than a visible failure.

v2.12.0 β€” Medium Findings: Origins, Passwords, Permissions, Honest Failures

Security

Fixed

v2.11.0 β€” Paged Listings

Fixed

This closes the last High finding from the audit. Twenty-three Medium findings remain.

v2.10.0 β€” The QR Workflow, Proven

Fixed

Verified end to end

week 1  set https://bozztv.com/promo/week1 β†’ re-encode β†’ 1 queued
        decoded from the encoded video: https://bozztv.com/promo/week1

week 2  change to https://bozztv.com/promo/week2 β†’ re-encode β†’ 1 queued
        decoded from the encoded video: https://bozztv.com/promo/week2

Read back with a real decoder from a frame of the finished file, not inspected by eye β€” the only test that distinguishes a code that works from one that merely looks right.

The queue was also observed doing its job in passing: with no encoding node registered it held the work and raised an alert rather than failing it, and when a node appeared after a restart it dispatched the restored jobs by itself.

v2.9.0 β€” The QR Workflow, End to End

New

The overlay is sent to the encoding node ahead of the source, because the node reads the parts in order and streams the source straight to disk β€” anything after it is never seen. If the overlay cannot be read the encode proceeds without it and says so: an encode without the code is better than no encode, and far better than a channel quietly losing its code with nothing said.

v2.8.0 Β· gpunode 1.15.0 β€” QR Codes Burned Into the Picture

New

On size, which is the whole question

Fixed while building it

v2.7.0 Β· gpunode 1.14.0 β€” Broadcast Settings in the Interface, and a Shared Pool That Explains Itself

New β€” PlayoutGo

New β€” gpunode

v2.7.0 β€” Clock Accuracy

The last of the three things standing between this and an uplink test, and the one I said I could not verify. It turned out to be verifiable after all β€” because on a constant-rate stream it is not a measurement.

Fixed

Regression protection

v2.6.0 β€” The Clock, and the Network Table

Closing the two gaps named in v2.5.0 as standing between this and an uplink test.

Fixed

New

Clock packets carry no payload, so their continuity counter deliberately does not advance β€” per ISO 13818-1, advancing it would make an analyser report a continuity error on a packet that is otherwise perfectly correct. There is a test for exactly that, because it is the kind of detail that passes review and fails an ingest.

v2.5.0 β€” DVB Service Information

Constant bitrate alone does not make a stream a broadcaster will carry. A transport stream with only PAT and PMT is valid MPEG and not valid DVB: a receiver has no service name to display, no provider, no idea what kind of service it is, and no clock β€” so it shows an unnamed entry or refuses the multiplex.

New

Tables are emitted only when a channel is configured for them. Claiming a service id and network nobody allocated would collide with real services in a receiver's list, which is worse than emitting none β€” so this is absent by default and correct for HTTP delivery.
On compliance. This makes the stream structurally correct against the published checks, and the tests verify each table field by field β€” identifiers, CRCs, lengths, running status, repetition intervals. It is not a substitute for validation with a real analyser against a particular operator's requirements: they each have their own, and no amount of care here can anticipate them. What this does is make that conversation start from a stream that is right rather than one that obviously is not.

gpunode 1.13.0 β€” Constant Bitrate Encoding, and Telemetry You Can Trust

Shipped with PlayoutGo v2.4.0.

Fixed

New

v2.3.0 β€” Constant Bitrate Output

New

How it behaves

v2.2.0 β€” App Keys Stop Competing With Playout

Fixed

Confirmed closed on review

v2.1.0 β€” Replacements, Races and Resume Offsets

Fixed

gpunode 1.12.0 β€” Probing Once, and an Honest Capacity Control

Shipped with PlayoutGo v2.0.2. This completes the gpunode findings.

Fixed

v2.0.1 β€” Security Headers

Security

Regression protection

v2.0.0 β€” Every Output Write Is Checked

Fixed

Version 2.0.0. Not a break in compatibility β€” configuration, database and the gpunode protocol are unchanged, and any 1.x installation upgrades in place. It marks the point at which the system runs unattended: work queues rather than failing, survives node and server restarts, reclaims its own disk, orders itself by what is needed soonest, and asks for a person only when it has reached a limit it cannot work around.

v1.99.0 β€” Fixed Ad Timestamps, Bounded Requests, Indexed Queries

Fixed

The index migration was written before the tables existed and the service refused to start β€” correctly, because of the fatal-migration change in v1.71.0. That is the behaviour working: a schema problem surfaced at boot rather than as a runtime failure hours later.

v1.98.0 β€” The Queue You Can Actually See

New

v1.97.0 β€” Urgent Work First, and Being Told When It Is Done

New

Verified

a file due on air in 30 minutes, queued after 30 bulk encodes,
was taken first

v1.96.0 β€” Estimates and Self-Maintenance

New

Only provably unused output is removed. Anything a media file still references is left alone regardless of age, and a directory that is not recognisably ours is never touched. A database error is not treated as proof a file is gone β€” only a definite "no such record" is, because deleting on a failed read would destroy live output.

v1.95.0 β€” Running Without Anyone Watching

The system should keep working on its own and speak up only when it has reached a limit it cannot work around. Three things stood in the way of that.

New

Fixed

v1.94.0 β€” Robust Encoding Across Nodes and Tenants

New

Verified

fair queue    one tenant with 20 queued, another with 2:
              six dispatches shared 4 / 2 rather than 6 / 0
CDN modes     inherit β†’ server default Β· override β†’ that CDN Β· origin β†’ no CDN

v1.93.0 β€” An Encode Queue That Survives Anything

New

v1.92.0 β€” Visible Failures and Bounded Parsing

New

Fixed

v1.91.0 β€” A Channel That Cannot Write Now Says So

Fixed

Regression protection

v1.90.0 β€” Segments That Always Start Where They Should

Fixed

Verified

live segments   every one begins with a keyframe
multi-track     first video track aired, extra track reported and ignored

v1.89.0 β€” Local Dates and a Preview That Starts

Fixed

Verified

preview  HTTP 206 Partial Content, Content-Range: bytes 0-1023/20418478
         (streaming, not a 20 MB download before the first frame)

v1.88.0 β€” Account Tokens Out of Playlist URLs

Security

Regression protection

v1.87.0 β€” Proxy Reachability and Connection Reuse

Security

Fixed

Confirmed already fixed on review: range and conditional headers are forwarded, and playlist bodies are size-capped β€” both landed in v1.76.0. The database pool was opened up earlier as well.

v1.87.0 β€” Concurrent Reads

Fixed

Verified

40 concurrent API reads    0 errors, 0 lock contention
8 readers + 1 writer       clean under the race detector
journal_mode               WAL on every connection

v1.86.0 β€” Containment, Concurrency and Atomic Claims

Fixed

Regression protection

v1.85.0 β€” Uploads That Tell the Truth

Fixed

The reserve is deliberately proportional. A flat figure gets both ends wrong: ten gigabytes refuses every upload on a volume already below it β€” including a one megabyte file when three gigabytes are free, which helps nobody β€” while on a multi-terabyte array it is far too little to matter.

gpunode 1.11.0 β€” Job Records Survive Restarts

Shipped with PlayoutGo v1.84.0.

Fixed

Verified

submit β†’ complete β†’ SIGTERM β†’ restart
  [jobs] 1 job record(s) restored from the previous run
  same id still answers: state=done, "3 rendition(s) in 11s"

gpunode 1.10.0 β€” Console Credentials and Late Fields

Shipped with PlayoutGo v1.83.0.

Security

Fixed

Verified

login              cookie is not the token, console 200 with it, 303 without
format after file  MP4 output produced, as asked

gpunode 1.9.0 β€” Descriptors and Data Races

Shipped with PlayoutGo v1.82.0.

Fixed

Regression protection

gpunode 1.8.0 β€” Crash, Cleanup and Honest Encryption

Shipped with PlayoutGo v1.81.0. Update your nodes.

Fixed

v1.80.0 β€” Upload Deadlines and Codec Honesty

Fixed

Verified

HEVC file  β†’ video refused, "HEVC (H.265) … encode it to H.264 first"
AC-3 file  β†’ audio replaced with silence, H.264 video unaffected
H.264/AAC  β†’ unchanged

gpunode 1.7.0 β€” Encodes Actually Run on the Chosen Card

Shipped with PlayoutGo v1.79.0. Update every node, particularly any with more than one GPU.

Fixed

Servers with two Quadro RTX 4000s were using one of them. After updating, both should carry work; nvidia-smi during a multi-job run will show it.

v1.78.0 β€” Per-Install Secrets and Real Revocation

Shipped with gpunode 1.6.0. Update your nodes.

Security

Verified

fresh install   jwt_secret 64 chars, not the default
password change old token β†’ 401, new token β†’ 200

v1.77.0 β€” Proxy Headers and URL Resolution

Fixed

Regression protection

v1.76.0 β€” Uploads and Proxy Correctness

Security

Fixed

v1.75.0 β€” Credentials Out of URLs

Security

Fixed

Regression protection

v1.74.0 β€” Storage, Ranges and Rundown Positions

Fixed

Regression protection

v1.73.0 β€” Protecting What Is On Air

Fixed

Regression protection

v1.72.0 β€” Rejecting Requests That Change Nothing

Fixed

v1.71.0 β€” Refusing to Start on a Broken Schema

Fixed

Regression protection

v1.70.0 β€” Signup and Deletion Made Whole

Fixed

Regression protection

v1.69.0 β€” Ordering, Tokens and Limiter Bounds

Fixed

Regression protection

v1.68.0 β€” Closing the Recovery Backdoor

Security

"allow_password_reset": true,
"recovery_secret": "a-long-random-value-kept-separately"
Existing installations lose nothing they were using deliberately: the endpoint simply becomes unavailable until configured. Ordinary sign-in and the authenticated change-password page are unaffected.

Already addressed, confirmed on review

Regression protection

v1.68.0 β€” Revocation That Works

Security

Existing keys keep working. A key created before this change is matched against its stored value and rewritten to the hashed form the first time it is used, so deployed apps carry on without being reissued β€” an upgrade that silently broke every app would be worse than the exposure it fixes. SHA-256 rather than a password hash: these are 192 bits of random data, so there is nothing to brute-force and a slow hash would only make every API request expensive.

Regression protection

v1.67.0 β€” Day and Week Grids

New

Regression protection

v1.66.0 β€” Cross-Tenant Isolation

Security

Regression protection

v1.65.0 β€” Dead Air Carries Picture and Sound

Fixed

A failure to generate the slate is reported and not fatal: gaps then fall back to tables and a clock without picture, which is worse but not a reason to refuse to start. It requires ffmpeg on the playout host.

Verified

before: ffprobe β€” End of file, zero streams
after:  h264,video
        aac,audio

Regression protection

v1.64.0 β€” Adaptive Delivery, For Real

New

Safeguards

Verified

720p-1   1280,720    6 segments
480p-1   854,480     6 segments
360p-1   640,360     6 segments
master   HTTP 200 with 3 EXT-X-STREAM-INF variants

v1.63.0 β€” The Version Was Lying

Fixed

If you have been unsure whether a deployment took effect, this is why. Check the startup line or the top of this page: both now show the real build.

v1.62.0 β€” Per-Channel Delivery Accounting

New

This is what THIS server sent, not what viewers received. With a CDN in front the origin sees only cache fill; the two differ by the CDN's hit ratio, and only CDN logs can close that gap. The figure is named for what it is rather than implied to be audience delivery.

Regression protection

v1.61.0 β€” Watching an Encode Actually Work

Fixed

v1.60.1 β€” Saying What ABR Mode Actually Delivers

Fixed

This is a labelling fix, not a behaviour change. Assembling and serving a multi-variant set is a feature PlayoutGo does not have; the renditions are frame-aligned so that something in front of it can.

v1.60.0 β€” Resumable Artifact Collection

Fixed

Regression protection

v1.59.0 β€” Edit Lists Honoured

Fixed

Regression protection

v1.58.0 β€” Encoding You Can Actually Follow

Fixed

Regression protection

v1.57.0 β€” A Channel Knows Its Own Timezone

Air times are stored as absolute instants, so playback was always correct. What was not consistent is which zone a day means β€” and three parts of the system disagreed.

Fixed

Nothing moves on upgrade. An unset timezone means the server's own, which is what every existing channel already used. Set it per channel as you go; a channel with none behaves exactly as before.

Regression protection

v1.56.0 β€” SCTE-35 Completed, and a Regression Caught

New

Fixed

Regression protection

v1.55.0 β€” Ad Breaks Signalled in the HLS Playlist

v1.53.0 fixed SCTE-35 generation and v1.54.0 scheduled the breaks, but markers went out only in-band in the transport stream. Most SSAI systems read the playlist rather than parsing SCTE-35 out of the stream, so a break was invisible to them β€” which for HLS delivery meant the feature was not yet usable.

New

Regression protection

v1.54.0 β€” Ad Breaks Scheduled from the Rundown

v1.53.0 made SCTE-35 generation conform to the standard. This places the markers: breaks are defined per rundown item and the engine emits them as the item plays.

New

Safeguards

Signalled on both paths as of v1.55.0: in-band in the transport stream for SRT and MPEG-TS consumers, and in the HLS playlist for SSAI systems that read tags rather than parsing the stream.

Regression protection

v1.53.0 β€” SCTE-35 Conforms to the Standard

Fixed

Regression protection

Still to come: markers are generated correctly but nothing yet schedules them from the rundown. Placing breaks per item is the next step.

v1.52.0 β€” Import Safety

An import that binds the wrong asset or moves an item to the wrong day is the worst kind of failure: the rundown looks correct and the wrong thing airs, with nothing to report it. Three ways that could happen.

Fixed

Regression protection

gpunode 1.5.0 β€” Graceful Shutdown and TLS Integrity

Shipped alongside PlayoutGo v1.51.3. Update your nodes.

Fixed

Regression protection

gpunode 1.4.0 β€” Credentials and Honest Capability

Shipped alongside PlayoutGo v1.51.2. Update your nodes.

Fixed

# Recommended, so the token never appears in ps:
echo -n 'your-long-random-token' > /etc/gpunode.token
chmod 600 /etc/gpunode.token
./gpunode -listen 0.0.0.0:9099 -token-file /etc/gpunode.token \
          -work /var/lib/gpunode -slots 3 -allow <your-playout-host>

Regression protection

gpunode 1.4.0 β€” Resumable Artifact Collection

Performance and reliability

gpunode 1.3.0 β€” Streaming Uploads and a Quieter Disk

Shipped alongside PlayoutGo v1.51.1. Update your nodes.

Fixed

Regression protection

gpunode 1.3.0 β€” One Decode Per Ladder

Performance

v1.51.0 β€” Overlapping Runs and Boundary Losses

Fixed

Regression protection

v1.50.0 β€” Startup Order, Feed Identity and Quieter Failures

Fixed

Regression protection

v1.49.0 β€” Request Limits, Redirects, Exports and SRT Bounds

Fixed

Regression protection

v1.48.0 β€” Re-import Correctness

Re-import is the recovery path after data loss, so getting ownership or identity wrong quietly corrupts a multi-tenant library β€” and does so at exactly the moment an operator is least able to check. Five related defects lived here together.

Fixed

Regression protection

v1.47.0 β€” Dead Air, Config Integrity and Interface Robustness

Fixed

Regression protection

v1.46.0 β€” Boot, Config and Login Hardening

Fixed

Regression protection

v1.45.0 β€” Closing the HLS Proxy

Security

The proxy's existing protections are unchanged and were re-verified: only http and https, no private, loopback or link-local addresses, and the resolved address is re-checked at connection time to close DNS rebinding.

Regression protection

v1.44.0 β€” Audit Fixes, First Pass

Fixed

gpunode 1.2.0

Regression protection

v1.43.1 β€” Migration That Reaches Existing Installations

Fixed

Regression protection

v1.43.0 β€” Refusing an Incompatible Node

Fixed

Regression protection

v1.42.2 β€” A Job That Stalls Says So

Fixed

Regression protection

v1.42.1 β€” One View of a Node

Fixed

Regression protection

v1.42.0 β€” CDN Per Account, and Visible in the Interface

Fixed

New

Diagnostics

Regression protection

v1.41.1 β€” Buttons That Did Nothing

Fixed

Regression protection

v1.41.0 β€” Watch Folders in the Interface

New

v1.40.0 β€” Delivery Settings in the Interface

New

v1.39.0 β€” Encode Per Channel

New

Fixed

Regression protection

v1.38.4 β€” Tolerating a Warming GPU Driver

Fixed

If a node is persistently slow to answer, enable persistence mode on it: nvidia-smi -pm 1. Without it the driver unloads whenever the GPU is idle and every status query pays to bring it back.

v1.38.3 β€” Readable Node Errors

Fixed

Regression protection

v1.38.2 β€” Panels Confined to Their Own Screens

Fixed

Regression protection

v1.38.1 β€” Interface Responsiveness

Fixed

Regression protection

v1.38.0 β€” Trim Points, Repeating Days, Watch Folders

New

Security

Regression protection

v1.37.0 β€” Session Handling & Interface Audit

Fixed

Regression protection

v1.36.0 β€” CDN Delivery & Encode Fallback

New

Fixed

Regression protection

v1.35.0 β€” Encoded Renditions Are Actually Played

Fixed

New

Regression protection

v1.34.0 β€” Performance

Improved

Regression protection

v1.33.0 β€” Encoding Fully Managed from the Interface

New

Security

Regression protection

v1.32.0 β€” Encoding Modes & gpunode Integration

New

Fixed during implementation

Regression protection

v1.31.0 β€” App API for Client Applications

New

Fixed during implementation

Documentation

Regression protection

v1.31.0 β€” App API for Client Applications

New

Fixed during development

Regression protection

v1.30.0 β€” Honest Capacity Projections

Fixed

Regression protection

v1.29.0 β€” Load Figures That Understand Stuck I/O

Fixed

New

Regression protection

v1.28.0 β€” Density Benchmark & Idle-Host Capacity Fix

Fixed

New

Regression protection

v1.27.1 β€” Accurate Overlap Wording

Fixed

Regression protection

v1.27.0 β€” Multi-Clip Bin-Packed Gap Filler

New

Documentation

Regression protection

v1.26.0 β€” Overlaps as Visible as Gaps

Changed

Verified

v1.25.0 β€” Re-probe Progress Reporting

New

Regression protection

v1.24.0 β€” Unknown Durations Made Visible

Fixed

Verified, not changed

v1.23.0 β€” Generation and Re-probe for Files Missing Durations

Fixed

Regression protection

v1.22.0 β€” Stream URLs Follow the Request Scheme

Fixed

Regression protection

v1.21.0 β€” Dangling Gap-Filler References

Fixed

Audited, no defect found

Regression protection

v1.20.0 β€” Full API Parity in the Interface

New interface controls for existing API features

Fixed

Regression protection

v1.19.0 β€” Playlist Generation & Schedule Import

New

Fixed

Regression protection

v1.18.0 β€” Data-Race and Input-Handling Fixes

Fixed

Audited, no defect found

Regression protection

v1.17.0 β€” Atomic Channel Allowances

Fixed

Audited, no change needed

Regression protection

v1.16.0 β€” HTTPS on 443 with Automatic Certificates

New

Fixed

Verified (already present, re-tested end to end)

Regression protection

v1.15.0 β€” Resource Cleanup & No More Silent Failures

Fixed

Regression protection

v1.14.0 β€” Honest System Metrics

Interface

Fixed

Regression protection

v1.13.0 β€” Correct System Figures

Fixed

Regression protection

v1.12.0 β€” Channel Health at a Glance

New interface

Fixed

Regression protection

v1.11.0 β€” Correct Disk Attribution, Honest Capacity & SRT Diagnostics

Fixed

Regression protection

v1.10.0 β€” As-Run Logs & Data-Integrity Fixes

New: as-run logging

Bugs found and fixed

Production-readiness audit

v1.9.0 β€” Automatic Restart Resume & Accurate Disk Reporting

New: channels survive restarts

Fixed

Interface

Regression protection

v1.8.0 β€” System Monitoring, Capacity Planning & Invite Codes

New: system utilisation & capacity

New: invite codes & signup control

Friendlier interface

Bugs found and fixed during this release's testing

Regression protection

v1.7.0 β€” Security Hardening & Extensive In-App Documentation

Security release. Multi-user installations should upgrade: two paths allowed one tenant to broadcast another tenant's media.

Security fixes

Functional fixes

New: extensive in-app documentation

Friendlier interface

Regression protection

v1.6.1 β€” Critical UI Repair

Upgrade immediately from v1.4.0–v1.6.0. Those builds shipped a broken Rundown and Stats page. If your Rundown showed an empty channel selector and an empty Add Files panel, this release fixes it β€” no data was lost or corrupted; only the browser UI was affected.

Fixed

New: regression protection

v1.6.0 β€” Gap Filler & Schedule-Engine Fix Release

New

Bugs found by live testing and fixed

v1.5.0 β€” In-App Documentation & Field-Validation Release

New: documentation lives in the interface

Fixes

Field validation performed for this release

v1.4.0 β€” Rundown, Safari-grade HLS & Field-Fix Release

Everything below was verified with automated Go tests, node syntax checks, live-server API smoke tests, and end-to-end ffprobe/ffmpeg stream validation.

New: Rundown scheduling UI

Streaming correctness

Data & API fixes

Security

v1.3.1 β€” Critical Streaming Fix Release

v1.3.0 β€” Robustness & Leak-Fix Release

v1.2.0 β€” Hardening & Polish Release

v1.1.0 β€” Security & Correctness Release

v1.0.0 β€” Initial Release