🎬 PlayoutGo Operations Manual v1.28.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

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

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

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)

Changelog

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