A single-process, pure-Go data plane that turns a live MPEG-DASH stream into MPEG-TS — read, in-process CENC decrypt, and mux — with zero external tools.
No ffmpeg, no mp4decrypt, no N_m3u8DL-RE, no FIFOs. One static binary (or one
imported library) does fetch → decrypt → mux and writes a TS a player can consume.
live DASH (CENC) ──► read @ live edge ──► in-proc AES-CTR decrypt ──► MPEG-TS ──► stdout / HTTP
The pieces exist separately in the Go ecosystem (an fMP4 decrypter here, a TS muxer there, an MPD parser elsewhere). What's uncommon is the live glue: staying at the live edge across manifest refreshes, and not falling over at a DAI/SCTE ad-break Period boundary — cleanly cutting and resuming when content returns.
✅ Solid:
- Live DASH read with a held live-edge cursor:
SegmentTimeline($Time$/$Number$) expansion and$Number$+@durationaddressing (live edge from wall-clock vsavailabilityStartTime),minimumUpdatePeriodrefresh, and non-fatal 404/init retry. - In-process CENC (AES-CTR /
cencscheme) decryption using only the Go standard library — you supply the content key; it does not acquire keys. - fMP4 → MPEG-TS mux (H.264 AVCC→Annex B with in-band SPS/PPS, AAC→ADTS, PTS/DTS/PCR, PAT/PMT).
- Survives DAI ad breaks: the two tracks (different timescales/PTOs) are rebased onto one monotonic 90 kHz timeline, the Period splice is marked with a TS discontinuity, and content resumes cleanly. No crash, no desync.
- Filling the ad gap with anything nice. The default (
--gap drop) simply emits nothing during the break and resumes after — a buffering player spins for the duration.--gap holdfreezes the last frame instead. A branded "slate" still is available in the library (GapSlate) but is experimental: a mid-stream SPS switch into a foreign still can stall some hardware decoders even across a TS discontinuity. See Ad breaks. - Playing the ads (ad muxing through the splice).
- DRM license acquisition (Widevine/PlayReady/FairPlay). You bring the key.
cbcs(AES-CBC) scheme, HEVC, multi-audio, subtitles. See the support matrix.
Secrets go on stdin as one JSON object, never on argv (/proc/<pid>/cmdline is
world-readable):
make build # ./dashpipe
echo '{"key":"<KID:KEY hex>"}' | ./dashpipe run \
--mpd-url <live MPD URL> \
--res 1920x1080 \
--out - # "-" = stdout (default); ":PORT" = single-client HTTP TS server- stdout = stream bytes only. stderr = NDJSON logs. Never mixed.
--gap drop|hold— content-gap behaviour at ad breaks (defaultdrop).- Exit codes:
0ok ·1fatal ·2bad args ·3upstream/manifest ·4key/decrypt ·5key-rotation boundary (re-resolve a fresh key and respawn).
Pipe it straight into a player or ffprobe to verify:
echo '{"key":"<KID:KEY>"}' | ./dashpipe run --mpd-url <url> --res 1280x720 | ffprobe -import (
"github.com/outhud/dashpipe/dash"
"github.com/outhud/dashpipe/pipeline"
)
err := pipeline.Run(ctx, pipeline.Config{
Reader: dash.Config{MPDURL: mpdURL, Width: 1280, Height: 720},
Key: contentKey, // [16]byte AES-128; you already resolved it
KIDHex: kidHex,
Output: "ts",
Gap: pipeline.GapDrop,
Stdout: w, // any io.Writer
})For streams whose key rotates, supply a KeyProvider instead of a static Key and
the pipeline fetches the new key per KID in-process (no respawn):
cfg.KeyProvider = func(ctx context.Context, kidHex string) (key [16]byte, err error) {
return myCDM.KeyFor(ctx, kidHex) // your DRM integration lives here
}The lower-level packages are usable on their own:
| Package | Purpose |
|---|---|
dash |
live-edge DASH reader → ordered Fragment stream |
cenc |
DecryptFragment(media, key, ivSize) — in-proc CENC AES-CTR |
output |
TSMuxer: fMP4 samples → MPEG-TS; single-client HTTP Serve |
mp4 |
ISO-BMFF init/fragment parse, sample extraction, AVC/AAC config |
keymat |
KID:KEY parse/validate |
pipeline |
wires reader → decrypt → mux under one context |
Server-side DAI inserts clear ad Periods into the live manifest. dashpipe is
content-only: it drops those Periods (dash.DropAds) and rebases the content
timeline so playback resumes cleanly afterwards. The gap they leave is handled by the
--gap / Gap strategy:
GapDrop(default) — emit nothing; resume on the next content sample. Proven.GapHoldLastFrame— re-send the last IDR + silent audio so the player holds a frozen picture instead of underrunning. Emits slightly ahead of wall-clock so a buffering player (ExoPlayer/TiviMate) builds a buffer and leaves the spinner.GapSlate— replay a caller-supplied Annex B still. Experimental. The still carries its own SPS; some hardware decoders silently stall on a mid-stream SPS switch even with a TS discontinuity at the seam. We ship the code path and the discontinuity bracketing, but make no promises that a given player/decoder accepts it. If you need reliable in-break visuals, do it upstream of this tool.
| Capability | Status |
|---|---|
DASH live (type=dynamic), SegmentTimeline |
✅ |
DASH live (type=dynamic), $Number$+@duration (no timeline) |
✅ |
$Time$ and $Number$ addressing |
✅ |
CENC cenc scheme (AES-CTR) |
✅ |
CENC cbcs scheme (AES-CBC) |
❌ |
| H.264 / AVC video | ✅ |
| HEVC / AV1 video | ❌ |
| AAC-LC audio → ADTS | ✅ |
| Hold-last-frame silent audio | ✅ stereo 48 kHz AAC-LC only |
| DAI ad-break cut + resume | ✅ |
| Ad muxing / slate playback | ❌ / experimental |
| DRM license acquisition | ❌ (bring your own key) |
Requires Go ≥ 1.22.
make build # ./dashpipe (dev)
make static # CGO_ENABLED=0 static linux/amd64 binary
make test # go test ./...
make vetmake test runs everything; tests that need captured fixtures skip cleanly when the
fixtures are absent (see testdata/). The CENC decrypt path is covered by
a self-contained synthetic round-trip test that needs no fixtures.
Apache-2.0. See LICENSE. This project does not include or link any DRM client; key acquisition is the integrator's responsibility and may carry its own legal and licensing constraints in your jurisdiction.