segcheck
segcheck logo

segcheck

Check what your HLS/DASH segments actually contain — not just what the manifest claims.

dependencies 0 one static Go binary HLS + DASH MPEG-TS · fMP4/CMAF · ADTS no ffmpeg, no cgo
segcheck check https://cdn.example/master.m3u8
🔴 BAD continuity 1080p seg 412 gap of +512ms: previous segment ends at 824.512s, this one starts at 825.024s, with no EXT-X-DISCONTINUITY ↳ the player has nothing to show for this interval: expect a stall or a skip 🔴 BAD resolution 1080p manifest declares 1920x1080, the bitstream codes 1280x720 ↳ the rendition is not the resolution the ladder promises 🟡 WARN bitrate 720p seg 38 segment peaks at 3.10 Mbps but BANDWIDTH declares 2.40 Mbps (+29%) ↳ BANDWIDTH must be an upper bound: under-declaring makes players choose a rendition their connection cannot sustain 🟢 OK alignment ladder renditions aligned at 6 shared segment indexes (tolerance 100ms) 22 checks: 18 OK, 1 WARN, 3 BAD, 0 ERROR — 18 segments, 24.5 MiB in 4.1s

Why this exists

A manifest is a set of claims

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 sayssegcheck reads the segments and answers
#EXTINF:6.000Is the media really 6.000s, or 5.184s and drifting?
RESOLUTION=1920x1080What does the H.264 or HEVC parameter set / the sample entry actually code?
Segment 41 follows segment 40Does 41's first timestamp equal 40's last one, or is there a 512ms hole?
BANDWIDTH=2400000What is the measured peak segment bitrate?
Four renditionsDo 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?
No ffmpeg, no ffprobe, no cgo, no dependencies. The MPEG-TS, ISO-BMFF/CMAF, H.264 and HEVC parameter set and ADTS parsers are all in-tree, standard library only. segcheck is one static binary that runs from your laptop, a cron job, or CI.

What it checks

Thirteen checks, worst findings first

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.

CheckWhat it comparesWorst status
manifestThe manifest parses, and what shape it isBAD
fetchEvery sampled segment is reachable, and none was truncated by the byte capERROR
initThe EXT-X-MAP / DASH initialisation segment is availableERROR
containerThe bytes are the media they claim to be — an origin error page served with a 200 lands hereBAD
continuityEach segment starts where the previous one ended; MPEG-TS continuity-counter breaks (packet loss)BAD
durationDeclared EXTINF / @d against the real media duration, per segment and accumulated; TARGETDURATION complianceBAD
discontinuityEXT-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 ladderBAD
timelineA DASH SegmentTimeline @t against the fragment's tfdtBAD
bitrateMeasured peak and average against the declared BANDWIDTH, in both directionsWARN
resolutionThe coded resolution in the bitstream against the declared RESOLUTIONBAD
keyframeEvery segment carries a random access point — an IDR, an HEVC IRAP, an fMP4 sync sample — so it can be switched into at allBAD
framerateThe measured frame rate against the declared FRAME-RATE / @frameRate, and rungs whose rate is unrelated to the rest of the ladderWARN
audioThe sampling rate, channel layout and codec the media actually carries against CHANNELS / @audioSamplingRate / AudioChannelConfiguration / CODECS, and any of them changing part-way through a renditionBAD
captionsCEA-608/708 caption data actually in the video bitstream — an SEI message or a CMAF c608/c708 track — against CLOSED-CAPTIONS / DASH AccessibilityBAD
adbreakSCTE-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 allBAD
subtitlesWebVTT 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 seeBAD
tracksExpected video/audio present, codecs match CODECS, track layout stable across segmentsBAD
alignmentSegment boundaries across renditions, so ABR switching does not glitchBAD
encryptionDeclared 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 allBAD
popWith --pop, the same segment URLs fetched through several CDN edges and compared byte for byte — a stale edge plays perfectly and plays the wrong contentBAD
cacheWhether 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 storeBAD
byterangeWhether 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 haveBAD
ladderDuplicate rungs, inverted rungs, dangling AUDIO groups, missing CODECSBAD
codecstringThe 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 viewersBAD
videorangeVIDEO-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 waysBAD
clearWhether 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 auditBAD
schemeThe 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 schemesBAD
drmThe 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 XboxBAD
iframeEXT-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 toBAD
profileWith --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 limitWARN
dvrThe 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 purposeBAD
availabilityA 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 directionsBAD
pdtEXT-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 clockBAD
partsLow-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-TARGETBAD
watchWith --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 backwardsBAD
periodA 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 guessBAD

