# Changelog
All notable changes to `hls-runtime` (formerly `ll-hls-runtime`) are
documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.5.0] - 2026-08-05
### Changed
- Requires `transmux` 0.23 (epoch-pure caret bump from ^0.22).
- Requires `media-plane` 0.3 (epoch-pure caret bump from ^0.2).
## [0.4.0] - 2026-08-02
### Changed
- `HlsOrigin`'s `_HLS_msn` abuse bound tightened from +4 to the spec's +2
(RFC 8216bis §6.2.5.2 SHOULD: "greater than the Media Sequence Number of
the last Media Segment in the current Playlist plus two").
### Added
- `server::Container` (`Fmp4` / `MpegTs`, `#[non_exhaustive]`) and
`HlsOrigin::builder(trunk)` -> `HlsOriginBuilder` (issue #873, closes #865):
`HlsOrigin`'s origin can now serve classic HLS-of-TS (`.ts` segments, no
`#EXT-X-MAP` by default) as well as fMP4, and container is orthogonal to
low latency — `.container(Container::MpegTs)` and `.low_latency(part_target_ms)`
are independent builder calls, so classic HLS (no low-latency tags at all)
is finally expressible. `HlsOriginBuildError` (`#[non_exhaustive]`,
`thiserror`) surfaces a missing `target_duration_secs`/`window_segments` as
an error rather than a silent default.
- RFC 8216bis §3.1.1's `EXT-X-MAP` rule for MPEG-2 TS is a disjunction
(PAT+PMT in-band, *or* `EXT-X-MAP`), not a container restriction —
`Container::MpegTs` omits the tag by default (matching `transmux`'s
self-initialising TS segments) without forbidding it structurally.
- `HlsOrigin::set_init` stays callable under `MpegTs` — a documented no-op
(bytes stored, never advertised or served), so a caller sharing one code
path across both containers need not branch.
### Changed (Breaking)
- **`HlsOrigin::new` deleted**, replaced by `HlsOrigin::builder(trunk)`
(issue #873): the old four-positional constructor made `part_target_ms`
mandatory, so classic (non-low-latency) HLS was inexpressible. No
compatibility shim — this crate is at 0.4.0, unpublished at the time of
this change. `multimux`'s `HlsOrigin::new` call site is updated to the
builder in the same release.
### Fixed
- The LL-HLS origin (`server::engine`) no longer supplies a hardcoded
`EXT-X-VERSION:9` (`LL_HLS_VERSION`, deleted). `broadcast_hls::MediaPlaylist::to_m3u8`
now computes the version from the content actually emitted (RFC 8216bis
§8) — the true minimum for this origin's fMP4 playlist is 6, since none
of the LL-HLS directives it renders (`EXT-X-PART`/`EXT-X-PART-INF`/
`EXT-X-PRELOAD-HINT`/`EXT-X-SERVER-CONTROL`) carry any version requirement
at all. The old hardcoded 9 over-declared and would have caused every
client on protocol version 6, 7, or 8 to refuse a stream it could
otherwise have played (issue #871).
### Changed
- The client/origin engines parse and render HLS playlists via `broadcast-hls`
directly instead of reaching through `transmux::hls` for it (issue #878).
`Error::PlaylistParse` now wraps `broadcast_hls::Error` instead of
`transmux::Error` — a source-type change to a `#[from]`-wrapped variant,
not a shape change to `Error` itself. Playlist docs moved from this
crate's `docs/` to `broadcast-hls/docs/`, since they document the tags
that crate now implements.
## [0.4.0] - 2026-08-01
### Changed (Breaking)
- **Crate renamed** `ll-hls-runtime` -> `hls-runtime` (issue #868): the
crate is gaining a non-low-latency mode, so the `ll-` name became wrong.
A clean break — no deprecated alias, no re-export shim. `ll-hls-runtime`'s
already-published versions stay live on crates.io, untouched.
- Public types dropped their `Ll` prefix: `LlHlsOrigin` -> `HlsOrigin`,
`LlHlsRequest` -> `HlsRequest`, `LlHlsBody` -> `HlsBody`, `LlHlsClient` ->
`HlsClient`. No other behaviour change.
## [0.3.1] - 2026-07-30
### Fixed
- Floor `media-plane` to `0.1.1`. The `^0.1` bucket also contains 0.1.0,
which is built against `transmux` 0.20, so a consumer could resolve two
`transmux` minors into one graph and hit trait-resolution errors pointing
at this crate's internals (#858).
## [0.3.0] - 2026-07-30
### Changed (Breaking)
- `LlHlsRequest`, `LlHlsBody` (`server::engine`) now carry
`#[non_exhaustive]` (issue #806's non_exhaustive drift-guard audit). A
downstream `match` on either of these now needs a wildcard arm.
- The client's part-prefetch now ignores (rather than exhaustively assumes
only `PART`/`MAP`) a future `PreloadHintType` variant from a newer
`transmux`, consistent with that enum's own new `#[non_exhaustive]`.
### Added
- `tests/non_exhaustive_coverage.rs` drift guard (issue #806).
## [0.2.0] - 2026-07-28
### Fixed
- `server::engine`'s own test helpers and the `client_stepping`/
`origin_playlist` examples called `Trunk::writer()` (the samples+events
writer) instead of `Trunk::segment_writer()` to publish segments/parts — a
stale call from before the `SegmentWriter` split that never actually
compiled since (found via multimux plan step 5b's first successful
`cargo test --workspace`/`--all-targets` across the whole workspace since
then). No behaviour change to any shipped API — test/example-only.
- **Cleared this crate's share of the latest-stable clippy canary** (issue
#770 — the non-blocking `clippy (latest stable)` CI job, which had been
failing on `main` unnoticed across many merges): the `golden_gate`
integration test builds its single-track init segment with
`std::slice::from_ref(&spec)` instead of `&[spec.clone()]`
(`clippy::cloned_ref_to_slice_refs`). Test-only, behaviour-preserving.
### Changed
- **BREAKING: `server::MediaStore`/`HealthState`/`SegmentWindowEntry`/
`PlaylistOutcome`/`ResourceOutcome` are gone.** Media-plane implementation
plan step 4: the LL-HLS origin is now [`server::LlHlsOrigin`], a
`media_plane::egress::ServedEgress` that renders playlists and resolves
blocking-reload/part-availability requests directly from a shared
`media_plane::Trunk`, instead of a second, push-fed rolling-window store
that duplicated exactly what the `Trunk` now holds. `server/store.rs`
(732 lines of `MediaStore`) is deleted outright — live parts, whether a
segment has closed, and the just-closed-segment-final-part-still-serves
guarantee (multimux 0.2.1/0.2.2's hard-won bug fixes) all now fall out of
the `Trunk`'s own live-part log with no cache of any kind on this crate's
side. The one thing that genuinely cannot come from a `&Trunk` call alone —
the rolling window of currently-advertised *closed* segments (bytes,
duration, discontinuity bit), the lifetime-max segment duration, and the
cumulative `#EXT-X-DISCONTINUITY-SEQUENCE` count — is assembled by
`LlHlsOrigin` draining exactly **one** `Trunk` segment cursor, per this
crate's own `media_plane::egress` module doc ("a `ServedEgress`
implementation... keeps its own resolvable window in sync by draining
[cursors]"), not a second `MediaStore`. See `docs/superpowers/plans/
2026-07-26-media-plane-implementation.md` Step 4.
- The engine-level `BlockingQuery`/`DEFAULT_TRACK_ID`/`master_playlist_m3u8`
are unchanged; the local `CachePolicy` duplicate is gone in favour of
`media_plane::egress::CachePolicy`, re-exported via `LlHlsOrigin`'s
`EgressResponse`.
- Dropped the `event-listener` direct dependency (now reached transitively
through `media-plane`, whose `Trunk::listen` supersedes `MediaStore::listen`)
and added `media-plane`/`bytes` (both gated by this crate's `std` feature,
same as `event-listener` was).
### Removed
- **`multimux` no longer builds against this crate.** `multimux::store`
re-exported `server::MediaStore`/`HealthState` directly, and
`multimux::output::{llhls,dash,ll_dash}`/`origin::resource` called the
deleted `resolve_playlist`/`resolve_resource`/`media_playlist_m3u8(&MediaStore,
_)` shapes — none of that is a cheap adapter-level fix (it is `multimux`'s
full `ServedEgress` port, scoped to plan Step 5, which this step
deliberately does not half-port). `ll-hls-runtime`'s own `tests/
golden_gate.rs`/`tests/glass_to_glass.rs` (both `tokio`-feature-gated,
both depending on `multimux` as a dev-dependency) do not build until
Step 5 lands.
## [0.1.1] - 2026-07-26
### Added
- **`client::LlHlsClient` now ingests classic MPEG-TS-segment HLS** (issue
#760): a Media Playlist that never advertises an `EXT-X-MAP` (HLS v3, RFC
8216 — the dominant legacy/IPTV form: self-contained `.ts` segments
carrying their own PAT/PMT/PES, no separate init resource) routes each
fetched Part/Segment through `transmux::TsDemux` instead of the fMP4/CMAF
`transmux::Fmp4Demux` path, content-sniffed by the MPEG-TS sync byte
(`0x47`) once the playlist itself is known to carry no map. The first
successfully demuxed segment's recovered `TrackSpec`s synthesize the one
`Output::Init` the crate's output contract requires (via
`transmux::build_init_segment`), so downstream callers built against the
fMP4 path (e.g. `multimux`'s `HlsPull`) need no TS-specific handling of
their own. The fMP4/CMAF + LL (parts/preload-hint) path is entirely
unchanged; the two never overlap for a single playlist.
## [0.1.0] - 2026-07-21
### Fixed
- **`MediaStore::window_segments`/`last_closed_segment_seq` no longer use a
bare `.lock().unwrap()`.** Pre-release audit finding: these two lock sites
(the bare-`_HLS_msn` reload path and the DASH `SegmentTemplate` window
accessor) panicked on a poisoned `Mutex`, unlike the store's other 11 lock
sites, which already tolerate poisoning via
`.unwrap_or_else(std::sync::PoisonError::into_inner)`. Both now follow the
same poison-tolerant pattern, so one panicking holder of the lock can no
longer cascade into panics on ordinary requests.
- **`client::LlHlsClient::on_resource` now actually enforces
`Error::UnrequestedResource`.** Pre-release audit finding: the variant's
docs already claimed a `ResourceId` the client never requested was
rejected, but `on_resource` never checked — any bytes for any id (a
caller/driver bug, or a stale/duplicate delivery) were silently accepted.
Now checked against the client's internal `requested` bookkeeping (`Init`
against whether an init fetch is outstanding/cached), returning
`Error::UnrequestedResource` instead.
### Changed
- **`server::master_playlist_m3u8` now takes a `media_playlist_name: &str`
argument** (issue #663 "shared output auth + configurable playlist_name"):
the master playlist's `#EXT-X-STREAM-INF` reference is the caller's
configured media-playlist filename instead of the hardcoded `"media.m3u8"`
literal, so a server (e.g. multimux's `Config::playlist_name`) can serve
its media playlist under any `*.m3u8` name. Breaking: pass the intended
filename explicitly (`master_playlist_m3u8("media.m3u8")` reproduces the
old behaviour).
- **`client::tokio_client::TokioClient` now authenticates via `broadcast-auth`**
(issue #663 P3c): `TokioClientConfig::auth` takes a
`broadcast_auth::Credentials` (Basic/Digest/Bearer) instead of the ad hoc
`Auth` enum (Basic/Bearer only) — fulfilling the TODO the field's doc
comment carried since P3a. Basic/Bearer are still pre-applied on every
request via reqwest's own helpers; Digest now works end-to-end: on a `401`,
`TokioClient` reads `WWW-Authenticate`, computes the response via
`broadcast_auth::Authenticator`, resends once, and caches the resulting
authenticator (applied preemptively, advancing `nc`, on later requests).
New `TokioError::Auth` variant for a challenge/response failure. Breaking:
`tokio_client::Auth` is removed; construct `broadcast_auth::Credentials`
instead (added as a `tokio`-feature-gated optional dependency).
### Added
#### Server (issue #663/#717 Stage 2)
- **`server` — the sans-IO LL-HLS origin engine** (Stage 2 of the
ll-hls-runtime unification, issue #663/#717 —
`docs/superpowers/specs/2026-07-18-multimux-hub-design.md`, "ll-hls-runtime
— client + server in one crate"), moved out of `multimux` behind the new
`std` feature (needs `std::sync::Mutex`, unlike the no_std-capable
`client`):
- **`server::MediaStore`** — the protocol-neutral rolling in-RAM window
(init/segments/live parts/`recent_parts`/health/max-segment-duration),
moved verbatim from `multimux::store::MediaStore` **including the
part-404-boundary fix** (`recent_parts`, so an in-flight preload-hint
request for a segment's final part still resolves after the segment
closes). The `tokio::sync::watch<u64>` progress signal is replaced with a
runtime-agnostic wakeup: `MediaStore::progress_version()` (a monotonic
counter) and `MediaStore::listen()` (an `event_listener::EventListener` —
a plain `Future<Output = ()>` any executor, or none via its blocking
`.wait()`, can drive), via the new `event-listener` dependency.
- **`server::MediaStore::resolve_playlist`/`resolve_resource`** — the
blocking-reload (RFC 8216bis §6.2.5.2) and part-availability decision
logic as synchronous poll methods returning `PlaylistOutcome`
(`Ready`/`WouldBlock`/`BadRequest`) / `ResourceOutcome`
(`Ready`/`WouldBlock`/`NotFound`) — never blocking, never touching a
clock. An async adapter (e.g. `multimux`) turns `WouldBlock` into an
actual bounded wait via `MediaStore::listen()` + its own
`tokio::time::timeout`; see the `server` module docs for the exact
caller-driven wait-loop shape.
- **`server::media_playlist_m3u8`/`master_playlist_m3u8`** — the LL-HLS
playlist renderers, moved verbatim from
`multimux::output::llhls::media_playlist_m3u8` **including the
reentrant-lock deadlock fix** (`max_segment_duration()`/
`target_duration_secs()` read *before* `MediaStore::with_segments_and_parts`'s
lock).
- **`server::CachePolicy`** (`Immutable`/`NoCache`) — the cache-control
policy a resolved `ResourceOutcome::Ready` carries, for an adapter to
apply as HTTP `Cache-Control`.
### Changed
- **Renamed `ll-hls-client` → `ll-hls-runtime`** (Stage 1 of the ll-hls-runtime
unification; never published, so a free rename — no `0.1.0` behaviour
change). The client engine moved under a `client` module
(`ll_hls_runtime::client::LlHlsClient` etc., mirroring `rtsp-runtime`'s
client+server split); an empty `server` module is reserved for the LL-HLS
origin engine currently in `multimux`, to be folded in as Stage 2.
#### Client (issue #717)
- **`LlHlsClient` — sans-IO Low-Latency HLS playback client engine** (issue
#717, slices 2-4). A caller-driven state machine in the same sans-IO shape
as `srt-runtime` (#565): `poll()`/`next_output()` drain queued `Action`s /
`Output`s; `on_playlist()`/`on_resource()`/`on_error()` feed responses back
in. No socket, no clock, no `tokio`/`reqwest` dependency in the core.
- **Reload scheduler** (slice 2): Blocking Playlist Reload
(`_HLS_msn`/`_HLS_part`, RFC 8216bis §6.2.5.2) once a playlist advertises
Low-Latency support, correctly distinguishing a bare `_HLS_msn` (waits for
a closed segment) from `_HLS_part=0`; non-blocking-reload backoff derived
from `#EXT-X-TARGETDURATION` for non-LL origins; best-effort `EXT-X-SKIP`/
`CAN-SKIP-UNTIL` Playlist Delta Update merge.
- **Fetch pipeline** (slice 3): `EXT-X-PRELOAD-HINT` part prefetch ahead of
its own numbered appearance; `BYTERANGE` part/segment/map support
(including the "omitted offset continues the previous sub-range" rule);
the init segment (`EXT-X-MAP`) fetched once.
- **Output adapter** (slice 4): ordered `Output::Init` then `Output::Samples`
(real access units via `transmux::Fmp4Demux`, not opaque container bytes);
`EXT-X-DISCONTINUITY` forwarded as `Output::Discontinuity`; parts already
individually fetched are never double-counted when their parent segment
later closes (dedup/coalescing); a non-LL playlist (no parts at all) plays
via the full-segment fallback path; resources arriving before the init
segment are buffered and replayed once it arrives.
- Reuses `transmux::hls::MediaPlaylist::parse` (issue #717 slice 1) for the
playlist model — this crate defines no playlist types of its own.
- `tests/origin_loop.rs`: an in-process origin↔client loop against a real
`transmux::ll_hls::LlHlsSegmenter`, asserting the exact blocking-reload
`_HLS_msn`/`_HLS_part` requested, the preload-hint prefetch actually
issued, ordered/deduped/byte-identical sample reconstruction, and the
non-LL full-segment fallback path.
- `CAN-BLOCK-RELOAD` (issue #717 slice 1 follow-up, fixed alongside slice
5): reload scheduling now honours `transmux::hls::LowLatencyConfig::can_block_reload`
rather than inferring blocking-reload support from `low_latency` being
`Some` — an origin advertising `CAN-BLOCK-RELOAD=NO` (while still
carrying `PART-INF`/`PART` tags) now correctly gets a plain, non-blocking
reload paced by `Action::WaitMs`, never a blocking `_HLS_msn`/`_HLS_part`
request. Covered by `tests/origin_loop.rs`'s
`can_block_reload_no_yields_non_blocking_reload_with_backoff`.
- **`TokioClient` — tokio + reqwest (rustls) IO adapter** (issue #717 slice
5), behind a new, non-default `tokio` cargo feature. Drives `LlHlsClient`
over real HTTP: performs the blocking `_HLS_msn`/`_HLS_part` reload and
plain playlist GETs, resource fetches (including `Range` byte-ranges),
retries resource fetches with capped backoff before falling back to
`on_error` (letting the next reload naturally re-request them), and
retries a failing playlist reload indefinitely with capped backoff (the
sans-IO core has no other recovery path for a playlist fetch failure).
Optional HTTP Basic/Bearer auth via `TokioClientConfig::auth`, with a
documented TODO to swap in the workspace's planned shared multi-scheme
auth crate once it exists. Exposes `TokioClientStats` (playlist fetches,
blocking reloads, resource fetches, preload-hint-triggered resource
fetches) so blocking-reload/prefetch behaviour is externally observable,
not just internally exercised. The sans-IO core (`client.rs`) gained no
new dependency from this — `tokio`/`reqwest` are entirely behind the new
feature.
- `tests/glass_to_glass.rs` (gated on the `tokio` feature; epic #717's
done-bar): drives `TokioClient` against a **real** `multimux`-served
LL-HLS origin over real loopback HTTP, fed by a real-time-paced
`transmux::ll_hls::LlHlsSegmenter` producer (live-shaped, ~30fps/120ms
parts) — measures glass-to-glass latency (wall-clock push-to-emit,
embedded per-sample) and asserts it is **sub-second**, asserts at least
one Blocking Playlist Reload and one preload-hint-triggered resource
fetch actually occurred (`TokioClientStats`), and asserts a genuinely
non-LL playlist (no `PART` tags, served from a minimal hand-built axum
origin) still plays via the full-segment fallback with zero blocking
reloads.
- `tests/golden_gate.rs` (gated on the `tokio` feature, `ffprobe`-gated,
non-blocking CI lane — closes issue #717's last acceptance box,
"Integrated into #569's golden-gate harness as the reference client"):
`TokioClient` is now the **reference client** in the #569 player-validated
golden gate. `transmux/tests/golden_gate.rs` (#569) validates only the
origin half — transmux's own muxer output handed to an independent
decoder (`ffprobe`). This closes the other half: demuxes the workspace's
real `fixtures/ts/h264_aac.ts` capture (Main profile, 320x240, 25fps, 75
real video frames) via `TsDemux`, live-paces those real samples through
the same `LlHlsSegmenter`/`MediaStore`/`LlHlsOutput` origin stack
`glass_to_glass.rs` uses, drives a real `TokioClient` against it over
loopback HTTP, then muxes the **client's own** reconstructed init +
samples (not the origin's) into a real fMP4 and hands that to `ffprobe`:
asserts it decodes as H.264 at the source's resolution, and that
`ffprobe -count_frames`'s own decoded frame count exactly matches the
frames fed in — catching a drop/duplicate/reorder that corrupts the
bitstream even when the container alone still looks well-formed. Also
covers the non-LL/full-segment fallback path decoding correctly, and a
self-test (`dropped_sample_changes_the_decoded_frame_count`) proving the
frame-count oracle isn't vacuous. `.github/workflows/ci.yml`'s existing
non-blocking `golden-gate` job now also runs this suite alongside
transmux's.