π¬ 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.
Overview
PlayoutGo is a multi-tenant broadcast playout server designed to run 24/7 live channels from a library of uploaded MP4 files. Each channel has an independent playlist, schedule, and output pipeline. The system outputs to:
- HLS β native HTTP Live Streaming, served at
/hls/{slug}/index.m3u8, viewable in any browser or media player. - SRT β Secure Reliable Transport for low-latency contribution to broadcast encoders, Nimble Streamer, Flussonic, Wowza, etc.
- Both β simultaneous HLS monitoring preview and SRT contribution output.
HLS is always active when a channel is running, even in SRT-only mode. This lets operators monitor playout in a browser without interrupting the SRT feed.
Architecture
βββ main.go # HTTP server, router, graceful shutdown
βββ config.go # JSON config loader, 0600 file permissions
βββ database.go # SQLite schema, CRUD, playlist/schedule logic
βββ models.go # Data structs: User, Channel, MediaFile, etc.
βββ auth.go # JWT generation/validation, bcrypt, middleware
βββ handlers.go # REST API handlers
βββ playout.go # PlayoutEngine, PlayoutManager, Scheduler
βββ mp4reader.go # Pure-Go MP4/ISO-BMFF demuxer (H.264+AAC)
βββ tsmuxer.go # MPEG-TS muxer: PAT/PMT/PES, continuity counters
βββ hlswriter.go # HLS segmenter: keyframe-aligned, rolling window
βββ hlsproxy.go # CORS proxy for external HLS feeds + SSRF guard
βββ srtcaller.go # SRT caller + listener via datarhei/gosrt
βββ epg.go # XMLTV EPG generation, M3U playlist
βββ docs.go # This documentation page + viewer HTML
βββ static/ # Embedded SPA (index.html, app.js, style.css)
Data flow
When a channel starts: the PlayoutEngine reads MP4 samples via mp4reader.go, wraps them in MPEG-TS packets via tsmuxer.go, paces output to wall-clock time, and simultaneously feeds both the HLSSegmenter (for browser playback) and the SRTCaller (for downstream ingest). SRT reconnection happens in a background goroutine so a dropped SRT client never interrupts HLS output.
Quick Start
Build and run
cd src go build -o playout . ./playout
Opens on http://localhost:8700. Default admin: admin@playout.local / admin!123 β change immediately.
Log in and upload media
Use the Web UI at /. Upload MP4 files (H.264 video + AAC audio). The system probes each file on upload β duration, resolution, codec, and bitrate are recorded automatically.
Create a channel
Click New Channel. Set the name, choose output mode (hls, srt, or both), configure SRT parameters if needed, then save.
Build a playlist and start
Add media files to the channel playlist in the desired order. Click Start Channel. The HLS stream is immediately available at /hls/{channel-name}-{id}/index.m3u8.
Monitor
Use the Viewer at /viewer to watch any channel in the browser. The dashboard shows live bytes/packets, RTT (SRT), current file, and error history.
UI Tour & In-App Help v1.5
The entire documentation you are reading lives inside the application: open the π Help tab in the top navigation β it opens with a one-click launcher bar for the tutorials, recipes, comparison, FAQ, glossary and testing guide. Every page also has a small ? button next to its title that jumps straight to the relevant manual section β Channels β channel management, Rundown β the scheduling manual, Media β upload & probing, and so on. The β button in the header opens this manual in its own browser tab if you prefer a second window while operating.
Five-minute tour for a new operator
- πΊ Channels β create a channel, choose output (HLS, SRT, or both), press βΆ to start, βΉ to stop. The card shows live status, the copy-ready player URLs, and shortcuts to the rundown and stats.
- π Media β drag-drop MP4s (H.264 + AAC). Files probe automatically: watch processing β ready and the real resolution/fps/duration appear. A file marked error is not playable β re-encode it.
- π Rundown β the heart of the system: order content, pin air times with π anchors, watch gaps/overruns, and read the NOW line. Full manual in the Rundown section.
- βΆ HLS Player β quick in-browser preview of any HLS URL (served through the built-in CORS proxy).
- π‘ EPG β copy XMLTV and M3U links for Plex/Jellyfin/IPTV apps (your token is embedded automatically).
- π Stats β live throughput, viewer counts, play history, and the channel error log.
- π‘ Admin (admins only) β user and system management; see the Admin Guide.
Configuration (config.json)
Config is loaded from config.json next to the binary. If missing, it is created with defaults. File permissions are 0600 (owner-only) to protect secrets.
{
"port": 8700,
"admin_email": "admin@playout.local",
"admin_password": "CHANGE_THIS",
"jwt_secret": "CHANGE_THIS_TO_A_LONG_RANDOM_STRING",
"database_path": "playout.db",
"uploads_dir": "uploads",
"hls_dir": "/dev/shm/playout_hls",
"max_upload_mb": 10240,
"hls_proxy_allowed_hosts": []
}
| Key | Default | Description |
|---|---|---|
| port | 8700 | HTTP listen port |
| admin_email | admin@playout.local | Initial admin account email (used only on first run) |
| admin_password | admin!123 | Initial admin password β also the emergency reset secret |
| jwt_secret | (weak default) | HMAC-SHA256 signing key for JWTs β must be changed in production |
| database_path | playout.db | SQLite database file path |
| uploads_dir | uploads | Base directory for uploaded media files |
| hls_dir | /dev/shm/playout_hls | Where HLS segments are written. tmpfs (/dev/shm) is strongly recommended for performance and to reduce SSD wear |
| max_upload_mb | 10240 | Maximum single-file upload size in MB |
| hls_proxy_allowed_hosts | [] | Optional allowlist for the HLS CORS proxy. Empty = any public host allowed (private IPs always blocked) |
POST /api/auth/reset with the config secret for emergency recovery.
Tutorial 1 Β· Your First Channel 10 min
Goal: take a fresh install to a channel playing in a browser. No prior playout experience assumed.
- Log in with the admin credentials from
config.json(defaultadmin@playout.local). Change the password from the π button in the header. - Upload media. Go to π Media and drag in two or three MP4s. They must be H.264 video + AAC audio (video-only is fine β PlayoutGo adds a silent track automatically). Watch the status go processing β ready; the real resolution, fps and duration appear when probing finishes.
If a file lands on error, it isn't H.264/AAC or the container is damaged. Re-encode:
ffmpeg -i input.mkv -c:v libx264 -preset veryfast -g 48 -c:a aac -ac 2 -ar 48000 output.mp4 - Create a channel. πΊ Channels β + New Channel. Name it
demo, set Output mode to HLS (simplest β plays in any browser), leave the rest at defaults. Save. - Fill the rundown. Open π Rundown, pick
demoin the channel selector, and click files in the right-hand Add Files panel to append them. - Start it. Back on Channels, press βΆ Start. Within ~10 seconds the card shows β LIVE.
- Watch it. Click the βΆ button on the card (opens the built-in player), or copy the HLS URL into VLC or Safari.
What just happened: the engine opened your first file, remuxed it to MPEG-TS in real time, and wrote 4-second keyframe-aligned segments to /dev/shm/playout_hls/demo-1/, updating index.m3u8 as it went. When the file ended it moved to the next item with a discontinuity marker and continuous timestamps β no re-encoding anywhere, which is why one modest server can run many channels.
Tutorial 2 Β· Build a Broadcast Day 15 min
Goal: understand anchors, flow, gaps and overruns β the heart of the Rundown.
Step 1 β flow everything from a start time
Add six items. In Schedule day from pick today at 18:00 and press β© Set & flow. Item 1 becomes π anchored at 18:00; everything else shows a grey computed time that simply follows the previous item's end. Drag any row β every grey time recalculates instantly, the anchor never moves.
Step 2 β pin a hard start
Say item 4 is a news bulletin that must hit 19:00 exactly. Click its π to anchor it, then edit the yellow time to 19:00. Now one of two things appears:
- β GAP β 00:14:00 β your first three items end at 18:46, so 14 minutes are unaccounted for. Options: add content, move the anchor earlier, or assign a gap filler so a slate loops instead of dead air.
- β OVERLAP (red row) β content before an anchor runs past its air time, so the anchored item starts late by that amount. PlayoutGo never truncates a file: the item already playing always runs to completion. The delay does not accumulate β the next anchor is an absolute wall-clock time, so if there is slack before it the engine waits there and the schedule self-corrects. Shown as a full-width row, like a gap, because a late programme is at least as serious as dead air.
- +00:07 over (red, on the anchor) β your content runs past 19:00. The bulletin will start late. Options: remove or shorten an item, or move the anchor.
This is the entire job of a traffic scheduler: make the gaps and overruns visible before air, not while viewers are watching.
Step 3 β repeat a block
Tick the checkboxes for items 1β3 (shift-click selects a range), then press β§ Duplicate in the blue bulk bar. Floating copies land at the end and immediately flow after the last item.
Step 4 β safety net
Press β© Undo. Every schedule and order change is snapshotted (10 levels). Try to drag or delete the row marked βΆ playing β you'll be asked to confirm, because that item is on air right now.
Step 5 β survive a restart
Stop and start the channel mid-programme. It resumes inside the correct item at the correct offset: the engine walks anchors, chains floating durations after them, and compares against the wall clock. A 25-minute-old anchor followed by two 10-minute items resumes 5 minutes into the third.
Tutorial 3 Β· A 24/7 FAST Channel 20 min
Goal: a channel that runs unattended forever, in the shape a FAST/OTT operator actually needs.
- Create the channel with Output mode = Both (HLS for viewers, SRT for a downstream CDN or transcoder) and Loop playlist ticked.
- Load a deep rundown β several hours of content. With looping on, when the last item finishes the whole playlist resets to pending and starts again.
- Assign a gap filler (channel settings β Gap filler content): a 10β30 s branded slate. Anything that would have been dead air becomes on-brand.
- Decide on anchors. For pure looping, use no anchors β content just cycles. For appointment programming, anchor only the few must-hit slots and let the rest flow.
Anchors and looping interact: an anchor is a one-shot wall-clock event. When a looping playlist resets, anchors already in the past fire immediately in flow order rather than waiting for tomorrow. For a repeating daily grid, re-anchor per day (or leave the day unanchored and let it cycle).
- Publish. From π‘ EPG copy the token-scoped M3U into your IPTV app, and the XMLTV URL into Plex/Jellyfin/Emby for a programme guide.
- Monitor. π Stats shows live throughput and the error log. Run the server under systemd so it restarts on reboot:
[Unit] Description=PlayoutGo After=network.target [Service] WorkingDirectory=/opt/playout ExecStart=/opt/playout/playout Restart=always RestartSec=5 [Install] WantedBy=multi-user.target
Tutorial 4 Β· Multi-Tenant Setup 10 min
Goal: give several people (clients, colleagues, departments) their own isolated channels on one server.
- As admin, open π‘ Admin and create a user with an email, a password of at least 8 characters, and role
user. - They log in and see only their own channels, media, rundowns, EPG feeds and stats. Admins see everything.
- Isolation is enforced server-side, not merely hidden in the UI: a user cannot read another tenant's files, add another tenant's media to their playlist, or point their gap filler at it β all return 403/400. (Both playlist and filler paths were hardened in v1.7.0; see the changelog.)
- Give each tenant a distinct SRT port, or share a port and give each channel a distinct stream ID β the listener routes by exact stream-ID match.
- Each tenant's
/m3u?token=β¦and/epg/my?token=β¦return only their channels, so the links are safe to hand out individually.
Tutorial 5 Β· Diagnose a Dead Stream 10 min
Goal: a repeatable path from "nothing is playing" to a root cause. Work top to bottom β each step rules out a layer.
Layer 1 β is the engine running?
βΊ Channels tab: does the card show β LIVE? βΊ Server log: look for "Playing: filename" β Status "error" β open Stats and read the channel error log β Nothing playing β the rundown may be empty, or every item failed to parse
Layer 2 β is content being produced?
βΊ ls -la /dev/shm/playout_hls/<slug>-<id>/ β .ts files with timestamps advancing every few seconds = engine is fine β No files / stale files = the engine is stuck; check the log
Layer 3 β is it valid?
βΊ curl -s http://SERVER:8700/hls/<slug>-<id>/index.m3u8 β #EXTM3U with #EXTINF lines and segment names βΊ Download one segment and probe it: curl -s .../seg00000042.ts -o /tmp/s.ts ffprobe -v error -show_entries stream=codec_name,channels,sample_rate -of csv=p=0 /tmp/s.ts β h264 + aac,48000,2 β "aac, 0 channels" β source has no audio and silent-track synthesis failed (pre-v1.4 bug) ffmpeg -v error -i /tmp/s.ts -f null - β silent output = decodes cleanly β "non-existing PPS" = segment starts mid-GOP (pre-v1.6 bug)
Layer 4 β the player
βΊ Works in VLC but not Safari? Safari is far stricter: it needs every segment to start on a keyframe and a consistent audio layout. Test in a private window (Safari caches HLS aggressively) or with a cache-buster: index.m3u8?v=2 βΊ Works in the browser but not VLC over SRT? SRT is single-consumer β disconnect the other client first, and use the exact URL form below.
Layer 5 β SRT specifics
βΊ ffprobe -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=YOUR_ID&latency=200000"
β ERROR:PEER β stream ID doesn't match. It must equal the channel's Stream ID
exactly. The URL parameter is streamid= (NOT srt_streamid=),
and latency is in MICROseconds (200 ms = 200000).
β Connection refused β the channel isn't started, or output mode excludes SRT
β Bad secret β passphrase mismatch (10β79 chars)
Recipes & Examples
Encode source files for reliable playout
# Standard 1080p conform β closed GOP every 2 s, AAC stereo 48 kHz
ffmpeg -i input.mov -c:v libx264 -preset medium -crf 20 -g 48 -keyint_min 48 \
-sc_threshold 0 -pix_fmt yuv420p -c:a aac -b:a 128k -ac 2 -ar 48000 \
-movflags +faststart output.mp4
-g 48 with -sc_threshold 0 gives predictable 2-second keyframes, which lets the segmenter cut cleanly at its 4-second target.
Make a branded slate for gap filler
# 15-second slate from a still image, silent stereo audio track
ffmpeg -loop 1 -i slate.png -f lavfi -i anullsrc=r=48000:cl=stereo -t 15 \
-c:v libx264 -g 48 -pix_fmt yuv420p -c:a aac -ac 2 -shortest slate.mp4
Restream a channel to YouTube/Facebook with ffmpeg
# PlayoutGo has no RTMP output yet; relay its HLS or SRT with ffmpeg
ffmpeg -re -i "http://SERVER:8700/hls/mychannel-1/index.m3u8" \
-c copy -f flv "rtmp://a.rtmp.youtube.com/live2/YOUR-KEY"
Pull a channel into another server over SRT
ffmpeg -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=ch1&latency=200000" \
-c copy -f mpegts "srt://DEST:9000?mode=caller&streamid=relay1"
Schedule a day from a script
TOKEN=$(curl -s -X POST http://SERVER:8700/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"..."}' | jq -r .token)
# anchor item 101 at 18:00 UTC, let the rest flow
curl -s -X POST http://SERVER:8700/api/playlist/1/schedule_bulk \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"items":[{"id":101,"scheduled_at":"2026-08-01T18:00:00Z"},
{"id":102,"scheduled_at":""},
{"id":103,"scheduled_at":""}]}'
Back up and restore
# Everything lives in the database plus the uploads tree tar czf playout-backup-$(date +%F).tar.gz playout.db uploads/ # HLS segments are ephemeral β never need backing up
How PlayoutGo Compares
An honest positioning guide, so you can tell whether this is the right tool for a given job.
| System | Shape | Where PlayoutGo differs |
|---|---|---|
| ffmpeg + concat/cron | DIY scripts | The common starting point. PlayoutGo adds a real schedule model (anchors, gaps, overruns), a rundown UI, per-tenant isolation, EPG output and stats β and avoids the classic concat pitfalls (timestamp discontinuities, restart-from-zero, no mid-item resume). |
| OBS Studio | Live production switcher | OBS is for a human operating a live show, and it re-encodes. PlayoutGo is unattended file playout with no encode step, so it scales to many channels per box β but it has no scenes, overlays or camera switching. |
| CasparCG | Broadcast graphics/playout | CasparCG excels at SDI output and rich CG/overlay layers for on-air graphics. PlayoutGo is IP-only (HLS/SRT), far simpler to run, and includes its own scheduler; Caspar normally needs an external automation system driving it. |
| Flussonic / Nimble | Streaming servers | Those are delivery/transcode platforms with some playlist features. PlayoutGo is the scheduling source that feeds them: point its SRT output at your streaming server and let that handle ABR, DRM and CDN edge. |
| Commercial playout (WideOrbit, Amagi, Vesetβ¦) | Full traffic + playout suites | They bring traffic/billing integration, ad sales workflows, compliance logging, redundancy and support contracts. PlayoutGo covers the scheduling-and-playout core as a single self-hosted binary with no per-channel licence. |
| Cloud FAST platforms | Managed SaaS | Managed platforms handle scale and monetisation for you, at recurring per-channel cost, with your content in their cloud. PlayoutGo runs on your own hardware and keeps content and schedule in your control. |
Choose PlayoutGo when
- You want many unattended file-based channels per server, without paying to re-encode content that is already H.264/AAC.
- You need a genuine schedule (hard starts, gap/overrun visibility, mid-item restart resume) rather than a shuffle loop.
- You want a single static binary, no runtime dependencies, and your content on your own disks.
- You're feeding a downstream CDN/transcoder over SRT, or serving HLS directly.
Choose something else when
- You need live camera/studio switching or on-air graphics overlays β OBS, vMix, CasparCG.
- You need an ABR ladder, DRM or SDI output β a transcoding platform downstream (PlayoutGo outputs a single rendition).
- You need ad sales, traffic/billing integration or contractual support β a commercial playout suite.
- You need frame-accurate SDI compliance for a licensed broadcaster β dedicated broadcast hardware.
Frequently Asked Questions
Does PlayoutGo re-encode my files?
No. It remuxes H.264/AAC into MPEG-TS in real time. CPU use per channel is small, which is why a modest server runs many channels β but it also means all files should already be H.264/AAC, and mixed resolutions/frame rates are passed through as-is with discontinuity markers rather than conformed.
How many channels can one server run?
Because there's no encoding, the practical limits are disk I/O and network throughput rather than CPU. Measure on your own hardware with your own bitrates before committing to a number β the honest answer is that it depends on your content and storage far more than on PlayoutGo.
Why does my SRT client get rejected?
Almost always the stream ID. It must match the channel's Stream ID exactly; the URL parameter is streamid= (not srt_streamid=) and latency is in microseconds. Also remember SRT here is single-consumer: one client at a time per channel.
Why does VLC play my stream but Safari won't?
Safari enforces the HLS spec strictly: every segment must start on a keyframe and the audio layout must stay consistent. VLC tolerates violations of both. If Safari refuses a stream that VLC plays, probe an individual segment (Tutorial 5, Layer 3) β and remember to test in a private window, because Safari caches playlists aggressively.
What happens if the server restarts mid-programme?
On start the engine recomputes the on-air position from your anchors plus the durations of floating items, and seeks into the correct item at the correct offset. A channel with a schedule rejoins where it should be, not at the top of the playlist.
Can I use video files with no audio?
Yes. Video-only files automatically get a synthesized silent 48 kHz stereo AAC track, so a mixed playlist presents one stable audio layout β required for Safari and tvOS.
Where are the HLS segments stored, and do I need to back them up?
In hls_dir (default /dev/shm/playout_hls, a tmpfs). They're ephemeral and regenerate on start β back up only playout.db and uploads/.
Is it safe to expose this to the internet?
Put it behind a reverse proxy with TLS (there's no built-in HTTPS yet), change the default admin password, and keep the JWT secret out of version control. API access is token-authenticated and per-user isolated; the HLS endpoints are intentionally public so players can reach them.
Can two channels share one SRT port?
Yes β they share a listener and are routed by stream ID, provided their passphrase, latency, buffer and max-bandwidth settings match. Mismatched settings on the same port are rejected with a clear error.
Glossary
| Anchor | A rundown item pinned to an exact wall-clock air time (π). The engine waits β playing filler or padding β to hit it precisely. |
| Floating item | An item with no stored time (π); it airs immediately after the previous one and its displayed time recomputes whenever the rundown changes. |
| Gap / underrun | Unfilled time before an anchor. Covered by gap filler if configured, otherwise null packets (dead air). |
| Overrun | Content running past an anchor's start time, so the anchored item begins late. |
| GOP | Group of Pictures β the span from one keyframe to the next. Segment boundaries and clean stream joins can only happen on keyframes. |
| Keyframe / IDR | A frame decodable on its own, carrying SPS/PPS headers. A stream that starts anywhere else produces "non-existing PPS" errors. |
| Discontinuity | An HLS tag telling the player that timing/encoding parameters change at that point β emitted at every file transition. |
| Null packets | Padding TS packets that keep a stream alive with no content β the dead-air fallback when no gap filler is set. |
| Stream ID | The SRT identifier a caller sends to select a channel. Must match exactly; empty or wrong IDs are rejected. |
| Caller / Listener | SRT roles. Listener = PlayoutGo waits for clients to connect (pull). Caller = PlayoutGo dials out to your ingest (push). |
| FAST | Free Ad-supported Streaming TV β linear channels delivered over IP, the typical use case for this system. |
| As-run | The record of what actually aired and when (visible under Stats as play history). |
Channels
A Channel represents one live broadcast stream. Each channel has:
- An independent playlist (ordered queue of media files)
- Optional scheduled_at times on individual playlist items
- An output mode:
hls,srt, orboth - SRT connection parameters (used when output mode includes SRT)
- A loop_playlist flag β when true the playlist auto-resets when all items are played
Channel statuses
| Status | Meaning |
|---|---|
| stopped | Channel is idle, no output being generated |
| connecting | Channel has been started; waiting for SRT connection or first frame |
| running | Actively playing a media file and producing output |
| error | Stopped due to an error (all playlist items failed, or unrecoverable error) |
HLS URL format
Each channel gets a directory named {slugified-name}-{id}. For example, channel GB1 with ID 3 β /hls/gb1-3/index.m3u8. Legacy ch{id} format is also recognised for backward compatibility.
Media Files
The system accepts MP4 / M4V files with:
- Video: H.264 (AVC) β HEVC/H.265, VP9, AV1 are not supported
- Audio: AAC-LC β other audio codecs (MP3, Opus, AC-3) are not supported
After upload, the server probes the file asynchronously and stores: duration, width, height, FPS, bitrate, video/audio codec, whether closed captions are present, and whether SCTE-35 markers exist.
status: error and the playlist engine marks it failed after one attempt. A message is logged and stored in the channel error history.The Rundown β Playlist & Scheduling v1.4
As of v1.4.0 the separate Playlist and Schedule tabs are merged into a single Rundown β one ordered list per channel showing what plays and when, side by side with the media library for quick adds. This section is the operator manual for it.
Core concept: anchors and floating items
Every rundown item is in one of two states, toggled with the padlock in the β± column:
- π Anchored β the item has an exact air time (
scheduled_atis set). The engine pads with null packets until that wall-clock moment and starts precisely on it. Anchored times display in bold yellow and are edited inline. - π Floating β no stored time. The item airs immediately after the previous one ends; its computed start is shown in grey. Reordering, adding, or removing items reflows all floating times instantly. Anchors never move.
This mirrors a broadcast traffic log: a few hard starts ("news at 18:00"), with everything between flowing by duration. Items placed before the first anchor have no defined air time and simply play in loop order.
Gap and overrun detection
- β unknown duration β a file whose length was never recorded is treated as zero-length, so every Start and Ends value after it, and any gap or overrun warning, becomes unreliable. The rundown flags these items and shows a banner; recover the metadata with Admin β Re-probe Broken Files.
- β GAP (amber row) β content before an anchor ends early. If the channel has a gap filler configured, the row names the clip that will loop there; otherwise it warns of dead air. Fix by assigning a filler, adding content, or moving the anchor earlier.
- +MM:SS over (red badge on an anchor) β content before the anchor runs past its start time. The engine will begin the anchored item late. Fix by removing/trimming content or moving the anchor later.
Orientation aids
- NOW line β a red rule at the current wall-clock position with a live countdown to the next item; the view auto-scrolls to it when the rundown loads.
- Day separators β automatic date headers when the schedule crosses midnight.
- Footer totals β item count, total content duration, anchor count, and the covered time span.
- Timezone label β the header shows which timezone all displayed times use (the browser timezone; stored values are UTC RFC-3339).
Operator tutorial: building a broadcast day
- Open Rundown, select the channel.
- Add content from the right-hand panel (search, click to append).
- Drag rows to order the day. All times reflow as you drag.
- Set the day start: pick a time in Schedule day from and press β© Set & flow. This anchors item 1 and lets everything else float.
- Pin the hard starts: click π on any item that must air at an exact time, then adjust its yellow time inline. Watch for GAP/over indicators and correct them.
- Start the channel. On restart or crash the engine recomputes the correct item and offset inside it from the anchors + flow, so the channel rejoins the schedule mid-item.
Multi-select, bulk edits, undo
- Checkbox column with shift-click ranges; a bulk bar appears with Move Top / Move Bottom / β§ Duplicate / Clear times / Remove. Duplicate appends floating copies of the selection at the end β the quick way to repeat a block later in the day.
- β© Undo (up to 10 levels) reverts schedule and order mutations β cascades, clears, reorders, bulk moves. The stack resets when you switch channels.
- All bulk schedule writes go through one atomic API call β a failure changes nothing (no half-written rundowns).
On-air safety
Removing, moving, or re-scheduling the item that is currently playing prompts for confirmation. The playing row is highlighted green with a βΆ marker.
Schedule engine semantics
The engine walks the rundown in position order. If the next pending item is anchored in the future, it pads with null packets and waits β it never skips ahead. A background scheduler checks every 10 seconds and auto-starts stopped channels when an anchored item comes up within 15 seconds.
Flow-aware restart (v1.4): on start/restart the engine computes the on-air position from anchors and the durations of floating items chained after them, then seeks into the correct item at the correct offset. Example: an anchor 25 minutes ago followed by two floating 10-minute items resumes 5 minutes into the third item.
Gap Filler β Covering Dead Air v1.27
A linear channel must always be transmitting something. When your rundown does not reach the next anchored item, PlayoutGo covers the hole with content you choose instead of black.
What happens during a gap, precisely
- The stream never stops. Even with no filler configured, the engine keeps transmitting null packets: the TS/HLS output stays alive, segments keep being written, SRT clients stay connected and the channel stays running. Viewers see black β dead air, not a dropped stream.
- With a filler configured, the engine fits your clips into the hole and plays them, null-padding only whatever is left over.
- The anchor still starts on its exact second. Filler is only ever scheduled when a whole clip fits before the deadline, with a 300 ms safety margin, so covering a gap can never delay the programme that follows.
Bin-packing: why a pool beats a single clip
Configure several clips and PlayoutGo fits the best combination into each gap rather than looping one slate. The difference is large:
| Gap | Single 2:00 slate | Pool of 2:00 + 1:00 + 0:20 + 0:10 |
|---|---|---|
| 3:20 | 2:00 played, 1:20 of black | 2:00 + 1:00 + 0:20 β nothing left |
| 0:50 | 0:50 of black (clip doesn't fit) | 0:20 + 0:20 + 0:10 β nothing left |
The algorithm is greedy longest-fit: it repeatedly takes the longest clip that still fits in the time remaining. It is fast, predictable, and easy to read back from the log β which matters more for an operator than squeezing out a final second.
It also avoids playing the same clip twice in a row whenever an alternative fits. If only one clip fits the remaining time, it repeats rather than leaving black β repetition beats dead air.
Verified in production conditions: a 29 second gap with a pool of 8 s, 4 s and 2 s clips was covered as 8+8+4+4+2+2 = 28 s with 1 s null-padded, and the anchored item still started on its exact second.
Configuring it
- Upload your filler clips like any other media (short promos, idents, sponsor tags, a branded slate).
- Open the channel's settings and select them under Gap filler content β β-click or Ctrl-click for several.
- Save. The change takes effect at the next gap; no channel restart needed.
Choosing good filler
- Mix the lengths. A pool of 2:00 / 1:00 / 0:30 / 0:10 packs almost any hole exactly. Four clips of 2:00 pack no better than one.
- Include something short (10β20 s). The remainder that gets null-padded can never be smaller than your shortest clip.
- Match your channel's format β same resolution and frame rate as your programming, with real audio (silent is fine, but the track must exist; PlayoutGo adds a silent one automatically if it doesn't).
- Keep it evergreen. Filler plays at unpredictable times, so avoid anything with a date or "coming up next" reference.
How this compares with professional playout systems
An honest positioning of what PlayoutGo does and does not do here:
| Technique | PlayoutGo |
|---|---|
| Filler/interstitial pool β fit promos, idents and PSAs into the hole | β Yes, with bin-packing across a multi-clip pool |
| Slate fallback β guarantee something legal always goes out | β Yes β include a slate in the pool; null packets are the last resort |
| Gap visibility before air | β Yes β gaps and overlaps are shown in the Rundown with sizes |
| Elastic items β mark a programme as loopable so it absorbs slack | β Not yet |
| Under/over compensation β trim earlier item tails so the anchor lands on time | β Not yet β an item always plays to completion, so an overlap makes the anchor start late |
| Operator alarms on large gaps | β οΈ Partial β gaps appear in the Rundown and the channel error log, but nothing pushes a notification |
Troubleshooting
Gap shows "dead air" in the Rundown
β no filler configured for that channel; select clips in channel settings
Filler configured but black still airs
β the gap is shorter than your shortest clip (check the log line, which
reports exactly what was played and how much was padded)
β or every clip is unusable: the channel error log names them
Log line to look for:
[channel] Filler: covering 29s with promo8.mp4 Γ2 + ident4.mp4 Γ2 + tag2.mp4 Γ2 (1s null-padded)
Generating & Importing Schedules v1.19
Two ways to fill a rundown without clicking each file: generate one randomly from your library, or import a schedule produced by another system.
π² Generate β randomised rundown
In the Rundown toolbar press π² Generate and give either a count ("20") or a duration ("3h", "90m"). PlayoutGo fills the rundown from the channel owner's media library.
- Only ready files are used. Anything still probing, or marked
error, is skipped β scheduling those produces dead air at transmission time. The result tells you how many were skipped and why. - No back-to-back repeats. When more items are requested than you have files, the library is reshuffled for each pass rather than repeating one fixed order, and the seam between passes is checked so the same file never plays twice in a row.
- Duration fill adds items until the requested length is reached, so the last item may overshoot slightly β the result reports the true total.
- Reproducible when you supply a
seedvia the API: the same seed and library always produce the same order, which is useful for testing.
The dialog exposes every option below: fill by count or by hours, replace or append, the no-repeat guarantee, and a seed. The same options are available through the API:
POST /api/playlist/{channelID}/generate
{
"count": 20, // or "duration_minutes": 180
"replace": true, // false appends to the existing rundown
"no_repeat": true, // never place the same file twice in a row
"seed": 12345 // optional: reproducible ordering
}
β¬ Import β CSV or XML
Press β¬ Import and choose a file. The format is detected from the content, and you are asked whether to replace the rundown or append to it. Entries whose media cannot be found are reported individually rather than failing the whole import.
Matching: a schedule entry is matched against your uploaded filenames β exact first, then case-insensitively, then ignoring the extension, then as a substring. So news.mp4, News.MP4 and news all find the same asset.
Air times are optional. An entry with a start time becomes an π anchored item; entries without one simply flow after the previous item (see the Rundown manual).
CSV
A header row is recommended. The media column may be named file, filename, media, asset, title, clip, name, material or content; the time column start, start_time, start_datetime, air_time, scheduled_at, time or on_air. Case, spaces, hyphens and underscores are ignored.
file,start news.mp4,2026-08-01T18:00:00Z promo.mp4, weather.mp4,2026-08-01T18:30:00Z
Without a header, the first column is the media reference and an optional second column the start time. Blank lines and lines beginning with # are ignored.
Accepted time formats: RFC-3339 (2026-08-01T18:00:00Z), 2026-08-01 18:00:00, 2026-08-01 18:00, 2026-08-01, DD/MM/YYYY HH:MM:SS, and a bare 18:00 meaning today at that time.
XML β three dialects accepted
The importer detects the dialect from the document's contents rather than its namespace, so exports that omit or alter namespaces still import.
1 Β· PlayoutGo native β the simplest option if you are generating the file yourself:
<?xml version="1.0"?> <playlist> <item file="news.mp4" start="2026-08-01T18:00:00Z"/> <item file="promo.mp4"/> </playlist>
<entry>, <event> and <clip> are accepted as synonyms for <item>, and the media reference may be an attribute (file, filename, src, media, asset, name, title) or a child element of the same name.
2 Β· SMPTE ST 2021 (BXF) β the broadcast industry standard for schedule interchange, used by traffic and automation systems such as WideOrbit, Imagine, Pebble and Amagi. PlayoutGo reads a subset: each <ScheduledEvent> contributes one rundown item, using the first available of EventTitle, AssetName, MaterialName, Name, Title, MaterialId or EventId as the media reference, and StartDateTime, StartTime or SmpteDateTime as the air time.
<BxfMessage xmlns="http://smpte-ra.org/schemas/2021/2008/BXF">
<BxfData><Schedule>
<ScheduledEvent>
<EventData>
<EventTitle>news</EventTitle>
<StartDateTime>2026-08-01T18:00:00Z</StartDateTime>
</EventData>
</ScheduledEvent>
</Schedule></BxfData>
</BxfMessage>
3 Β· XMLTV β the widely used open EPG format. Each <programme> becomes an item, using <title> as the media reference and the start attribute (20260801180000 +0000) as the air time. Handy because PlayoutGo already publishes XMLTV, so a guide can round-trip.
<tv>
<programme start="20260801180000 +0000" channel="c1">
<title>news</title>
</programme>
</tv>
API
# multipart upload (as the interface does) curl -X POST "http://SERVER:8700/api/playlist/3/import?replace=true" \ -H "Authorization: Bearer $TOKEN" -F "file=@schedule.csv" # or post the document directly curl -X POST "http://SERVER:8700/api/playlist/3/import" \ -H "Authorization: Bearer $TOKEN" --data-binary @schedule.xml
The response reports the detected format, how many entries were parsed, added, anchored and skipped, plus a warnings list naming each unmatched entry and its line number.
Restart & Resilience v1.9
A 24/7 channel must survive a reboot, a crash or an upgrade without an operator pressing anything. PlayoutGo separates two ideas to make that work:
- Status β what a channel is doing right now (running, stopped, error).
- Desired state β what you asked for. βΆ Start marks the channel as should be running; βΉ Stop clears it.
Shutdown stops the engines but deliberately preserves the desired state, so the next start brings back every channel you left on air. Channels queued for resume show an β³ auto-resume badge in the Channels list.
Where does it resume from?
- With a schedule β the engine walks your anchors, chains floating-item durations after them, compares against the wall clock, and seeks mid-file into the item that should be airing now. A channel anchored 25 minutes ago with three 10-minute items resumes 5 minutes into the third item.
- Without a schedule β playback continues from the next pending item.
- Empty rundown β the channel is left stopped with a note in its error log rather than spinning.
Resume is driven by the schedule rather than saved playback state, so it behaves correctly whether the process was stopped cleanly, killed, or the machine rebooted.
kill -9 followed by a restart resumed into item 2 at +12.5 s β exactly the scheduled position for the wall-clock time at which the process returned.Automatic at the OS level
Run under systemd with Restart=always (see the 24/7 tutorial). Combined with channel resume, a reboot restores the entire playout unattended.
Measuring Channel Density v1.28
"How many channels will this box run?" has no useful generic answer β it depends on your bitrates, resolutions and storage far more than on PlayoutGo. The capacity panel gives a projection; benchmark.sh (shipped with the source) gives a measurement.
./benchmark.sh --password 'your-admin-password' \
--file representative-clip.mp4 \
--max 40 --step 4 --settle 60
It starts channels in steps, lets each step settle, then records CPU, load per core, I/O wait, memory, disk utilisation and egress β and, critically, whether every channel is still producing healthy HLS. Results go to benchmark-<timestamp>.csv; the benchmark channels are deleted automatically when it finishes or is interrupted.
Why it stops on output health, not a resource threshold
A host can look comfortable on CPU while segments start arriving late. The run therefore stops when any channel's HLS goes unhealthy, and reports the previous step as your practical density. Resource verdicts (LOAD_SATURATED, IO_BOUND) are recorded alongside so you can see which resource gave out first.
Reading the results
- The last row with verdict
okand zero unhealthy channels is your measured density for that content. IO_BOUNDin the verdict means storage, not CPU, is your ceiling β move media to SSD/NVMe before buying cores.- Run it with representative content. A 640Γ360 test pattern will give a flattering and useless number; benchmark with the bitrate and resolution you actually air.
- Benchmark on an otherwise quiet host. If the machine is already loaded by other work, you are measuring that, not PlayoutGo.
As-Run Logs v1.10
The as-run log is the record of what actually aired, as opposed to what was scheduled. Broadcasters need it for advertiser reconciliation, licensing reports, affiliate compliance and post-incident review. PlayoutGo writes one record per item automatically β there is nothing to enable.
Reading the log
Open the π§Ύ As-Run tab. Filter by channel and date range (quick buttons for 1, 7 and 30 days), and the header summarises total items, total airtime in hours, bytes delivered, and how many entries had errors.
| Started / Ended | Wall-clock times playback actually began and finished. |
| Duration | Measured on-air time β not the file's nominal length. A shortfall against the file's nominal length means the channel was stopped or the item failed mid-play β not that playout truncated it. |
| Sent | Bytes delivered during the item. Populated for SRT output; HLS-only channels write segments to disk rather than a socket, so this reads 0. |
| Status | completed aired in full Β· playing on air now Β· error failed Β· interrupted the process died mid-item (closed out automatically on the next start, so airtime totals stay honest). |
Exporting
β¬ Export CSV downloads exactly the rows currently filtered, with columns channel, file, started_at, ended_at, duration_sec, bytes_sent, errors, status, last_error. Timestamps are UTC RFC-3339 so spreadsheets and billing systems parse them unambiguously. The download is fetched with your auth header rather than a URL token, so your credentials never reach server access logs.
API
GET /api/asrun?channel_id=&from=&to=&format=csv|json&limit= from / to RFC3339 or YYYY-MM-DD (a bare date for 'to' means end of that day) default the last 24 hours format=csv returns a downloadable CSV instead of JSON # last week for one channel, as CSV curl -H "Authorization: Bearer $TOKEN" \ "http://SERVER:8700/api/asrun?channel_id=3&from=2026-07-20&to=2026-07-27&format=csv" \ -o asrun.csv
Output Modes
hls_dir. No SRT connection attempted. Best for internet streaming, browser preview, and Plex/Emby/Jellyfin.Authentication
All API endpoints (except EPG feeds and /api/auth/reset) require a JWT Bearer token. Obtain a token via login.
POST /api/auth/login
{"email": "admin@playout.local", "password": "admin!123"}
β {"token": "eyJ...", "user": {...}}
Pass the token in the Authorization header:
Authorization: Bearer eyJ...
?token= query parameters (except for SSE streams, which cannot set custom headers). Always use the Authorization header for REST calls.current_password and new_password. Changes persist across restarts.{"secret": "config_admin_password"}. Rate-limited to 5 attempts per IP. Resets admin email/password to values in config.json.Channels API
name. Optional: description, output_mode, srt_mode, srt_host, srt_port, srt_stream_id, srt_passphrase, srt_latency, loop_playlist.scheduled_at set.stopped. The channel's original start time is preserved.Files API
file (MP4/M4V), optional short_desc and long_desc. Max size configured by max_upload_mb. File is probed asynchronously β status transitions from processing to ready or error.short_desc and long_desc.Playlist API
{"file_id": 5, "position": 2, "scheduled_at": "2025-01-15T20:00:00Z"}. Position defaults to end of list. All subsequent items are shifted down to maintain unique positions.scheduled_at and/or position. Pass "scheduled_at": "null" (string) to clear the schedule. Invalid RFC-3339 now returns 400 instead of being silently ignored (v1.4). Position updates reorder the playlist consistently.{"items":[{"id":12,"scheduled_at":"2026-07-26T18:00:00Z"},{"id":13,"scheduled_at":""}]} β empty string clears (item becomes floating). Any invalid item ID or timestamp rolls back everything (400/500, no partial writes). Returns the full updated playlist. This powers the Rundown's Set & flow, Clear-all, bulk-clear and Undo.{"item_ids": [3, 1, 4, 2]}. Assigns positions 0, 1, 2, 3 β¦ Any items not included are appended at the end in their current order.{"count": 20}.Schedule API
scheduled_at within the given time window. Defaults to today through 7 days from now.Stats API
?token= since EventSource cannot set headers.EPG / XMLTV API
These endpoints generate XMLTV-compatible EPG data for use with Plex, Emby, Jellyfin, Kodi, and other IPTV managers. Per-channel feeds support unauthenticated access so media players can poll without a token.
tvg-id, tvg-name, and catchup-source attributes.Admin API
All admin endpoints require both authentication and the admin role.
max_channels limit (0 = unlimited).processing status.running or connecting after a server restart.SRT Output
SRT (Secure Reliable Transport) provides low-latency, resilient TS delivery over UDP. PlayoutGo supports both caller and listener modes.
Caller mode (default)
PlayoutGo dials out to your ingest server. Configure your ingest to listen on the specified host/port.
# Nimble Streamer β listen on port 9000, stream ID "ch1" srt://0.0.0.0:9000?streamid=ch1&mode=listener # VLC β listen on all interfaces vlc srt://:9000
Listener mode
PlayoutGo listens on the specified port. Your ingest server or VLC calls in.
# VLC calls in to PlayoutGo listener vlc srt://your-server:9000?mode=caller&transtype=live&streamid=ch1&latency=200000 # Flussonic ingest srt://your-server:9000?mode=caller&transtype=live&streamid=ch1
Connecting a player (listener mode) v1.4
The listener routes by exact stream-ID match. Clients with an empty or wrong stream ID are rejected (REJ_PEER) so a stray puller can never grab a channel's output slot. Use this exact form β the URL parameter is streamid= and latency is in microseconds:
ffprobe -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=YOUR_STREAM_ID&latency=200000" vlc "srt://SERVER:9000?mode=caller&transtype=live&streamid=YOUR_STREAM_ID&latency=200000"
Clean join: a newly connected client receives nothing until the next keyframe, so the stream always starts on an IDR with SPS/PPS β expect up to one GOP of delay before first frame, and no "non-existing PPS" decoder errors.
Log hint: when a listener starts, the server logs the full ready-to-paste connect URL.
SRT parameters
| Parameter | Default | Description |
|---|---|---|
| srt_mode | caller | caller (dial out) or listener (accept connections) |
| srt_host | localhost | Target host (caller mode) or bind address (listener mode) |
| srt_port | 9000 | UDP port number |
| srt_stream_id | SRT stream ID for multiplexed ingest servers | |
| srt_passphrase | AES encryption passphrase (10β79 chars) | |
| srt_latency | 200 | Target latency in milliseconds (ARQ buffer) |
| srt_max_bw | -1 | Maximum bandwidth in bytes/sec (-1 = unlimited) |
HLS Output
HLS segments are written to hls_dir/{channel-slug}-{id}/. The playlist is available at:
http://your-server:8700/hls/{channel-slug}-{id}/index.m3u8
Segments are keyframe-aligned MPEG-TS files: a segment closes on the first keyframe after 4 seconds (hard cap 15 s for sparse-GOP content), so every segment begins with an IDR carrying SPS/PPS and is independently decodable β a requirement for Safari's native HLS. The rolling window keeps the last 6 segments; an atomic rename ensures players always see a consistent playlist.
Consistent audio for mixed content v1.4
Video-only files (no audio track) automatically receive a synthesized silent 48 kHz stereo AAC-LC track covering the video duration. Files with real audio pass through untouched. Result: the HLS variant presents one stable audio layout across a mixed playlist β without this, strict players (Safari, tvOS) reject the whole stream on seeing an audio track with 0 channels. Discontinuity tags are emitted at every file boundary.
index.m3u8?v=2), or open the URL in QuickTime Player (File β Open Location).hls_dir to /dev/shm/playout_hls (tmpfs). This eliminates disk I/O for segment writes and keeps latency low. Segments are ephemeral β they are recreated on restart.
XMLTV / Plex Integration
Add your channels to Plex DVR, Emby, or Jellyfin using the XMLTV feed:
- In Plex: Settings β Live TV & DVR β Add EPG Source β enter
http://your-server:8700/epg/{channelID} - In Emby/Jellyfin: Dashboard β Live TV β Add β XMLTV β enter the same URL
- Programme data comes from the playlist items'
short_desc/long_descandscheduled_atfields
M3U Playlists
/hls/<slug>-<id>/index.m3u8 β identical to what the UI shows. (Bare-slug URLs still resolve for backward compatibility.)The M3U feed at /m3u lists all channels with their HLS or SRT URLs. Import in VLC, Kodi, or any IPTV app:
vlc http://your-server:8700/m3u
HLS Proxy
The CORS proxy at /api/hls-proxy?url=... fetches external HLS streams through the server, solving browser CORS restrictions. It rewrites all segment and key URLs to also go through the proxy.
localhost, 10.x.x.x, 192.168.x.x, or 172.16.x.x resources. Configure hls_proxy_allowed_hosts in config.json to restrict which external hosts may be proxied.
Admin Guide v1.5
Everything on this page requires an admin account. The π‘ Admin tab is hidden for regular users, and every /api/admin/* endpoint returns 403 for non-admin tokens. The first admin is created from admin_email / admin_password in config.json on first start β change that password immediately in production.
User management
- Create users from the Admin tab (or let people self-register if you keep registration open). Each user sees only their own channels, media, rundowns, EPG and stats.
- Roles:
admin(sees and manages everything, /m3u and EPG return all channels) anduser(scoped to their own resources). - Edit / disable: change email, role, or reset a password from the user list. Disabling a user blocks login but keeps their channels and media intact.
- Deleting a user cascades: their channels, playlist items, and media records are removed (files on disk under uploads/user_<id>/ remain β clean up manually if desired).
System monitoring & capacity
The Admin tab now leads with a System Utilisation panel: CPU, memory, disk I/O, per-volume disk usage, aggregate channel output, a capacity estimate of how many more channels the host can carry, and automatically generated tuning advice.
Account creation
Two routes: create an account directly (Users β + New User), or issue an invite code that lets someone self-register with a preset channel limit. Tick Require an invite code to sign up to close open registration.
Maintenance operations
- Re-import (Admin β Reimport, POST /api/admin/reimport) β scans the uploads directory and registers any files present on disk but missing from the database (e.g. copied in over SFTP). New records probe automatically.
- Fix statuses (POST /api/admin/fixstatus) β re-probes files stuck in processing or wrongly marked, refreshing their metadata and status.
- Admin stats β global dashboard: per-user channel/file counts, disk usage, running engines.
Operational practices
- Backups: everything lives in two places β
playout.db(all users/channels/rundowns/metadata) and theuploads/tree (media). Copy both; HLS segments are ephemeral and never need backup. - Upgrades: stop the binary, swap it, start. The schema migrates automatically; rundowns and anchors survive. Then run the Testing Guide acceptance pass.
- Logs: the process logs to stdout β run under systemd or redirect to a file. Watch for auto-restart lines (a file failing repeatedly) and throttled SRT reject lines (a misconfigured puller hammering a port).
- tmpfs: keep
hls_diron /dev/shm; it regenerates on start.
System Utilisation & Capacity Planning v1.8
The π‘ Admin β System Utilisation panel shows what the host is actually doing, estimates how many more channels it can carry, and turns those measurements into specific tuning advice. Tick live to refresh every 5 seconds.
What each metric means
| CPU | Host-wide utilisation sampled between calls, plus load average. Playout is remux-only, so CPU should stay modest; sustained high CPU usually means very high-bitrate sources or another process competing. |
| Memory | Used vs total, based on MemAvailable (so page cache is not counted as used). Swap pressure is called out separately because swapping causes stream stalls. |
| Disk I/O | Read/write throughput, IOPS and utilisation (the share of wall-clock time the busiest device had a request in flight). Utilisation near 100% means storage is the bottleneck even if CPU looks idle. |
| Channel output | Aggregate egress across running engines, measured from bytes actually sent β the honest figure for capacity planning. |
| Network | Host-wide interface throughput (excluding loopback and container bridges). |
| Disks | Usage for the uploads volume, the HLS directory and the application directory. The HLS entry is flagged when it is correctly on tmpfs. |
How the capacity estimate works
The green panel answers "how many more channels can this box run?" It computes headroom three independent ways and reports the tightest one, along with which resource is the limiting factor:
- By CPU β remaining headroom up to a 75% planning ceiling, divided by measured per-channel CPU cost.
- By RAM β remaining headroom up to an 80% ceiling, divided by per-channel memory.
- By tmpfs β free space in the HLS directory versus the rolling segment window at the current bitrate.
Ceilings are deliberately below 100% so bursts, GC and the operating system still have room. When channels are running, per-channel cost is measured from your actual content and the estimate is marked confident. With nothing running it falls back to conservative assumptions and says so β start a few representative channels and re-check for a figure you can plan against.
Tuning suggestions
Below the metrics, PlayoutGo lists advisories generated from the current snapshot β colour-coded by severity, each with a concrete action. Typical guidance:
- Keep
hls_diron tmpfs (/dev/shm/playout_hls). Segments are rewritten every few seconds per channel; on disk this is pure wear and I/O for no benefit. You'll be told if it isn't. - Put media on SSD/NVMe. If disk utilisation is high while CPU is low, media reads are your bottleneck.
- Watch load-vs-CPU divergence. High load average with low CPU means processes waiting on I/O, not compute.
- Avoid swap. Any meaningful swap usage on a streaming host risks stalls; add RAM or shed channels.
Invite Codes & Signup Control v1.8
Invite codes let you hand out accounts without opening registration to anyone who can reach the server β and each code carries the channel limit for the accounts created with it.
Creating a code
π‘ Admin β Invite Codes β + New Code, then answer three questions:
- Channels allowed β the per-user limit applied at signup (
0= unlimited). Enforced whenever that user tries to create a channel; they see "channel limit reached (n/m)" at the ceiling. - Uses β
1for a personal single-use invite (recommended), a higher number for a team,0for unlimited. - Expiry β optional; blank or 0 means it never expires.
Codes look like NVMY-WNND-SXTF β generated with a cryptographic random source and an alphabet that omits easily-confused characters (no O/0, no I/1). The new code is copied to your clipboard automatically.
Requiring codes for signup
Tick "Require an invite code to sign up" to close open registration. New users then must supply a valid code on the signup form. The toggle is persisted to config.json (require_invite_code) so it survives restarts.
Lifecycle
- Revoke stops a code being used but keeps the record (and does not affect accounts already created with it). Re-enable reverses it.
- Delete removes the record entirely; again, existing accounts are unaffected.
- Status is shown per code: active, used up, expired or revoked.
- Redemption is atomic β two people racing to use the last remaining use of a code cannot both succeed.
Changing a user's limit later
The code sets the limit at signup only. To change it afterwards, use Admin β Users β β Edit and set Max channels directly (0 = unlimited).
HTTPS with Automatic Certificates v1.16
PlayoutGo can serve HTTPS on port 443 with certificates obtained and renewed automatically from Let's Encrypt β no reverse proxy, no certbot, no cron job. Enable it in config.json:
{
"tls": {
"enabled": true,
"domains": ["playout.example.com"],
"email": "ops@example.com",
"cache_dir": "certs",
"port": 443,
"http_port": 80,
"redirect_http": true,
"staging": false
}
}
domains | Required. Certificates are only issued for these hostnames. Without an allowlist anyone who points DNS at your server could trigger issuance and exhaust your rate limits, so startup refuses an empty list. |
cache_dir | Where certificates and the ACME account key are stored. Must persist across restarts β a wiped cache means re-issuing on every boot and hitting Let's Encrypt limits. Created mode 0700; PlayoutGo tightens it automatically if it finds it group/world-readable. |
redirect_http | Binds port 80 to answer ACME HTTP-01 challenges and 301-redirect everything else to HTTPS. Leave this on: HTTP-01 validation always arrives on port 80. |
staging | Uses Let's Encrypt's staging CA β certificates are untrusted by browsers but limits are far higher. Prove your DNS and firewall with this first, then switch it off. |
Requirements
- The domain's DNS A/AAAA record must already point at this server before first start β issuance happens on the first request for that hostname.
- Ports 80 and 443 reachable from the internet. Let's Encrypt validates from outside; a firewall that only allows your office will fail issuance.
- Binding ports below 1024 needs privileges. Either run as root, or grant the binary the capability once:
sudo setcap 'cap_net_bind_service=+ep' /opt/playout/playout
PlayoutGo prints this hint automatically if the bind fails.
First run
βΊ Set staging:true, start the service, and watch the log:
π HTTPS on :443 for playout.example.com
βͺοΈ HTTP :80 answers ACME challenges and redirects to HTTPS
π Certificate cache: certs (must persist across restarts)
βΊ Visit https://playout.example.com β the browser will warn (staging CA), which
proves issuance worked end to end.
βΊ Set staging:false, delete the cache directory once, restart.
β A trusted certificate is issued on the first request and renewed automatically.
tls.enabled false and keep terminating TLS at nginx/HAProxy as before β nothing changes. TLS is off by default, so upgrading never causes an unexpected attempt to bind 443.Security Hardening
v1.4 security behaviors
- SRT passphrases are never returned by the API β channel create/update/list/stats responses expose only
srt_passphrase_set: true|false. To remove encryption intentionally, send{"clear_srt_passphrase": true}on channel PUT; a blank passphrase field means "keep existing". - /m3u requires a token and returns only the requesting user's channels (admins see all); unauthenticated requests get 401. The UI's copy link includes your token automatically.
- Partial channel updates are safe β PUT merges onto the stored record, so sending only
{"name":"x"}no longer zeroes SRT host/port/latency/loop settings. - HLS proxy pins DNS-validated IPs at dial time and re-checks the allowlist on every redirect hop (DNS-rebinding and redirect-SSRF are closed).
- SRT listener requires an exact stream-ID match; empty/unknown IDs are rejected and reject-log lines are throttled to one per 5 s per ID.
| Finding | Severity | Fix |
|---|---|---|
| HLS path traversal via /hls//etc/passwd | π΄ Critical | filepath.Clean + HasPrefix check ensures resolved path stays inside hls_dir |
| SSRF via /api/hls-proxy | π΄ Critical | Resolves hostname to IP; blocks all RFC-1918/loopback/link-local ranges; optional allowlist |
| Proxy URL encoding breaks signed segments | π‘ High | url.Values.Encode() used instead of string concatenation |
| Any user reads all channels' live stats | π‘ High | Stats endpoints now filter to caller's own channels (admin sees all) |
| Playlist position silently ignored on PUT | π Medium | UpdatePlaylistItemPosition() implemented and wired to the handler |
| Duplicate positions on AddToPlaylist | π Medium | Existing items at β₯ position are shifted up in the same transaction |
| Reorder leaves stale positions | π Medium | Omitted items are appended at end; all items always get a valid position |
| Wrong item played with future schedule | π Medium | Engine stops at a future-scheduled item; no unscheduled item may jump past it |
| started_at overwritten on stop/error | π Medium | started_at is only set on "running"/"connecting" transitions |
| Unauthenticated admin reset (brute-force) | π‘ High | 5-attempt rate limit per source IP added |
| Password changes lost on restart | π‘ High | EnsureAdmin() no longer overwrites existing admin credentials |
| Config file world-readable (0644) | π‘ High | SaveConfig() now writes with 0600 (owner read/write only) |
| JWTs in query strings leak to logs | π Medium | API middleware removed ?token= support; kept only for SSE (EventSource limitation) |
| Data race on e.output (SRT goroutine) | π΄ Critical | outputMu RWMutex added; all accesses use getOutput()/clearOutput() helpers |
| Login endpoint brute-forceable | π‘ High | Per-IP failure counter; 20 consecutive failures = block |
| Arbitrary role string accepted in admin user update | π‘ High | role validated to exactly "admin" or "user" |
| Admin can self-demote via API | π Medium | Self-demotion rejected with clear error message |
| JWT in M3U link URL (EPG page) | π Medium | Token stripped from public M3U URL β endpoint needs no auth |
| JWT in video src attribute (file preview popup) | π Medium | Preview now uses fetch() + Blob URL; token stays in Authorization header |
| output_mode / srt_mode accept arbitrary strings | π’ Low | Validated against allowed enum values on create and update |
| SRT passphrase length not validated | π’ Low | Enforced 10β79 char requirement per SRT protocol spec |
| multiplayout: any file type uploadable | π‘ High | Extension whitelist (MP4/M4V) + MaxBytesReader before ParseMultipartForm |
| multiplayout: login brute-forceable | π‘ High | Per-IP failure counter; 20 consecutive failures = block |
| multiplayout: undefined variable n in ensureSRTDial | π΄ Critical | Pre-existing build error fixed; network var correctly stays "srt" |
| MP4 parser OOM via crafted sample count | π΄ Critical | Capped at 10M samples (β« 90 hours of video) in three box parsers |
| SRT connection leak on cancel/timeout | π‘ High | Drain goroutine closes any stale connection before returning |
| Scheduler goroutine leaked on server shutdown | π Medium | Context-aware Run(); cancelled by server shutdown context |
| XSS in stats grid (current_file unescaped) | π Medium | X() escaper applied; file names can contain HTML characters |
| XSS in admin user table (role unescaped) | π Medium | X() applied in badge and meta; editUser uses data-* attributes |
| Missing 405 on GET-only endpoints | π’ Low | Method guard added to five read-only handlers |
| HLS tmp file not cleaned on rename failure | π’ Low | os.Remove(tmp) called on both WriteFile and Rename error paths |
| writePES infinite loop β OOM crash | π΄ Critical | TS stuffing recalculated payload-first; remaining always advances β₯1 byte per iteration |
| SRT rejection log floods 100+ lines/second | π Medium | Batched: one log line per 5s with rejection count |
| Playout panic kills entire server | π΄ Critical | recover() in goroutine; one channel crash no longer kills the process |
| HLS takes 4s minimum before first segment | π Medium | First segment flushes on any keyframe after 0.5s |
| SRT client connecting mid-stream misses PAT/PMT | π Medium | PSI injected immediately on SRT connect via srtNewConn atomic flag |
Production checklist
- Change
admin_passwordandjwt_secretin config.json before first deployment - Put PlayoutGo behind a reverse proxy (nginx/Caddy) with TLS β never expose it directly on port 80/443
- Firewall
/api/auth/resetat the reverse proxy layer (allow only from management IPs) - Set
hls_proxy_allowed_hostsif you know which external HLS servers you need to proxy - Set
hls_dirto tmpfs (/dev/shm) for performance - Run as a non-root user; set
GOMEMLIMIT=3584MiBin the systemd unit - Back up
playout.dbanduploads/regularly
Deployment (systemd)
[Unit] Description=PlayoutGo Playout Server After=network.target [Service] Type=simple User=playout WorkingDirectory=/opt/playoutgo ExecStart=/opt/playoutgo/playout Restart=always RestartSec=5 Environment=GOMEMLIMIT=3584MiB # Ensure HLS tmpfs dir exists ExecStartPre=/bin/mkdir -p /dev/shm/playout_hls [Install] WantedBy=multi-user.target
sudo systemctl enable --now playoutgo
nginx reverse proxy (with TLS)
server {
listen 443 ssl;
server_name playout.example.com;
ssl_certificate /etc/letsencrypt/live/playout.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/playout.example.com/privkey.pem;
# Block emergency reset from public internet
location /api/auth/reset {
allow 10.0.0.0/8;
deny all;
}
location / {
proxy_pass http://127.0.0.1:8700;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Required for SSE
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
Troubleshooting
A page is blank, or dropdowns/buttons do nothing
This is almost always a JavaScript error aborting the page script. Open the browser console (Safari: Develop β Show JavaScript Console; Chrome: β₯βJ) and look for "X is not defined". Then:
- Hard-refresh first (β§βR / Ctrl+Shift+R) β the UI is embedded in the binary, so an upgraded server with a cached old page is a common cause.
- If you are on v1.4.0βv1.6.0, upgrade to v1.6.1: those builds had a genuine missing-function bug affecting Rundown and Stats.
- Developers: run
go test -run TestUI ./...β the shipped UI static tests detect undefined functions and missing element IDs.
SRT does not connect but HLS works
Work through these in order β they cover almost every case:
- Include the stream ID. The listener matches it exactly; a URL with no
streamidis rejected. Use the connect URL printed in the server log when the listener starts:
ffprobe -i "srt://HOST:9000?mode=caller&transtype=live&streamid=YOUR_ID&latency=200000" - Check the channel's Stats β error log. Since v1.11 a failed listener bind is recorded there, e.g. "SRT output unavailable: SRT port 9000 already in use with different listener settings". Two channels may share a port only when passphrase, latency, buffer and max-bandwidth match β then they are routed by stream ID. Otherwise give them separate ports.
- Confirm something is listening:
ss -lunp | grep 9000on the server, and check the log for "[SRT] Listener on :9000". If it is absent, no channel currently owns that port. - Remember SRT is single-consumer β one client per channel at a time. Disconnect VLC before testing with ffprobe.
- Firewall: SRT is UDP. Confirm the port is open for UDP, not just TCP.
The Help tab (or another tab) is not visible
Fixed in v1.6.1 β tabs used to overflow on narrow windows. On older builds, widen the browser window or scroll the tab strip horizontally.
Channel stuck in "connecting"
Run POST /api/admin/fixstatus to reset channels that are stuck in running or connecting after an ungraceful shutdown. Or stop/start the channel from the UI.
HLS stream shows "Stream starting, please waitβ¦"
The channel is running but no segments have been flushed yet. Wait 4β5 seconds for the first keyframe boundary, then reload. If it persists, check the channel error log via GET /api/channels/{id}/diag.
SRT connection fails immediately
In caller mode, verify the downstream server is listening before starting the channel. In listener mode, verify the port is open in your firewall. Check the channel stats for the last SRT error message.
File stuck in "processing" status
The background probe goroutine may have failed. Use POST /api/admin/reimport to re-trigger probing on stuck files. Check server logs for MP4 parse errors β the file may be corrupt or use an unsupported codec (H.265, VP9, etc.).
All playlist items show "failed"
The engine marks items failed when it can't read samples. This usually means the files are HEVC/H.265 β only H.264/AAC MP4 is supported. Re-encode with: ffmpeg -i input.mp4 -c:v libx264 -c:a aac output.mp4
HLS proxy returns 403 "upstream host not permitted"
The proxy blocked the request because the target hostname resolves to a private IP. If you need to proxy an internal host, add it to hls_proxy_allowed_hosts in config.json.
Testing Guide β verify every feature v1.4
A step-by-step acceptance checklist. Run it after every upgrade. Replace SERVER, TOKEN, CH (channel id) and stream IDs with your values. Get a token via the login call in step 1.
1 Β· Authentication
βΊ curl -s -X POST http://SERVER:8700/api/auth/login -H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"..."}'
β Returns {"token":"..."} β export it: TOKEN=eyJ...
βΊ curl -s http://SERVER:8700/api/channels
β 401 without a token; 200 JSON array with -H "Authorization: Bearer $TOKEN"
2 Β· Media upload & probe
βΊ Upload an MP4 in the Files tab (or POST /api/files) β Status shows "processing", then "ready" with real widthΓheight, fps, duration, bitrate β Restart the server β metadata persists (no re-probe of already-probed files) βΊ Upload a renamed non-MP4 (e.g. a .txt renamed .mp4) β Status becomes "error", not "ready"
3 Β· Channel lifecycle
βΊ Create a channel (output=both, listener, port 9000, stream id "test1"), press Start β Status: connecting β running; logs show HLS segmenter + SRT listener with a connect URL βΊ Press Stop during an auto-restart back-off (kill the engine by removing its media) β Channel stays stopped β no zombie resurrection
4 Β· Rundown β anchors & flow
βΊ Add 3 files; drag to reorder β Grey floating times reflow instantly while dragging βΊ "Schedule day from" a time 1h ahead β Set & flow β Item 1 shows π bold yellow; items 2β3 show π grey computed times βΊ Drag item 3 above item 2 β Floating times swap; the anchor does not move βΊ Lock item 3 at a time 10 min AFTER item 2's flow end β Amber "GAP β 10:00" row appears between them βΊ Edit that anchor to a time BEFORE item 2's flow end β Red "+MM:SS over" badge appears on the anchor
5 Β· Rundown β bulk, undo, safety
βΊ Shift-click to select a range β bulk bar appears
β Move Top / Move Bottom / Clear times / Remove all work on the range
βΊ Press β© Undo after each mutation
β Previous schedule/order returns exactly
βΊ Try to remove the row marked βΆ playing
β Confirmation prompt appears
βΊ API atomicity:
curl -s -X POST http://SERVER:8700/api/playlist/CH/schedule_bulk \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"items":[{"id":REAL_ID,"scheduled_at":"2030-01-01T00:00:00Z"},{"id":999999,"scheduled_at":""}]}'
β Error response AND the real item's time is unchanged (rolled back)
6 Β· Schedule engine & restart resume
βΊ Anchor item 1 at nowβ25min with three 10-min items flowing after; restart the channel β Log shows resume in item 3 at β5:00 offset (flow-aware restart) βΊ Anchor an item 1 min in the future on a stopped channel; wait β Scheduler auto-starts the channel ~15 s before air
7 Β· SRT output
βΊ With no other client connected: ffprobe -i "srt://SERVER:9000?mode=caller&transtype=live&streamid=test1&latency=200000" β Connects; shows your video (h264 β¦); NO "non-existing PPS" spam; brief keyframe wait is normal βΊ Same URL with streamid=WRONG (or none) β Rejected with ERROR:PEER; server log shows throttled reject lines βΊ Connect VLC with the correct URL, then run ffprobe simultaneously β ffprobe is rejected β single consumer per channel is enforced βΊ Wrong passphrase on an encrypted channel β Rejected (bad secret) β this is the one legitimate password error
8 Β· HLS output
βΊ curl -s http://SERVER:8700/hls/SLUG-ID/index.m3u8 β #EXTM3U, EXT-X-VERSION:3, segments with ~real durations, DISCONTINUITY at file changes βΊ Download any segment and probe it: ffprobe -v error -select_streams a -show_entries stream=channels,sample_rate SEG.ts β channels=2, sample_rate=48000 β including for VIDEO-ONLY source files (silent track) ffprobe -v error -select_streams v -show_entries frame=key_frame -read_intervals %+#1 SEG.ts β first frame of every segment is a keyframe (key_frame=1) βΊ Play the m3u8 in Safari (private window) and VLC β Both play; audio-bearing files have sound, video-only files are silent
9 Β· EPG / M3U
βΊ curl -s http://SERVER:8700/m3u β β 401 unauthorized βΊ curl -s "http://SERVER:8700/m3u?token=$TOKEN" β M3U with only YOUR channels; SRT URLs use streamid= and transtype=live βΊ /epg/my?token=β¦ returns XMLTV for your channels
10 Β· Security checks
βΊ Create/update/list a channel with a passphrase set
β API responses contain srt_passphrase_set:true and NEVER the passphrase itself
β /api/stats/CH likewise
βΊ PUT {"name":"renamed"} only
β SRT host/port/latency and loop flag survive (no zeroing)
βΊ PUT {"clear_srt_passphrase":true}
β Encryption removed; unencrypted client now connects
βΊ Channel name with quotes/<script> renders inert in UI and M3U
11 Β· Stats & admin
βΊ Stats tab while a channel runs β Live viewer/bitrate numbers update; history renders; no passphrase in the payload βΊ Admin tab (admin account): user list, create/edit/disable user, per-user channels β Non-admin token on /api/admin/* β 403
12 Β· In-app Help v1.5
βΊ Click the π Help tab β Full manual loads inside the interface (this document) βΊ On Channels / Media / Rundown / Player / EPG / Stats / Admin, click the ? next to the title β Help opens scrolled to that page's manual section βΊ Click β in the header β Manual opens in a separate browser tab βΊ Developers, before deploying: go test ./... β TestUIHasNoUndefinedFunctions and TestUICriticalElementsExist pass (these catch UI functions/IDs broken by refactoring β invisible to the compiler)
13 Β· Gap filler & duplicate v1.6
βΊ Upload a short slate clip; set it as Gap filler in a channel's settings βΊ Rundown: one 6s item, then a second item anchored ~30s ahead β GAP row names the filler βΊ Start the channel and watch the log β Item 1 plays, then "Filler: β¦ looped N time(s); padding Xs to anchor" β Anchored item starts on its exact scheduled second β Live stats show "βΈ Filler: name" during the gap; HLS keeps flowing with discontinuities βΊ Clear the filler (None) and repeat β Gap transmits null packets (dead air) as before βΊ Select two rundown rows β β§ Duplicate β Floating copies appear at the end in order
14 Β· Multi-tenant isolation & admin users v1.7
βΊ As admin: π‘ Admin β + New User (email, password β₯8 chars, choose role)
β User is created and can log in
β Duplicate email β "already exists"; short password β rejected
βΊ Log in as that user and try to use ANOTHER tenant's media:
curl -X POST .../api/playlist/<your-channel> -d '{"file_id":<other-users-file>}'
β 403 "media file belongs to another user"
curl -X PUT .../api/channels/<your-channel> -d '{"filler_file_id":<other-users-file>}'
β 400 "filler file belongs to another user"
curl -X PUT .../api/channels/<your-channel> -d '{"filler_file_id":999999}'
β 400 "filler file not found"
βΊ Own media still works normally in both places
β Added / set without error
βΊ Developers: go test ./...
β tenant_isolation_test.go and ui_static_test.go all pass
15 Β· System metrics & invite codes v1.8
βΊ π‘ Admin β System Utilisation β CPU, memory, disk I/O and disk usage all show real figures (not "β") β Capacity panel names a limiting factor (CPU / RAM / tmpfs) and a per-channel cost β Tick "live" β values refresh every 5 s; untick and leave the tab β refresh stops βΊ Start several channels, refresh β Capacity switches from "(estimated)" to measured, per-channel cost reflects your content βΊ Admin β Invite Codes β + New Code (e.g. 3 channels, 1 use, 30 days) β Code appears, is copied to clipboard, shows "active" βΊ Sign out, register with that code β Account created; the new user may create exactly 3 channels, then sees "channel limit reached" βΊ Try the same code again β "already been used the maximum number of times" βΊ Revoke a code, then try it β Rejected; Re-enable restores it βΊ Tick "Require an invite code to sign up" with no usable codes β Refused β create a code first (prevents locking everyone out) βΊ With a code available, tick it, then register without a code β "an invite code is required to sign up" βΊ Developers: go test ./... β invite_sysinfo_test.go covers redemption, expiry, revocation, concurrency and metric sanity
16 Β· Restart resume v1.9
βΊ Start a channel that has a schedule; let it run a minute βΊ Kill the process hard: kill -9 $(pgrep -x playout) then start it again β Log: "[resume] restoring N channel(s)β¦" then "back on air" β Log: "Time-aware restart: seeking to item X (+NNNNms)" β Playing what the SCHEDULE says should be on now β not from the top of the rundown βΊ Press βΉ Stop, then restart the process β Channel stays stopped (explicit stop clears the auto-resume intent) β Stopped-but-marked channels show the β³ auto-resume badge βΊ Verify disk figures against the OS: df -h β System Health percentages match df (measured against usable space) β Each volume listed once even when uploads and the app share a filesystem
17 Β· As-Run log v1.10
βΊ Run a channel for a few minutes, then open π§Ύ As-Run β One row per item that aired, oldest first, with real start/end times β Duration reflects actual airtime; Status shows completed β Header summarises item count, total airtime hours and bytes βΊ Try the 1d / 7d / 30d buttons and the channel filter β Row counts and the summary change accordingly βΊ Press β¬ Export CSV β A .csv downloads and opens cleanly in a spreadsheet with a header row βΊ Kill the process mid-item (kill -9) and restart it β Log line: "as-run: closed N play record(s) interrupted by the last shutdown" β That item shows status "interrupted" β not stuck on "playing" βΊ As a non-admin user, open As-Run β Only your own channels appear β curl .../api/asrun?channel_id=<another user's> β 403
18 Β· Disk I/O attribution & SRT diagnostics v1.11
βΊ Admin β System Utilisation, compare with the host: iostat -x 2 and df -h β The DISK I/O card names the device(s) actually backing uploads/HLS/the app β not whichever device has the largest kernel counter β A device with no reads/writes shows 0% utilisation (an idle or hung disk that the kernel parks at 100% util is no longer reported as system disk load) β Percentages match df; each volume appears once βΊ Capacity: compare "more channels" with uptime/load β On a host whose load per core is already β₯ 1, the CPU headroom figure is ~0 even when instantaneous CPU% looks low (processes blocked on I/O) βΊ SRT not working while HLS is fine? Check the channel's Stats β error log β A bind/settings clash now appears there, e.g. "SRT output unavailable: SRT port 9000 already in use with different listener settings" βΊ Two channels may share one SRT port only if passphrase, latency, buffer and max-bandwidth all match; they are then routed by stream ID. β Give clashing channels distinct ports, or identical settings + distinct stream IDs
19 Β· Channel health indicators v1.12
βΊ Run a channel with output mode "both" and no SRT client connected β Card shows an amber "SRT waiting" chip (normal β the listener is up) βΊ Connect VLC to it β Chip turns green "SRT live" βΊ Create a second channel on the same SRT port with a DIFFERENT latency, start it β Chip turns red "SRT error"; hovering shows the reason β A red count badge appears on the Channels tab from any page β Hovering the badge lists each channel and its problem βΊ Watch a running channel card for ~30 s β An OUTPUT BITRATE sparkline appears with the current Mb/s (works for HLS-only channels too β segment bytes are counted, not just SRT socket writes) βΊ Admin β System Utilisation β click the DISK I/O card β Expands to a per-device breakdown naming what each disk holds β Only disks backing uploads/HLS/the application are listed
20 Β· System figures sanity-check v1.13
βΊ Run several HLS-only channels, then open Admin β System Utilisation β CHANNEL OUTPUT is non-zero (HLS segment bytes count as egress, not just SRT) β The capacity box says "measured from N running channel(s)" β it must never claim nothing is running while the card beside it shows channels running β PROCESS shows "service up β¦" (this process) and "host up β¦" separately; after a redeploy service uptime resets while host uptime does not β Compare with the OS: iostat -x 2 Β· df -h Β· uptime Disk device, percentages and load should agree
21 Β· Reading System Utilisation correctly v1.14
βΊ Compare the CPU card with: uptime and iostat -x 2 β The headline is LOAD PER CORE, not instantaneous usage β a host at 1% CPU with load 9 across 8 cores is saturated and must not appear green β "using N%" and, when significant, "iowait N%" appear beneath it β iowait β₯ 20% raises a "CPU is waiting on storage" advisory naming the disk as the bottleneck rather than the processor βΊ Click the CHANNEL OUTPUT card β Expands to a per-channel list, with a β SRT marker on any channel whose SRT output is failing β The rows always add up exactly to the headline figure βΊ Before the second sample arrives (immediately after a restart) β Cards show "β" with "awaiting a second sample", never a misleading 0.0
21 Β· Resource cleanup & silent-failure checks v1.15
βΊ Note the segment directories: ls -d /dev/shm/playout_hls/*/ βΊ Delete a channel from the Channels tab β Its directory disappears; the log records "removed segment directory β¦" βΊ Create and delete a few channels, then restart the service β Log records "cleaned N orphaned segment directories" β nothing accumulates βΊ Start a channel, then clear its rundown while it is on air β Channel stays up transmitting padding (correct) BUT reports "Playlist is empty β channel is on air but transmitting padding only" β It appears in Stats β error log and is counted by the nav issues badge βΊ Admin β System Utilisation, cross-check against the OS: uptime Β· iostat -x 2 Β· df -h β Channel Output expands to a per-channel breakdown; parts sum to the total
22 Β· HTTPS & sign-up codes v1.16
βΊ Set tls.enabled true with staging:true and your real domain; restart β Log shows HTTPS listener, HTTP challenge/redirect listener and cache path β http://your-domain/ returns 301 to https://your-domain/ (path and query kept) β /.well-known/acme-challenge/β¦ is answered, not redirected β Certificate cache directory is mode 0700 βΊ Set tls.enabled true but leave domains empty β Startup refuses with a message naming the problem βΊ Admin β Invite Codes β generate a code with max_channels = 2, max_uses = 1 β With require_invite_code=true, signing up WITHOUT a code is refused β Signing up WITH the code succeeds and grants max_channels = 2 β The user can create 2 channels; the 3rd returns "channel limit reached (2/2)" β Re-using the code returns "already been used the maximum number of times"
23 Β· Generate & import schedules v1.19
βΊ Rundown β π² Generate β enter "20" β 20 items appear; no file plays twice in a row β Files still probing or marked error are NOT scheduled (the note says how many) βΊ Generate again with "5m" β Items are added until ~5 minutes of content is reached; the toast reports the total βΊ Via API with the same "seed" twice β Identical ordering both times βΊ Rundown β β¬ Import β choose a CSV with a file,start header β Toast reports format, added and anchored counts β Rows with a start time appear π anchored; rows without flow after the previous item β An unknown filename is reported by line number instead of failing the import βΊ Repeat with native XML, a BXF export and an XMLTV guide β Each is detected automatically (format shown in the toast) βΊ Import a file with no recognisable entries β Refused with a message naming what was expected
24 Β· Multi-clip gap filler v1.27
βΊ Upload three short clips of different lengths (say 0:30, 0:10, 0:05) βΊ Channel settings β Gap filler content β select all three (β/Ctrl-click) β Save βΊ Rundown: one short item, then a second item anchored ~1 minute ahead βΊ Start the channel and watch the log β "Filler: covering 55s with a.mp4 + b.mp4 Γ2 + c.mp4 (2s null-padded)" β The anchored item still starts on its exact scheduled second β The same clip is not played twice in a row while an alternative fits βΊ Clear the filler selection and repeat β Gap transmits null packets; the stream stays up and the channel stays running βΊ Select a clip that has no duration β It is greyed out and cannot be chosen (re-probe it first)
Changelog
v1.28.0 β Density Benchmark & Idle-Host Capacity Fix
Fixed
- [FIX] High: Capacity ignored run-queue pressure when no channels were running. The load-average bound added in v1.11 only applied once channels were on air, so an idle PlayoutGo on a host already saturated by other work advertised "+29 more channels" while load sat at 9.00 across 8 cores with 75% I/O wait. The bound now applies whether or not channels are running: a host past the planning ceiling on load alone reports zero headroom.
New
- [NEW]
benchmark.shβ measures real channel density instead of projecting it. Scales channels in steps, settles, records CPU/load/iowait/memory/disk/egress per step to CSV, and stops when any channel's HLS output degrades, reporting the previous step as the practical limit. Cleans up its channels on exit or interrupt. Requires only bash, curl and python3 β no jq. - [NEW] Measuring Channel Density chapter: how to run it, why it stops on output health rather than a resource threshold, how to read the verdicts, and the two ways to get a misleading number (unrepresentative content, or a host that is already busy).
Regression protection
- [NEW] Tests covering both directions: a saturated idle host must report zero CPU headroom, and a genuinely quiet host must still report usable headroom.
v1.27.1 β Accurate Overlap Wording
Fixed
- [FIX]: The overlap warning claimed the preceding item "will be cut short". That is not what this engine does: an item always plays to completion, and the anchored item simply starts late by the overlap amount. An operator reading the old text could reasonably have expected a hard cut and planned around a truncation that never happens. The row, the tooltip and the documentation now state the real behaviour, and add that the delay does not accumulate β the next anchor is an absolute wall-clock time, so slack before it puts the day back on schedule.
- [FIX]: The as-run Duration note carried the same implication and has been corrected: a shortfall against a file's nominal length means the channel was stopped or the item failed mid-play, not that playout truncated it.
Regression protection
- [NEW] A test asserts the interface and documentation never reintroduce "cut short" wording, and that both continue to state what actually happens. Verified against the engine: the playback loop exits only on end-of-file or an explicit channel stop, with no mid-file truncation path.
v1.27.0 β Multi-Clip Bin-Packed Gap Filler
New
- [NEW] Gap filler now accepts a pool of clips and fits the best combination into each hole, instead of looping one slate and leaving the remainder as black. A 3:20 gap with a 2:00 / 1:00 / 0:20 / 0:10 pool is covered exactly; the same gap with a single 2:00 slate left 1:20 of dead air. Verified live: a 29 s gap covered as 8+8+4+4+2+2 with 1 s padded, anchor still on its exact second.
- [NEW] Greedy longest-fit packing that avoids playing the same clip twice in a row when an alternative fits, and repeats only when nothing else does β repetition beats dead air.
- [NEW] The log now names exactly what covered each gap and how much was padded, so filler behaviour is auditable.
- [NEW] Unusable filler clips (still probing, failed, or missing a duration) are reported once to the channel error log rather than silently degrading to black, and are greyed out in the selector.
- [NEW] The single-clip setting still works unchanged, so existing channels need no reconfiguration.
Documentation
- [NEW] Rewritten Gap Filler chapter: exactly what happens during a gap, how bin-packing works and why a mixed pool beats one clip, guidance on choosing filler, an honest comparison with professional playout techniques (including what PlayoutGo does not do β elastic items and under/over compensation), and troubleshooting. Testing Guide step 24 added.
Regression protection
- [NEW]
filler_test.goβ tight packing of a mixed pool, remainder-only padding with a single clip, refusal to schedule unusable clips, no-immediate-repeat behaviour, repeating when no alternative exists, and degenerate inputs (empty pool, zero gap, gap shorter than every clip).
v1.26.0 β Overlaps as Visible as Gaps
Changed
- [NEW] Overlaps now get a full-width red row naming how far content runs past the anchor and what to do about it. Previously a gap produced a prominent amber row while an overlap produced only a small badge beside the time β the wrong emphasis, since an overlap means an anchored programme starts late, whereas a gap merely means dead air that a gap filler can cover.
- [NEW] The rundown footer now counts problems: "5 items Β· 4:10 content Β· 3 anchors Β· β 2 gaps Β· β 1 overlap", so conflicts are visible without scrolling a long day.
Verified
- Overlap detection was re-checked against a real rundown shape: an over-long floating item pushing past a later anchor, two anchors closer together than the first item's duration, and the no-overlap case β all correct, with no false gaps reported on an overlapping anchor and later anchors still reporting their own gaps.
v1.25.0 β Re-probe Progress Reporting
New
- [NEW] Live progress for bulk re-probe. The panel previously said "Re-probing N files in background" and then went silent β no indication of how far it had got, how many files were recovered, or whether it had finished. It now shows a progress bar with done / total, a running count of files recovered, and any that are still broken, finishing with a summary such as "Re-probe finished: 187 of 195 recovered Β· 8 still unusable" and the elapsed time.
- [NEW]
GET /api/admin/probe-statusreports the same figures for scripted use. - [NEW] Progress distinguishes recovered (now ready with a duration) from still unusable, so a file that genuinely cannot be parsed is visible rather than being silently counted as processed. Those need re-encoding β see the conform recipe.
Regression protection
- [NEW]
probe_progress_test.goβ counts and completion state through a full run, an empty run that must not leave the interface polling forever, and a second run resetting rather than accumulating.
v1.24.0 β Unknown Durations Made Visible
Fixed
- [FIX] High: Items with no recorded duration silently corrupted the whole schedule. They were treated as zero-length, so a rundown could show two consecutive items both starting and ending at the same second, and every air time after the first such item was wrong β with nothing on screen indicating a problem. Gap and overrun figures derived from those times were equally unreliable. The rundown now marks each affected item β unknown, shows a banner naming how many there are and what it means, and reports the count in the footer totals.
- [FIX]: The NOW countdown rendered waits over a day as raw hours ("81:12:22"). Long waits now read as "3d 9h".
Verified, not changed
- The gap/overrun arithmetic itself was checked independently against the live implementation: gaps, overruns, exact fits, floating-chain accumulation and the zero-duration case all compute correctly. The defect was never the maths β it was that a missing input was indistinguishable from a real zero.
v1.23.0 β Generation and Re-probe for Files Missing Durations
Fixed
- [FIX] High: Generate refused to run when media had no recorded duration. The pool required both
readystatus and a known duration, so a library whose files probed without recording one produced "no ready media files to build a playlist from" β while the media list beside it showed those very files. Generating by item count no longer needs durations at all; the result notes how many scheduled files lack one, since air times after them cannot be calculated until they are recovered. - [FIX] High: Files marked ready but missing a duration could not be repaired from the interface. Re-probe only picked up
errorandprocessingfiles, so these were stuck permanently: visible, unschedulable, and with no button that would fix them. Re-probe now includes them (the control is relabelled Re-probe Broken Files). - [FIX]: Generation failure messages now distinguish "nothing uploaded yet", "nothing has finished probing", and "durations unknown, so a duration-based fill is impossible" β each naming the action that resolves it, instead of one message that fitted none of them.
Regression protection
- [NEW]
gen_duration_test.goβ reproduces the reported failure (ready files with no duration must generate successfully and warn), verifies duration-based fill fails with an actionable message, and checks each empty-library message is specific.
v1.22.0 β Stream URLs Follow the Request Scheme
Fixed
- [FIX] High: Generated stream and EPG links were hardcoded to
http://host:8700. With HTTPS enabled on 443 the interface loaded correctly, but every HLS URL it produced still pointed at plain HTTP on the old port β so browsers blocked the segments as mixed content and the built-in player showed only "Error". The interface now derives links from the origin it was loaded from, so scheme and port always match. - [FIX]: Server-side URL generation (M3U playlists, the EPG index and channel API responses) assumed
http://as well. All of them now follow the actual request scheme, honouringX-Forwarded-Protoso the links are also correct behind a reverse proxy that terminates TLS.
Regression protection
- [NEW]
url_scheme_test.goβ scheme detection for direct TLS, plain HTTP, and proxied requests including a forwarded chain; plus a check that the M3U feed advertiseshttps://stream URLs when served over HTTPS and never emits plainhttp://ones.
v1.21.0 β Dangling Gap-Filler References
Fixed
- [FIX] High: Deleting media that a channel used as its gap filler locked that channel out of all further edits.
filler_file_idhas no foreign key, so the deleted file's id stayed on the channel. Because a channel update re-validates the whole record, every subsequent save β renaming it, changing a port, anything β failed with "filler file not found", referring to a field the operator had never touched and could not see was broken. Deleting a media file now clears the reference from every channel using it, and reports how many were affected. - [FIX]: References left dangling by earlier builds are repaired automatically at startup, so existing installations are unlocked without manual database surgery. The log records how many channels were repaired.
Audited, no defect found
- Concurrent playlist writes: 30 simultaneous additions produced 30 items with no losses, and simultaneous generate + import runs produced exactly the expected number of rows with no position collisions. (Noted honestly: this environment has a single CPU and SQLite serialises writes, so absence of a collision here is weak evidence rather than proof; the check is kept as a permanent guard.)
Regression protection
- [NEW]
filler_ref_test.goβ deleting filler media clears the reference and leaves the channel editable; the startup repair fixes dangling references while leaving valid ones untouched. - [NEW]
playlist_concurrency_test.goβ concurrent additions and concurrent generate/import must neither lose rows nor duplicate positions.
v1.20.0 β Full API Parity in the Interface
New interface controls for existing API features
- [NEW] Remove SRT encryption from the channel dialog. Clearing a passphrase was previously only possible through the API (
clear_srt_passphrase), because a blank field means "keep the existing one". The checkbox appears only when a channel actually has encryption set, and the field's placeholder now says whether one is stored. - [NEW] Generate dialog replaces the single text prompt and exposes every option the endpoint supports: fill by item count or by hours, replace or append, the no-repeat guarantee, and an optional seed for reproducible ordering.
Fixed
- [FIX]: Generating in append mode could place the same file back to back at the join. The no-repeat check only looked within the newly generated batch, so the first new item could duplicate the last existing one β contradicting the documented guarantee. The seam is now checked against the existing rundown, and the first pick of every pass is guarded too.
Regression protection
- [NEW] Twelve consecutive single-item appends from a two-file library β every one a seam β must produce no back-to-back repeats.
- [NEW] Generation must never schedule media that is still probing or marked
error, and must tell the operator how many files it skipped.
v1.19.0 β Playlist Generation & Schedule Import
New
- [NEW] π² Generate β build a randomised rundown from the media library by item count or target duration. Only ready files are used, the library is reshuffled for each pass instead of repeating one fixed order, the seam between passes is checked so nothing plays twice in a row, and an optional seed makes the ordering reproducible.
- [NEW] β¬ Import β load a schedule from CSV or XML, replacing or appending. Air times in the source become anchored items; entries without one flow normally. Unmatched entries are reported individually by line number rather than failing the import.
- [NEW] Three XML dialects, detected by content rather than namespace: PlayoutGo native, a practical subset of SMPTE ST 2021 (BXF) β the broadcast standard used by traffic systems β and XMLTV, which PlayoutGo already publishes, so a guide can round-trip.
- [NEW] Forgiving media matching (exact β case-insensitive β extension-agnostic β substring) and flexible timestamps covering RFC-3339, common date/time layouts, XMLTV's compact form and bare times meaning "today".
Fixed
- [FIX]: The previous
randomendpoint did not filter by file status, so it could schedule media that was still probing or had failed to parse β producing dead air at transmission. It also repeated a single fixed shuffle when asked for more items than the library held, and always destroyed the existing rundown with no option to append. The new implementation fixes all three;randomremains as an alias with its old replace-by-default behaviour. - [FIX]: The import result returned
nullrather than an empty array forwarnings, forcing clients to null-check.
Regression protection
- [NEW]
import_test.goβ CSV with and without headers and across column synonyms, all three XML dialects (BXF tested with its real namespace present), rejection of documents with no entries, the media-matching ladder, and every accepted timestamp layout.
v1.18.0 β Data-Race and Input-Handling Fixes
Fixed
- [FIX] High: Two data races between the playout engine and the statistics collectors, both introduced by recent monitoring work.
e.channelwas assigned while an engine started and read concurrently by the SRT-health check;e.hlsSegwas assigned the same way and read by the HLS egress accounting and the bitrate sampler. Under Go's race detector these are genuine unsynchronised accesses that can return torn or stale pointers, and on a busy multi-core host could crash the process. Both fields are now atomic pointers, which keeps the per-sample playout path lock-free. - [FIX]: Upload filenames were truncated on a byte boundary, splitting multi-byte characters and writing filenames containing invalid UTF-8 to disk. Because the admin re-import scanner reads names back from disk, that corruption could propagate into the database. Truncation now happens on a rune boundary, control characters and invalid sequences are stripped, and a name that reduces to nothing (or to only dots) becomes "upload" rather than an empty or dot-only stem.
Audited, no defect found
- Path traversal: upload names are always prefixed with a nanosecond timestamp and separators are replaced, and the HLS handler rejects
..and verifies the resolved path stays inside the segments directory. A traversal attempt in an upload filename was confirmed to land harmlessly inside the user's own directory. - As-run parameters: absurd limits and malformed dates are rejected or clamped (400 for bad input, never a full-table scan).
- Non-MP4 content with an .mp4 extension is accepted for upload but correctly probed and marked
error, notready.
Regression protection
- [NEW]
concurrency_test.goβ 40 interleaved Start/Stop calls on one channel, and continuous stats collection during 20 start/stop cycles, both under the race detector. These are what exposed the two races above. - [NEW]
sanitize_test.goβ filename handling must always yield valid UTF-8, stay within the length limit, contain no separators or control characters, and never be empty.
v1.17.0 β Atomic Channel Allowances
Fixed
- [FIX]: The per-user channel allowance was enforced with a check-then-act sequence. The handler counted a user's channels and inserted the new one afterwards, leaving a window in which several concurrent requests could each observe the count below the limit and all succeed β taking a user past the allowance their invite code granted. Counting and inserting now happen inside one transaction, so the allowance holds no matter how many requests arrive at once.
Honest note: this could not be reproduced in the test environment, whose single CPU and SQLite write serialisation hide the window. The fix removes the window by construction rather than relying on that timing; a multi-core production host is far more exposed. - [FIX]: The limit error now carries the counts as structured data instead of a formatted string, so the API reports "channel limit reached (2/2)" from a single source of truth.
Audited, no change needed
- Invite-code redemption was reviewed for the same class of bug and is already safe: it consumes a use with a conditional
UPDATE β¦ WHERE uses < max_usesand checks the affected row count, which is an atomic compare-and-swap.
Regression protection
- [NEW]
limit_test.goβ 25 concurrent creations against an allowance of 3 must yield exactly 3, unlimited users must never be blocked, and the error must report usable numbers.
v1.16.0 β HTTPS on 443 with Automatic Certificates
New
- [NEW] Built-in HTTPS with Let's Encrypt. PlayoutGo can now serve port 443 directly with certificates obtained and renewed automatically β no reverse proxy, certbot or cron job. A companion listener on port 80 answers ACME HTTP-01 challenges and 301-redirects everything else to HTTPS, preserving path and query.
- [NEW] Safety rails around issuance: a domain allowlist is required when TLS is enabled (an open host policy would let anyone pointing DNS at the server exhaust your rate limits), the certificate cache is created mode 0700 and tightened automatically if found readable by others, and a staging mode is provided for proving a deployment without burning production limits.
- [NEW] Bind failures on privileged ports print the exact
setcapcommand needed rather than a bare permission error. - [NEW] TLS is off by default and the block is written into new config files, so existing reverse-proxy deployments are unaffected by upgrading while operators can still discover the option.
Fixed
- [FIX]: Adding the ACME dependency silently downgraded
gosrtfrom v0.9.0 to v0.8.0 and raised the Go directive to 1.25. Both were caught and pinned back; the SRT stack builds and tests against the intended version.
Verified (already present, re-tested end to end)
- Invite-code sign-up with a per-code channel allowance: refusing sign-up without a code when required, granting the allowance on redemption, enforcing it at channel creation ("channel limit reached (2/2)"), and rejecting an exhausted code.
Regression protection
- [NEW]
tls_test.goβ empty domain list refused, port/cache defaults backfilled for pre-TLS configs, domain casing and whitespace normalised, HTTPS redirect correctness including non-standard ports, and TLS remaining off by default.
v1.15.0 β Resource Cleanup & No More Silent Failures
Fixed
- [FIX] High: Deleted channels leaked their HLS segment directories forever. The segments volume is normally a modest tmpfs, so a site that regularly creates and removes channels would slowly fill it with directories belonging to channels that no longer exist. Deleting a channel now reclaims its directory, and orphaned directories are swept at startup β covering deletions that happened while the service was down, and crashes. Verified live: four orphans cleaned on boot, and a deleted channel's directory removed immediately.
- [FIX]: A channel whose rundown became empty looked perfectly healthy. It stayed green and "running" while transmitting only padding β correct behaviour for a linear channel, but completely silent. It now reports "Playlist is empty β channel is on air but transmitting padding only" to the error log, the Stats page and the issues badge, once rather than every second.
- [FIX]: Segment directory naming is derived in one place, so the segmenter, the HTTP handler and the cleanup routines cannot disagree about which directory belongs to a channel.
Regression protection
- [NEW]
cleanup_test.goβ a deleted channel's directory is reclaimed, orphans are swept while live channels are preserved, and directory naming is stable everywhere it is derived.
v1.14.0 β Honest System Metrics
Interface
- [NEW] The CPU card now leads with load per core rather than instantaneous usage, and colours by whichever is worse. A host at 1% CPU with a load of 9 across 8 cores is saturated β the old card showed that as healthy green.
- [NEW] I/O wait is measured and displayed. High iowait with low CPU usage is the signature of a storage bottleneck and was previously invisible; at 20% or more it raises an advisory that names the disk, not the processor, as the constraint.
- [NEW] Channel Output is expandable into a per-channel breakdown with each channel's current egress and a β marker for failing SRT output.
- [NEW] Cards distinguish "no measurement yet" (a dash and "awaiting a second sample") from a genuine zero. The two were indistinguishable, so a panel that had not sampled yet looked like a dead system.
Fixed
- [FIX]: The Channel Output breakdown did not sum to its own headline. The total came from an independent sampler with a different window, so the expanded rows disagreed with the figure above them by roughly 50%. The headline is now derived from the same samples as the breakdown and reconciles exactly.
- [FIX]: Final unchecked
rows.Scanin the playlist reorder path now returns its error, completing the audit begun in v1.10.0.
Regression protection
- [NEW] Tests asserting the per-channel breakdown sums to the headline, and that iowait is sampled separately and stays within the non-busy share of CPU time.
v1.13.0 β Correct System Figures
Fixed
- [FIX] High: Channel Output always read 0.0 Mb/s on HLS hosts. Aggregate egress summed only SRT socket writes, so a server running many HLS channels reported no output at all. HLS segment bytes are now included, and the per-channel average derived from them is correct.
- [FIX] High: The capacity panel contradicted itself. Measured figures required CPU usage above 1%, so an efficient host could show "13 running of 23" beside the message "No channels are running, so per-channel cost is assumed". Capacity is now measured whenever channels are running; when per-channel CPU is too small to resolve from one sample it says exactly that and applies a conservative floor rather than pretending the host is idle.
- [FIX]: The PROCESS card showed host uptime, so a freshly redeployed service appeared to have been running for months. Service uptime and host uptime are now shown separately.
- [FIX]: Disk and network rates are guarded against unsigned counter underflow. If the set of measured devices changed between samples (a disk added, removed or remounted) the aggregate could decrease and produce an absurd throughput figure.
- [FIX]: The running-channel count now comes from live engines rather than stored channel status, which can go stale if a channel dies without updating the database β that inflated the count and skewed every per-channel projection.
Regression protection
- [NEW] Test asserting the capacity basis can never claim an idle host while channels are running, and that an idle host still reports honestly.
v1.12.0 β Channel Health at a Glance
New interface
- [NEW] SRT health indicator on every channel card, distinguishing the three states that previously all looked the same: green SRT live (a client is pulling), amber SRT waiting (listener up, nobody connected β normal), red SRT error with the reason on hover (e.g. a port/settings clash). Previously a failing SRT output was indistinguishable from an idle one.
- [NEW] Issues badge in the navigation β a red count on the Channels tab whenever any channel has an SRT failure, a stalled HLS output, or an error state, visible from any page. Hovering lists each channel and its problem.
- [NEW] Output-bitrate sparklines on running channel cards: roughly two minutes of egress history with the current rate, smoothed over a trailing six-second window so segment flushes don't render as a sawtooth.
- [NEW] Expandable Disk I/O card in System Utilisation β click for a per-device breakdown showing throughput, IOPS and utilisation for each disk, labelled with what it holds (media uploads, HLS segments, application).
Fixed
- [FIX]: HLS output was not counted as egress. Byte accounting only tracked SRT socket writes, so HLS-only channels β the common case β reported zero output bitrate everywhere. The segmenter now accounts for the bytes it writes, through both of its segment-write paths.
- [FIX]: Two defects in this release's own new code, caught before shipping: the issues badge read a mis-named JSON field (
hls_last_segment_agorather thanhls_last_segment_ago_s) so stalled-HLS detection never fired, and the raw-packet segment path was missing byte accounting, which left sparklines empty for padded output.
Regression protection
- [NEW] Test asserting the segmenter accounts for the bytes it writes, so a silent return to flat sparklines fails the suite.
v1.11.0 β Correct Disk Attribution, Honest Capacity & SRT Diagnostics
Fixed
- [FIX] High: Disk I/O was attributed to the wrong device. The busiest disk was chosen by the largest cumulative
io_tickscounter, so an idle or hung device β one not even mounted, parked at 100% utilisation by the kernel with zero throughput β was reported as the system's disk load, permanently and alarmingly. PlayoutGo now resolves the actual block devices backing the uploads directory, the HLS directory and the application directory (via/sys/dev/block) and measures only those. tmpfs correctly resolves to no block device. - [FIX]: Utilisation is now reported as 0% when no requests were served in the sampling interval, instead of trusting a counter that some devices increment while idle.
- [FIX] High: Capacity ignored run-queue pressure. Instantaneous CPU% does not count processes blocked on I/O, so a saturated host could read 1% CPU and be told it had room for ~29 more channels while its load average was 9 across 8 cores. The estimate is now additionally bounded by load per core and reports the lower figure.
- [FIX]: SRT failures were invisible in the interface. When an SRT listener could not bind β most commonly two channels sharing a port with different latency/passphrase/buffer settings β the channel kept serving HLS and logged the reason only to stdout, so operators saw "HLS works, SRT doesn't" with no explanation. The reason is now written to the channel's error log and shown in Stats (once per failure, not every retry).
Regression protection
- [NEW]
sysinfo_test.goβ verifies devices resolve to the volumes we actually use, that tmpfs is not attributed to a block device, that an idle device reports 0% utilisation, and that a host with load β₯ 1 per core is not credited with spare CPU capacity.
v1.10.0 β As-Run Logs & Data-Integrity Fixes
New: as-run logging
- [NEW] π§Ύ As-Run tab β the record of what actually aired, with channel and date-range filters, quick 1/7/30-day ranges, and a summary of item count, total airtime and bytes delivered.
- [NEW] CSV export of exactly the filtered rows, with UTC RFC-3339 timestamps for unambiguous import into billing and reporting systems. Downloaded via an authenticated fetch, so the JWT never appears in a URL or server log.
- [NEW]
GET /api/asrunwithchannel_id,from,to,limitandformatparameters. Admins see all channels; other users are scoped to their own and receive 403 for another tenant's channel. - [NEW] Play records orphaned by an unclean shutdown are closed as
interruptedat startup, so the log never contains items that claim to still be on air and airtime totals stay accurate.
Bugs found and fixed
- [FIX] High: Play history lost its status column.
rows.Scan's error was discarded andlast_erroris NULL by default, so every scan aborted at that column β leavingstatus(the next column) permanently empty on every play-history row. Statuses now read correctly (completed,interrupted, β¦). Fixed withCOALESCEplus a checked scan. - [FIX]: Two further query loops discarded their scan errors (user list, channel error log), which would silently return blank records on any NULL column. Both now check the error, and nullable columns are coalesced.
Production-readiness audit
- [TEST] Every navigation item was exercised against a live server with a running channel: Channels, Media, Rundown, HLS Player, EPG (page, XMLTV and M3U), Stats, As-Run, Admin (users, sysinfo, invites, global stats, channels) and Help β all return HTTP 200 with valid payloads.
- [NEW]
asrun_test.goβ verifies records are written with correct names and statuses, tenant scoping including the 403 path, CSV structure and headers, date validation and empty-window handling, and orphan closure.
v1.9.0 β Automatic Restart Resume & Accurate Disk Reporting
New: channels survive restarts
- [NEW] Automatic resume after restart or crash. Previously nothing came back after a reboot β shutdown wrote "stopped" to every channel and startup restored nothing, so a 24/7 installation stayed dark until an operator pressed Start on each channel individually. PlayoutGo now records operator intent separately from live status, preserves it across shutdown, and restores every channel that was on air.
- [NEW] Resumed channels rejoin at the correct scheduled timestamp, seeking mid-file into the item that should be airing now. Verified with
kill -9: resumed into item 2 at +12.5 s, exactly the scheduled position. - [NEW] An explicit βΉ Stop clears the intent so deliberately stopped channels stay stopped; queued channels show an β³ auto-resume badge.
Fixed
- [FIX] High: Disk usage was measured against raw capacity rather than usable space. On any filesystem with reserved blocks or quotas this understated pressure badly β a volume
dfreports as 54% full displayed as 3.9%, making the capacity estimate and "disk is healthy" advice dangerously optimistic. Now matchesdf. - [FIX]: System Health listed the same physical volume up to three times (uploads, HLS directory and application directory commonly share one filesystem). Volumes are de-duplicated by device.
- [FIX]:
desired_runningwas missing from channel API responses, so the interface could not show resume state.
Interface
- [NEW] Hover help throughout the Rundown: every column header explains itself, the anchor hint expands on how waiting works, and the auto-resume badge explains what it means.
- [NEW] New Restart & Resilience chapter and Testing Guide step 16.
Regression protection
- [NEW]
resume_test.goβ proves intent survives a simulated shutdown, that an explicit stop clears it, and that the resume position lands on the right item at the right offset.
v1.8.0 β System Monitoring, Capacity Planning & Invite Codes
New: system utilisation & capacity
- [NEW] System Utilisation panel in Admin: host CPU (with load average), memory (and swap pressure), disk I/O throughput/IOPS/utilisation for the busiest device, per-volume disk usage, host network throughput, aggregate channel egress, and process stats. Optional 5-second live refresh.
- [NEW] Capacity estimate β "how many more channels can this box run?" computed three ways (CPU, RAM, tmpfs headroom) with the tightest reported as the limiting factor. Measured from real per-channel cost when channels are running, and honestly labelled as an estimate when not.
- [NEW] Automatic tuning advice β severity-graded advisories with concrete actions: CPU/memory pressure, swap usage, disks filling, storage as the bottleneck, HLS directory not on tmpfs, runaway goroutines.
- [NEW]
GET /api/admin/sysinforeturns the whole snapshot as JSON for external monitoring.
New: invite codes & signup control
- [NEW] Invite codes with a per-code channel limit applied to accounts created with them, plus use count (single/multi/unlimited), optional expiry and a note. Codes are generated with
crypto/randfrom an unambiguous alphabet (no O/0/I/1) inABCD-EFGH-JKLMform. - [NEW] Redemption is atomic β verified under concurrency that a 5-use code grants exactly 5 of 25 simultaneous attempts.
- [NEW] Revoke / re-enable / delete, with live status (active, used up, expired, revoked).
- [NEW] "Require an invite code to sign up" toggle closes open registration; persisted to config.json. Guarded so it cannot be enabled while no usable code exists (which would leave no route to an account).
- [NEW] Signup form gained an invite-code field; codes are accepted case-insensitively.
Friendlier interface
- [NEW] Explanatory tooltips across the primary controls and the Rundown column headers (what π/π mean, how Start/Ends are derived, what each status value means).
- [NEW] Contextual ? help on the new Admin panels, linking to the System Utilisation and Invite Codes manual sections.
Bugs found and fixed during this release's testing
- [FIX]: Invite codes were initially generated with
math/rand, which the compiler accepted silently because that package also exposesRead. Codes are credentials, so this would have made them predictable from a known PRNG sequence β switched tocrypto/rand. - [FIX]: Creating an invite code failed with a foreign-key error whenever the creating account no longer existed. Attribution is now stored as NULL rather than a dangling id, so code creation cannot break.
- [FIX]: The first call to the metrics endpoint reported CPU, disk I/O and network as "unavailable" because rate figures need two samples to compare. Sampling is now primed at startup, with a short inline sample as a fallback, so the very first view shows real numbers.
Regression protection
- [NEW]
invite_sysinfo_test.goβ code lifecycle, case-insensitive redemption, expiry and revocation, atomic redemption under 25 concurrent goroutines, usable-code counting, generated-code shape/uniqueness/alphabet, metric range sanity, and concurrent metric collection. - [TEST] Full suite now 20 tests, all passing under the race detector.
v1.7.0 β Security Hardening & Extensive In-App Documentation
Security fixes
- [SEC] High: Cross-tenant media via playlist. Adding an item to a playlist did not verify that the media file belonged to the channel's owner. Direct file reads correctly returned 403, but this path bypassed that check entirely β any user could add and broadcast another tenant's content by id. Now returns 403 "media file belongs to another user".
- [SEC] High: Cross-tenant media via gap filler. The same gap existed for
filler_file_id: it was stored unvalidated and played by the engine. Filler references are now checked for existence, ownership and ready status on channel create and update. - [FIX]: A dangling
filler_file_id(deleted or nonexistent file) was accepted silently and degraded to dead air at broadcast time; it is now rejected at configuration time with a clear message.
Functional fixes
- [FIX]: Admin user creation never worked.
POST /api/admin/usershad no handler β the request fell through the method switch and returned an empty HTTP 200, creating nothing, while the Admin Guide described the feature as available. Implemented with email and password validation (min 8 characters), role assignment, duplicate detection (409) and a proper 405 for unsupported methods. The Admin tab now has a + New User button.
New: extensive in-app documentation
- [NEW] Five step-by-step tutorials β Your First Channel (10 min), Build a Broadcast Day, a 24/7 FAST Channel, Multi-Tenant Setup, and Diagnose a Dead Stream (a layered top-to-bottom diagnostic path).
- [NEW] Recipes & Examples β encoding commands that conform sources for reliable playout, building a branded slate, restreaming to RTMP platforms, SRT relaying, scripting a schedule via the API, and backup/restore.
- [NEW] "How PlayoutGo Compares" β an honest positioning table against ffmpeg scripting, OBS, CasparCG, Flussonic/Nimble, commercial playout suites and cloud FAST platforms, including an explicit choose something else whenβ¦ section.
- [NEW] FAQ (re-encoding, channel density, SRT rejections, Safari vs VLC strictness, restart behaviour, silent audio, storage, internet exposure, shared SRT ports) and a Glossary of broadcast terms (anchor, floating, gap, overrun, GOP, IDR, discontinuity, null packets, caller/listener, FAST, as-run).
- [NEW] Help tab opens with a one-click launcher bar for every major topic.
Friendlier interface
- [NEW] First-run banner on Channels that adapts to your state β prompts you to upload media if you have none, or to create your first channel if you do, with a link into the 10-minute tutorial.
- [NEW] Actionable empty states: an empty rundown now explains the next step and links to the relevant tutorial, and detects whether the real blocker is that no media has been uploaded yet.
Regression protection
- [NEW]
tenant_isolation_test.goβ proves foreign media is rejected from both the playlist and filler paths, that dangling filler ids are rejected, that owners are not blocked from their own media, and that admin user creation works including duplicate/weak-password handling. - [NEW]
TestHelpAnchorsResolveβ every in-app help link and internal documentation link is checked against real section ids, so a renamed heading fails the test run instead of silently mis-navigating. - [TEST] Audited all SQL SELECT column lists against their Scan argument counts (a past source of bugs): all aligned.
v1.6.1 β Critical UI Repair
Fixed
- [FIX] Critical:
loadChSels()β the function that fills the per-page channel dropdowns β was deleted during the v1.4 Playlist/Rundown merge while five call sites still referenced it. Every visit to Rundown or Stats threw ReferenceError: loadChSels is not defined, aborting the page load: the channel selector stayed empty, the rundown never rendered, and the Add Files panel stayed blank. Restored, and now also fetches the channel list on demand, preserves the operator's selection across refreshes, and falls back to the first channel if the previous selection no longer exists. - [FIX] Critical:
renderPlFiles()(removed with the old Playlist page) was still called on every media load, throwing insideloadFiles(). Now calls the Rundown's file renderer. - [FIX]:
previewHLS()called a non-existentloadHLSPlayer()and targeted a stale input id, so the βΆ preview button on channel cards did nothing. Fixed to use the real player input and play routine. - [FIX]: Navigation tabs overflowed on narrower windows, scrolling the π Help tab out of reach. Tabs now compact progressively and collapse to icons on small screens so every tab stays clickable.
- [FIX]: Rundown and Stats no longer dead-end when a channel isn't selected β they explain what to do ("No channels yet β create one on the Channels tab first") and the media panel still works.
New: regression protection
- [NEW]
ui_static_test.goships with the source: it parses the interface, cross-checks every inline event handler and app-level call against the defined functions, and verifies that all element IDs the JS depends on exist. This class of failure β invisible to the Go compiler because the UI is plain inline JS β now fails the test run instead of reaching production. Verified by deliberately re-deletingloadChSelsand confirming the test fails. - [NEW] Tests are now included in the distributed source archive; run the whole suite with
go test ./...before deploying.
v1.6.0 β Gap Filler & Schedule-Engine Fix Release
New
- [NEW] Gap filler content β per-channel slate/filler clip that loops during schedule gaps before anchors (whole plays + null-padded remainder), replacing dead air. Configured in channel settings; picked up live; GAP rows in the Rundown show which clip will cover each gap. Verified live: 26 s gap covered by 4Γ6 s plays + 2 s pad with the anchor starting on its exact second.
- [NEW] Rundown bulk bar: β§ Duplicate β append floating copies of the selection to repeat a block later in the day.
- [NEW] Idempotent additive schema migrations (filler_file_id added automatically on upgrade; safe to re-run).
Bugs found by live testing and fixed
- [FIX] High: Starting a channel before its first anchor skipped all pre-anchor floating items (marked them played and jumped to the anchor). Start now begins at the top, plays the floating content, and waits at the anchor.
- [FIX] High: Future-anchored items were withheld from the engine until 5 s before air, so long gaps idled in a generic null-packet loop β bypassing the schedule-wait path (and the new filler). The engine now receives the anchored item immediately and owns the entire wait.
v1.5.0 β In-App Documentation & Field-Validation Release
New: documentation lives in the interface
- [NEW] π Help tab β the complete operations manual (this document) is embedded in the web UI; no context switch to read it.
- [NEW] Contextual ? buttons on every page (Channels, Media, Rundown, HLS Player, EPG, Stats, Admin) jump directly to that page's manual section.
- [NEW] UI Tour β five-minute onboarding walkthrough for new operators.
- [NEW] Admin Guide β user management, roles & scoping, re-import / fix-status maintenance, backup and upgrade practice.
- [NEW] Testing Guide extended with the in-app-help acceptance step.
Fixes
- [FIX]: M3U playlists and the EPG index emitted a non-canonical HLS URL (
/hls/<slug>/β¦without the channel-ID suffix). Both now emit the canonical/hls/<slug>-<id>/index.m3u8, matching the UI. The old form still resolves.
Field validation performed for this release
- [TEST] Full live end-to-end on the release binary: real multipart uploads β automatic probe (correct dimensions/fps/duration persisted) β rundown β running channel β HLS fetched over HTTP with per-segment ffprobe/ffmpeg validation (keyframe start, 48 kHz stereo audio on video-only sources, clean decode, discontinuity at file boundaries).
- [TEST] Loop semantics: after a full pass the playlist resets to pending and replays; note that anchors are one-shot wall-clock events β on loop, past anchors play immediately in flow order.
- [TEST] Deleting a media file while its channel is on air: playlist entries cascade away and the engine continues seamlessly with remaining content.
- [TEST] XMLTV and token-scoped M3U verified live.
v1.4.0 β Rundown, Safari-grade HLS & Field-Fix Release
Everything below was verified with automated Go tests, node syntax checks, live-server API smoke tests, and end-to-end ffprobe/ffmpeg stream validation.
New: Rundown scheduling UI
- [NEW] Playlist + Schedule merged into a single Rundown with the media library alongside β one view for what plays and when.
- [NEW] Anchored π vs floating π items: anchors pin exact air times; floating items chain after the previous item and reflow live on reorder/add/remove.
- [NEW] Gap & overrun detection β amber dead-air rows and red "+over" badges before/on anchors.
- [NEW] NOW line with countdown + auto-scroll, day separators, timezone label, footer totals (items Β· duration Β· anchors Β· span).
- [NEW] Multi-select with shift-click ranges; bulk Move Top/Bottom, Clear times, Remove.
- [NEW] Undo (10 levels) for schedule and order mutations; on-air guard prompts before touching the playing item.
- [NEW]
POST /api/playlist/{id}/schedule_bulkβ transactional bulk schedule writes; any failure rolls back completely. - [NEW] Flow-aware restart: CalcSchedulePosition chains floating-item durations after anchors, so restart resumes mid-block in the right item at the right offset.
Streaming correctness
- [FIX] Critical: Silent-audio synthesis for video-only files. Mixed playlists produced an empty AAC track (0 channels, no sample rate) that Safari rejected wholesale; video-only sources now get a real silent 48 kHz stereo AAC-LC track, giving one stable audio layout across the stream.
- [FIX] Critical: SRT clients joining mid-GOP saw "non-existing PPS / no frame". New connections are now keyframe-gated β output starts on the next IDR (with SPS/PPS).
- [FIX]: HLS segments cut only on keyframes (4 s target, 15 s sparse-GOP cap) so every segment starts decodable; removed the EXT-X-INDEPENDENT-SEGMENTS tag that conflicted with EXT-X-VERSION:3 on strict players.
- [FIX]: EXTINF durations include the final sample (segments no longer under-report by one frame).
- [FIX]: Documentation/UI/logs standardized on the working SRT URL form:
streamid=(not srt_streamid) with microsecond latency; the listener log prints a paste-ready connect URL. - [FIX]: SRT listener routing is exact-match only β empty/wrong stream IDs can no longer capture a channel's single output slot; rejects are throttled in logs.
Data & API fixes
- [FIX] Critical: Probe metadata (duration, size, codecs, width/height, fps, bitrate) is now persisted β files no longer re-probe on every restart and the UI shows real properties.
- [FIX]: tkhd v1 width/height offsets corrected in both the main parser and the fallback scanner; corrupt/audio-less-moov uploads are marked
errorinstead ofready; uploads insert asprocessing. - [FIX]: Channel PUT merges onto the stored record β partial updates no longer zero unspecified fields.
- [FIX]: Playlist item PUT rejects malformed RFC-3339 with 400 (was silently ignored).
- [FIX]: Time-aware skip uses playlist position, not row id (correct after reorders).
Security
- [SEC]: Raw SRT passphrase removed from channel create/update responses and from
/api/stats/{id}; explicitclear_srt_passphraseflag added. - [SEC]:
/m3unow requires a token and is scoped per user (admins see all). - [SEC]: HLS proxy: IP-pinned dialing (DNS-rebinding) + allowlist re-check on every redirect hop; empty-DNS guard.
- [SEC]: SRT URL building uses URLSearchParams/url.Values (correct encoding); UI escapes stream/HLS URLs in HTML and onclick contexts; M3U attribute escaping covers backslashes and strips CR/LF.
- [SEC]: Remaining MP4 table allocations bounded (ctts/stss/stsc/co64/stsz) with stsc-empty panic guard.
- [FIX]: SRT buffer/max-bandwidth now apply in listener mode and participate in shared-listener compatibility checks; auto-restart back-off is cancelable by Stop.
v1.3.1 β Critical Streaming Fix Release
- [BUG] Critical: Root cause of all OOM crashes found and fixed.
writePES()intsmuxer.gohad an infinite loop when the final TS packet required stuffing with 1β183 bytes. The adaptation-field stuffing calculation setmaxPayload=0, sotoCopy=0andremainingnever advanced β writing empty 188-byte packets forever at RAM speed until OOM. This caused the process to crash after 0β60 seconds of play depending on RAM and PTS pacing speed. Every single audio and video sample triggered this loop for its last partial TS packet. - [FIX]: Rewrote
writePESwith correct MPEG-TS stuffing: determine how many payload bytes fit first, then compute the adaptation-field size from the remaining space β never the other way around. The loop now always advancespayloadby at least 1 byte per iteration. - [FIX]: HLS streams now produce segments reliably β first segment flushes on the first keyframe after 0.5s (was: first keyframe after 4s minimum).
- [FIX]: SRT listener rejection log spam reduced β identical rejections now batched and logged once per 5 seconds instead of per-connection (100+ per second).
- [FIX]: Playout goroutine now recovers from panics via
defer/recover; a crash in one channel no longer kills the entire server. - [FIX]: PSI (PAT+PMT) injected immediately when an SRT client connects mid-stream, so the decoder gets programme map without waiting for the next 100ms PSI interval.
- [FIX]: Per-sample size validation added to mp4reader (max 50MB) to guard against OOM from corrupted STSZ box entries.
- [FIX]: Explicit
buf.Reset()added before first-sample write inplayFilefor belt-and-suspenders safety. - [FIX]: 20MB per-sample TS output sanity guard β skips the sample if the muxer produces unreasonably large output, rather than crashing.
- [FIX]: HLS force-flush reduced from 12s to 8s for faster stream recovery after a GOP without keyframes.
v1.3.0 β Robustness & Leak-Fix Release
- [BUG] Critical: MP4 parser OOM β malformed MP4 with crafted sample count (up to 4 billion) caused instant out-of-memory; capped at 10 million across STSZ, STTS, and STCO box parsers
- [BUG] High: SRT connection leak on cancel/timeout β goroutine running
srt.Dialcould leave a live socket open; now drained and closed in both exit paths - [BUG] Medium: Scheduler goroutine leaked on shutdown β now context-aware; exits cleanly when server stops
- [BUG] Medium: SQLite
busy_timeoutnot set β concurrent writes could return "database is locked"; set to 5s. Also addedsynchronous=NORMALand 32MB page cache - [BUG] Low: HLS tmp playlist file left on disk if
os.Renamefails;os.Removeadded to both WriteFile and Rename error paths - [SEC] Medium: XSS β
lv.current_fileinjected raw into stats gridinnerHTML; now escaped - [SEC] Medium: XSS β
u.roleraw in admin user badge and meta div; now escaped - [SEC] Medium: XSS β
editUseronclick embedded email in single-quoted JS attribute; replaced withdata-*attributes and dispatcher - [SEC] Low: M3U channel name not escaped in
tvg-nameattribute β quotes in channel name broke the M3U format;m3uAttrEsc()added - [SEC] Low: SRT stream ID not encoded in M3U URI β special chars broke SRT URL parsing; percent-encoded via
strings.NewReplacer - [BUG] Low: Missing HTTP 405 guards on
handleMe,handleStats,handleStatsPoll,handleAdminStats,handleAdminChannels - [BUG] Low: HTTP server missing
IdleTimeoutandReadHeaderTimeoutβ added 120s and 10s respectively to guard against slow-loris and zombie connections
v1.2.0 β Hardening & Polish Release
- [SEC] High: Login brute-force protection β 20 consecutive failed attempts per IP triggers a block on
/api/auth/login - [SEC] High: Role validation in admin user update β role must be exactly
"admin"or"user"; arbitrary strings rejected - [SEC] Medium: Admin self-demotion prevention β cannot demote own account to
"user"via the admin API - [SEC] Medium: Removed JWT token from public M3U link in EPG index β token was passed as query param to a public endpoint that ignored it
- [SEC] Medium: File preview popup no longer embeds JWT in video src attribute β now fetches via Authorization header and creates a Blob URL
- [BUG] Medium: Channel rename now returns HTTP 409 Conflict on duplicate name (was silent 500)
- [BUG] Low: output_mode and srt_mode validated on create and update β arbitrary strings no longer accepted
- [BUG] Low: srt_port range enforced (1024β65535); srt_latency capped (0β60 000 ms)
- [BUG] Low: srt_passphrase length validated (10β79 chars) per SRT protocol requirement
- [FEAT]: Added
POST /api/auth/refreshβ renew JWT without re-entering credentials - [FEAT]: Email format validation on registration
- [FEAT]: Minimum password length raised to 8 characters
- [FEAT] multiplayout: Upload extension whitelist (MP4/M4V only) + MaxBytesReader on uploads
- [FEAT] multiplayout: Login brute-force protection (20 attempts/IP)
- [BUG] multiplayout: Fixed pre-existing undefined variable
ninensureSRTDial(caused build failure)
v1.1.0 β Security & Correctness Release
- [SEC] Critical: Fixed HLS path traversal β
/hls/slug//etc/passwdno longer escapes hls_dir - [SEC] Critical: Fixed data race on SRT output pointer β added RWMutex; removed "acceptable race" workaround
- [SEC] Critical: Fixed SSRF in HLS proxy β private/loopback IPs blocked; optional host allowlist added
- [SEC] High: Fixed stats endpoints leaking other users' channel data β now filtered by ownership
- [SEC] High: Fixed admin password reset on every restart β EnsureAdmin() no longer overwrites existing credentials
- [SEC] High: Added rate limiting (5 attempts/IP) to
/api/auth/reset - [SEC] High: config.json now written with 0600 permissions to protect secrets
- [SEC] Medium: Removed ?token= from main API middleware β header-only auth for REST; SSE retains it (browser limitation)
- [BUG] Medium: Fixed proxy URL encoding β signed segments with query params now survive rewrite
- [BUG] Medium: Playlist position in PUT /item/{id} was silently dropped β now applied correctly
- [BUG] Medium: AddToPlaylist now shifts existing items to prevent duplicate positions
- [BUG] Medium: ReorderPlaylist now appends omitted items β partial reorders no longer leave stale positions
- [BUG] Medium: GetNextPlaylistItem now blocks at future-scheduled items β unscheduled items can no longer jump past them
- [BUG] Medium: started_at no longer overwritten on stop/error transitions
v1.0.0 β Initial Release
- Pure-Go MP4 demuxer (H.264 + AAC, ISO-BMFF)
- MPEG-TS muxer with PAT/PMT/PES, continuity counters, SCTE-35 pass-through
- HLS segmenter: keyframe-aligned 4s segments, rolling 6-segment window, atomic playlist writes
- SRT output via datarhei/gosrt β caller and listener modes, auto-reconnect
- Multi-tenant REST API with JWT auth, bcrypt passwords, per-user channel limits
- XMLTV EPG generation and M3U playlist feed
- Background scheduler for time-based playout
- Time-aware restart: seeks to correct playlist position after restart
- Seamless multi-file stitching via global PTS offset tracking
- Embedded SPA web UI with live stats dashboard