Containers understood

MPEG-TS

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.

fMP4 / CMAF

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.

Packed audio

ADTS AAC and MPEG-1/2 audio, with the ID3 transportStreamTimestamp that gives an audio-only rendition a timeline to compare against.

WebVTT & TTML

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.

AES-128

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

One binary, four ways

Static, CGO-free builds for Linux, macOS and Windows on amd64 and arm64.

Homebrew

brew install --cask allan-nava/tap/segcheck

macOS only — Homebrew on Linux does not support casks. Use Go or an archive there.

Go

go install github.com/Allan-Nava/segcheck/cmd/segcheck@latest

Docker

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.

Release archive

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

Point it at a master playlist or an MPD

# 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 status is 0 whenever the check ran, findings or not — a check that ran is a success. Use --exit-on warn|bad|error when you want a non-zero exit for CI.

Flags

FlagWhat it doesDefault
--segments NSegments to sample per rendition6
--renditions NVideo renditions to inspect, 0 = all0
--audio NAudio renditions to inspect1
--subtitles NSubtitle renditions to inspect1
--from MODEWhere to sample: auto|edge|start (auto = edge for live, start for VOD)auto
--iframes NTrick-play rungs to inspect1
--profile NAMEConformance rule set: none|apple|dash-ifnone
--parts NSampled segments whose EXT-X-PART parts are also fetched and checked1
--watch DURKeep re-reading the manifest for DUR and report what the live edge didoff
--stall-tolerance NRe-read intervals the edge may go without a new segment before that is a stall3
--duration-tolerance PCTAllowed declared-vs-real duration drift5
--gap-tolerance MSAllowed timeline gap or overlap between segments100
--bitrate-tolerance PCTAllowed excess over the declared BANDWIDTH10
--timeout DURPer-request timeout15s
--concurrency NSimultaneous segment downloads6
--header 'K: V'Extra request header, repeatable
--max-bytes NCap on a single response body67108864
--pop ADDRAlso fetch every sampled segment through this edge address and compare the bytes, repeatablenone
--insecureSkip TLS verificationoff
--output FORMATtext|json|markdown|prometheus|otlp|slacktext
--no-colorPlain text even on a TTY (NO_COLOR is honoured too)off
--exit-on STATUSExit 1 when a finding reaches warn|bad|errornever
--baseline FILECompare against a saved --output json run and report what changedoff
--key-file PATHRead the AES-128 content key from a file (16 raw bytes or 32 hex digits)
--key-env NAMERead it from an environment variable, as hex
--clear-lead DURThe unencrypted lead-in you asked your packager for, so the measured one can be checked against itreport only
--fetch-keysFetch the key from the URI EXT-X-KEY statesoff
No secrets on the command line. Credentials belong in a --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"

The same rule governs the content key, which is why it is given by name and never by value. A key in 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

A check you run, not a service

One binary, one command, a machine-readable report. Gate a pipeline on it, or run it on a schedule and keep the JSON.

GitHub Actions

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

GitLab CI

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.

On a schedule

# 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

What a run costs you

segcheck downloads real media, so it is worth knowing what it will pull: renditions × segments segments per run, plus one initialisation segment per rendition.

defaults

6 segments, all renditions, against a five-rung 1080p ladder: roughly 100–200 MB.

--renditions 3

A capped ladder always keeps the top and bottom rungs, then spreads evenly between them — that is where ladder defects concentrate.

--parts N

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.

--watch 2m

Manifests, not media: one request per selected rendition every re-read interval. The segments are downloaded once, at the start.

User-Agent

Requests carry segcheck/<version> so a check is distinguishable from real traffic in your access logs.

Honesty

When not to use it

A limit of this tool is never reported as a defect in your stream. An unsupported container, an encrypted segment or a representation that cannot be expanded produces an honest ERROR saying segcheck could not look — never a BAD that sends someone hunting a phantom.

Roadmap

What is next

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.

v0.2.0shipped

HEVC coded resolution, keyframe alignment, frame rate, sidx/SegmentBase, AV1/VP9 and parser fuzzing.

v0.3.0in flight

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.

v0.4.0

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.

v0.5.0

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.

v0.6.0

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.

v0.1.1shipped

A FROM scratch container image on GHCR, multi-arch and signed, the Homebrew tap, and tests for the two packages that had none.