Check what your HLS/DASH segments actually contain — not just what the manifest claims.
Why this exists
Every HLS/DASH checker reads the manifest. The interesting failures are the ones where the claims and the media disagree — and none of them can be answered without downloading the bytes.
| The manifest says | segcheck reads the segments and answers |
|---|---|
#EXTINF:6.000 | Is the media really 6.000s, or 5.184s and drifting? |
RESOLUTION=1920x1080 | What does the H.264 or HEVC parameter set / the sample entry actually code? |
| Segment 41 follows segment 40 | Does 41's first timestamp equal 40's last one, or is there a 512ms hole? |
BANDWIDTH=2400000 | What is the measured peak segment bitrate? |
| Four renditions | Do their segment boundaries land on the same timeline, so ABR switching is seamless? |
<S t="360000" d="180000"/> | Does the fragment's tfdt agree with the @t the MPD promised? |
segcheck is one static binary that runs from your laptop, a cron job, or CI.What it checks
Every renderer sorts by severity, so the first line is the thing an operator has to look at. ERROR means the check could not run — a hole in the coverage, not a broken stream.
| Check | What it compares | Worst status |
|---|---|---|
manifest | The manifest parses, and what shape it is | BAD |
fetch | Every sampled segment is reachable, and none was truncated by the byte cap | ERROR |
init | The EXT-X-MAP / DASH initialisation segment is available | ERROR |
container | The bytes are the media they claim to be — an origin error page served with a 200 lands here | BAD |
continuity | Each segment starts where the previous one ended; MPEG-TS continuity-counter breaks (packet loss) | BAD |
duration | Declared EXTINF / @d against the real media duration, per segment and accumulated; TARGETDURATION compliance | BAD |
discontinuity | EXT-X-DISCONTINUITY against the timeline reset it promises — a tag with nothing behind it is a decoder flush nobody asked for — and EXT-X-DISCONTINUITY-SEQUENCE across the ladder | BAD |
timeline | A DASH SegmentTimeline @t against the fragment's tfdt | BAD |
bitrate | Measured peak and average against the declared BANDWIDTH, in both directions | WARN |
resolution | The coded resolution in the bitstream against the declared RESOLUTION | BAD |
keyframe | Every segment carries a random access point — an IDR, an HEVC IRAP, an fMP4 sync sample — so it can be switched into at all | BAD |
framerate | The measured frame rate against the declared FRAME-RATE / @frameRate, and rungs whose rate is unrelated to the rest of the ladder | WARN |
audio | The sampling rate, channel layout and codec the media actually carries against CHANNELS / @audioSamplingRate / AudioChannelConfiguration / CODECS, and any of them changing part-way through a rendition | BAD |
captions | CEA-608/708 caption data actually in the video bitstream — an SEI message or a CMAF c608/c708 track — against CLOSED-CAPTIONS / DASH Accessibility | BAD |
adbreak | SCTE-35 splice points in the media — a TS signalling PID or an emsg — against EXT-X-DATERANGE/EXT-X-CUE-OUT/DASH EventStream, and whether either lands on a segment boundary at all | BAD |
subtitles | WebVTT and TTML segments actually parse, and their cue times overlap the segment the manifest put them in — the X-TIMESTAMP-MAP drift no manifest checker can see | BAD |
tracks | Expected video/audio present, codecs match CODECS, track layout stable across segments | BAD |
alignment | Segment boundaries across renditions, so ABR switching does not glitch | BAD |
encryption | Declared protection against what the segments carry, whether a supplied key actually decrypts them, and — for SAMPLE-AES and CENC, which protect the samples and not the container — which half of the tool could run at all | BAD |
pop | With --pop, the same segment URLs fetched through several CDN edges and compared byte for byte — a stale edge plays perfectly and plays the wrong content | BAD |
cache | Whether the CDN is actually caching: X-Cache, CF-Cache-Status, Age and their vendor spellings per segment, a live edge served entirely from the origin, and segments the origin tells caches not to store | BAD |
byterange | Whether the origin honours an HTTP Range request, measured once per host rather than once per segment: fatal to a stream addressed in byte ranges, free to one that is not, and a warning either way when Accept-Ranges: bytes claims support the origin does not have | BAD |
ladder | Duplicate rungs, inverted rungs, dangling AUDIO groups, missing CODECS | BAD |
codecstring | The whole CODECS string, not just its first component: video profile, level and tier against avcC/hvcC/av1C/vpcC or the parameter set, and the audio object type against the esds configuration — reported in both directions, because declaring below the media hides a rung and declaring above it hides viewers | BAD |
videorange | VIDEO-RANGE=SDR|HLG|PQ and DASH's CICP transfer descriptor against the transfer characteristic the media really codes — the difference between an HDR delivery and one the audience sees two different ways | BAD |
clear | Whether protected media is actually encrypted, sample by sample (saiz), and how long its clear lead really is — the defect nobody notices until a rights-holder audit | BAD |
scheme | The common encryption scheme the media really uses — schm, cross-checked against the tenc crypt pattern — against the one the manifest declares, and a ladder that mixes schemes | BAD |
drm | The DRM systems the initialisation segment's pssh boxes advertise against the ones the manifest promises (ContentProtection, KEYFORMAT) — the difference between a stream that plays on Chrome and one that also plays on Xbox | BAD |
iframe | EXT-X-I-FRAME-STREAM-INF trick-play ranges fetched and read: each must resolve to a keyframe and to nothing else, and the rung must sit on the same timeline as the video it belongs to | BAD |
profile | With --profile apple, the measurable subset of Apple's HLS Authoring Specification: peak-to-average bit rate, consistent segment durations, an IDR at every segment start, average bit rate against the tier the resolution implies, and a frame rate constant within a rung and shared across the ladder. Every finding names its rule and puts the measured value beside the limit | WARN |
dvr | The oldest segment the DVR window still promises — DASH timeShiftBufferDepth, or an HLS playlist's own span — is fetched and parsed, and when it is not there, four more probes bisect the window for how much of it the origin really holds. It is the only promise nobody collects on purpose | BAD |
availability | A dynamic MPD's live edge is computed, not listed: the UTCTiming source it names is honoured, the skew against this machine's clock is reported, and the computed edge is probed against what the origin actually has in both directions | BAD |
pdt | EXT-X-PROGRAM-DATE-TIME against the media: that it never goes backwards, that it advances at the media's rate, and that every rung of the ladder maps the same media to the same wall clock | BAD |
parts | Low-latency EXT-X-PART parts fetched and compared with the segment they make up: contiguity, coverage, INDEPENDENT=YES against the real sync sample, and measured length against PART-TARGET | BAD |
watch | With --watch, what the live edge actually does: new-segment latency, a stall, a packager that stopped publishing, an edge advancing every poll and still losing ground against real time, and one that moves backwards | BAD |
period | A multi-period MPD read as one presentation: whether each Period’s media lands where @presentationTimeOffset maps it, and whether the resolution or codec changes across a boundary. A Period whose position nothing in the MPD determines is reported as a hole in the coverage, never placed at a guess | BAD |
PAT/PMT, PES presentation timestamps, continuity counters, the H.264 or HEVC parameter set for the resolution the bitstream really codes, the ATSC A/53 SEI carrying CEA-608/708 captions, and the SCTE-35 splice sections on a stream-type 0x86 PID.
moov for timescale, codec, coded size and audio layout; tfdt and trun for the timeline the fragment actually carries, and for locating the samples themselves — which is where a c608 caption track's field and a stpp track's cues live. sidx for single-file DASH, emsg for inband ad breaks, sinf/schm for the CENC scheme.
ADTS AAC and MPEG-1/2 audio, with the ID3 transportStreamTimestamp that gives an audio-only rendition a timeline to compare against.
Subtitle segments as text or wrapped in fMP4, with the cue times that have to line up with the picture — and the X-TIMESTAMP-MAP that anchors them in HLS, which DASH does not use.
Full-segment encryption decrypted with a key given by name, so the content checks run on a protected stream instead of reporting that nothing could be read.
Install
Static, CGO-free builds for Linux, macOS and Windows on amd64 and arm64.
brew install --cask allan-nava/tap/segcheck
macOS only — Homebrew on Linux does not support casks. Use Go or an archive there.
go install github.com/Allan-Nava/segcheck/cmd/segcheck@latest
docker run --rm ghcr.io/allan-nava/segcheck:latest \ check https://cdn.example/master.m3u8
linux/amd64 and linux/arm64 from one tag. The image is the static binary and a CA bundle: no shell, no package manager, non-root.
tar -xzf segcheck_0.1.1_linux_amd64.tar.gz sudo install segcheck /usr/local/bin/ segcheck version
Every release publishes checksums, an SBOM per archive and keyless cosign signatures, so what you downloaded can be verified rather than trusted. Archives on the releases page.
Usage
# The whole ladder, six segments per rendition segcheck check https://cdn.example/master.m3u8 # A live stream: sample the live edge, which is what a joining viewer gets segcheck check https://cdn.example/live.m3u8 --from edge --segments 12 # DASH is the same command segcheck check https://cdn.example/manifest.mpd # A low-latency ladder: fetch two segments' worth of EXT-X-PART parts and # check they reconstruct the segments a normal player would fetch segcheck check https://cdn.example/ll.m3u8 --parts 2 # Run Apple's HLS Authoring Specification over the measurements — opt-in, # because a conformance rule you cannot turn off is a wall of findings segcheck check https://cdn.example/master.m3u8 --profile apple # Ask two CDN edges for the same segments and compare the bytes segcheck check https://cdn.example/live.m3u8 --pop 203.0.113.7 --pop 198.51.100.4 # Watch the live edge for two minutes: a packager that stopped publishing # serves a flawless playlist, and only a second look tells the two apart segcheck check https://cdn.example/live.m3u8 --watch 2m # Keep it cheap on a big ladder: top and bottom rungs plus a spread between segcheck check https://cdn.example/master.m3u8 --renditions 3 --segments 4 # A report to paste into an incident doc segcheck check https://cdn.example/master.m3u8 --output markdown > report.md segcheck check https://cdn.example/master.m3u8 --output prometheus \ > /var/lib/node_exporter/textfile_collector/segcheck.prom # Gate CI on the result segcheck check https://cdn.example/master.m3u8 --exit-on bad
--exit-on warn|bad|error when you want a non-zero exit for CI.| Flag | What it does | Default |
|---|---|---|
--segments N | Segments to sample per rendition | 6 |
--renditions N | Video renditions to inspect, 0 = all | 0 |
--audio N | Audio renditions to inspect | 1 |
--subtitles N | Subtitle renditions to inspect | 1 |
--from MODE | Where to sample: auto|edge|start (auto = edge for live, start for VOD) | auto |
--iframes N | Trick-play rungs to inspect | 1 |
--profile NAME | Conformance rule set: none|apple|dash-if | none |
--parts N | Sampled segments whose EXT-X-PART parts are also fetched and checked | 1 |
--watch DUR | Keep re-reading the manifest for DUR and report what the live edge did | off |
--stall-tolerance N | Re-read intervals the edge may go without a new segment before that is a stall | 3 |
--duration-tolerance PCT | Allowed declared-vs-real duration drift | 5 |
--gap-tolerance MS | Allowed timeline gap or overlap between segments | 100 |
--bitrate-tolerance PCT | Allowed excess over the declared BANDWIDTH | 10 |
--timeout DUR | Per-request timeout | 15s |
--concurrency N | Simultaneous segment downloads | 6 |
--header 'K: V' | Extra request header, repeatable | — |
--max-bytes N | Cap on a single response body | 67108864 |
--pop ADDR | Also fetch every sampled segment through this edge address and compare the bytes, repeatable | none |
--insecure | Skip TLS verification | off |
--output FORMAT | text|json|markdown|prometheus|otlp|slack | text |
--no-color | Plain text even on a TTY (NO_COLOR is honoured too) | off |
--exit-on STATUS | Exit 1 when a finding reaches warn|bad|error | never |
--baseline FILE | Compare against a saved --output json run and report what changed | off |
--key-file PATH | Read the AES-128 content key from a file (16 raw bytes or 32 hex digits) | — |
--key-env NAME | Read it from an environment variable, as hex | — |
--clear-lead DUR | The unencrypted lead-in you asked your packager for, so the measured one can be checked against it | report only |
--fetch-keys | Fetch the key from the URI EXT-X-KEY states | off |
--header value your shell reads from the environment, never in a flag that lands in shell history or a CI log:segcheck check "$URL" --header "Authorization: Bearer $TOKEN"argv lands in shell history, in the process list and in
every CI log that echoes its own invocation — and unlike a password it cannot be
rotated without re-encrypting the content:segcheck check "$URL" --key-env SEGCHECK_KEY--fetch-keys is off by default for a related reason: pointing a checker at
a key server is a request to a system that logs, rate-limits and sometimes bills, and a
manifest mentioning a URL is not a reason to make it.In CI
One binary, one command, a machine-readable report. Gate a pipeline on it, or run it on a schedule and keep the JSON.
- name: Check the ladder
run: |
go install github.com/Allan-Nava/segcheck/cmd/segcheck@latest
segcheck check "$STREAM_URL" \
--renditions 3 --segments 4 \
--output markdown --exit-on bad \
>> "$GITHUB_STEP_SUMMARY"
env:
STREAM_URL: ${{ vars.STREAM_URL }}segments:
image:
name: ghcr.io/allan-nava/segcheck:latest
entrypoint: [""]
script:
- segcheck check "$STREAM_URL"
--output json --exit-on bad
> segcheck.json
artifacts:
when: always
paths: [segcheck.json]No toolchain to install: the image is the binary. entrypoint: [""] because GitLab runs its own shell — and the image has none, so the runner needs its own.
# every 15 minutes, keep the last report where an operator can find it */15 * * * * segcheck check https://cdn.example/live.m3u8 --from edge \ --segments 8 --output json > /var/log/segcheck/latest.json 2>&1
Leave --exit-on off under a scheduler: segcheck exits 0 whenever the check ran, and a Kubernetes CronJob reads a non-zero exit as a failed job — it would retry a run that worked and tell you nothing about the stream. Compose and CronJob manifests are in running-in-containers.md.
Sampling
segcheck downloads real media, so it is worth knowing what it will pull: renditions × segments segments per run, plus one initialisation segment per rendition.
6 segments, all renditions, against a five-rung 1080p ladder: roughly 100–200 MB.
A capped ladder always keeps the top and bottom rungs, then spreads evenly between them — that is where ladder defects concentrate.
The parts of the N newest sampled segments per rendition: a segment's worth of bytes each, split across many small requests. --parts 0 switches them off.
Manifests, not media: one request per selected rendition every re-read interval. The segments are downloaded once, at the start.
Requests carry segcheck/<version> so a check is distinguishable from real traffic in your access logs.
Honesty
Roadmap
Planned work lives in BACKLOG.md, the single source of truth; ROADMAP.md is generated from it. Every item has a stable SC-n id that commits and changelog entries reference.
HEVC coded resolution, keyframe alignment, frame rate, sidx/SegmentBase, AV1/VP9 and parser fuzzing.
Everything in a stream that is not the video track: audio layout and rate, CEA-608/708 captions, WebVTT and TTML subtitles, SCTE-35 ad signalling, and AES-128 so the content checks run on a protected stream at all.
The live edge and CDN behaviour, plus wallclock correctness: EXT-X-PROGRAM-DATE-TIME and the DVR window checked against the media instead of taken at their word.
Metrics and chat outputs, a config file, and the measurable subset of the Apple HLS Authoring Spec and DASH-IF IOP behind an opt-in --profile.
Content protection in depth: which DRM system the segments really carry, cenc versus cbcs, and media in the clear while the manifest says it is protected. None of it needs a key.
A FROM scratch container image on GHCR, multi-arch and signed, the Homebrew tap, and tests for the two packages that had none.