feat(hls): pre-segmentación delantada — 2 s segments + async session start (0.9.10)
First-frame latency drops by another 1-2 s on cold-cache plays: 1. HLS segment duration halved from 4 s to 2 s. seg-0 lands in ~half the wait time — the player paints the first frame as soon as it arrives. Software encodes on 4K go from ~3 s wait to ~1.5 s; HW encoders shave ~0.5 s. Trade-off: 2× segment count per source (~3600 segments for a 2 h movie instead of ~1800), but each is half the size on disk. Within HLS spec — Apple recommends 6 s, but 2 s is valid; LL-HLS uses 1-2 s. 2. Cache from 0.9.9 self-heals: cached entries used 4 s segments; VerifyComplete now expects a different highest segment index and invalidates them, triggering a re-encode on next play. No manual cleanup needed. 3. OnStreamSession daemon callback now runs StartHLSSession in a goroutine. Sync HTTP responses return immediately (~50 ms instead of waiting for the ~0.3-1 s ffprobe). Other pending actions in the same sync cycle (new tasks, deletes) no longer wait for the transcoder warmup. Browser HEAD probes already have a 30 s retry budget that covers the brief gap between playerSessionRegistry.add and streamSrv.HLS().Register. Helpers added (engine.segmentDurationFor / segmentStartSec / segmentCountForDuration) so a future short-first-segment variant or non-uniform layout can slot in without touching every call site. Internal: -hls_init_time was investigated but discarded — ffmpeg's implementation treats it as a min duration, not a target, so it couldn't deliver a uniformly 2 s first segment on top of a 4 s steady state. Uniform 2 s is simpler and gets the same first-frame win.
This commit is contained in:
parent
bf8ed0d928
commit
0b2462c82a
5 changed files with 96 additions and 27 deletions
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.9.10] - 2026-05-27
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **HLS segments halved from 4 s to 2 s**. seg-0 now lands in ~half the
|
||||||
|
cold-cache wait time, so the player paints the first frame ~1-2 s
|
||||||
|
sooner on software encodes (~0.5 s sooner on HW encoders). Trade-off:
|
||||||
|
2× more segments per source (a 2 h movie produces ~3600 segments
|
||||||
|
instead of ~1800), but each is half the size. Well within HLS spec
|
||||||
|
— Apple recommends 6 s but 2 s is also valid; LL-HLS uses 1-2 s.
|
||||||
|
Existing 0.9.9 cache entries fail `VerifyComplete` (the new segment
|
||||||
|
count expects different file names at the boundary) and are
|
||||||
|
invalidated + re-encoded transparently on next play. Self-healing,
|
||||||
|
no manual cleanup needed.
|
||||||
|
- **`OnStreamSession` daemon callback now runs `StartHLSSession` in a
|
||||||
|
goroutine** instead of blocking the sync HTTP loop on ffprobe
|
||||||
|
(~0.3-1 s typical). Net: sync responses return immediately, and any
|
||||||
|
other pending actions in the same response (new tasks, deletes)
|
||||||
|
no longer wait for ffmpeg to warm up. Browser HEAD probes already
|
||||||
|
have a 30 s retry budget that absorbs the brief window between
|
||||||
|
`playerSessionRegistry.add` and `streamSrv.HLS().Register`.
|
||||||
|
|
||||||
## [0.9.9] - 2026-05-27
|
## [0.9.9] - 2026-05-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -618,6 +640,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
[0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6
|
[0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6
|
||||||
[0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5
|
[0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5
|
||||||
[0.6.4]: https://github.com/torrentclaw/unarr/compare/v0.6.3...v0.6.4
|
[0.6.4]: https://github.com/torrentclaw/unarr/compare/v0.6.3...v0.6.4
|
||||||
|
[0.9.10]: https://github.com/torrentclaw/unarr/compare/v0.9.9...v0.9.10
|
||||||
[0.9.9]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.9
|
[0.9.9]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.9
|
||||||
[0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8
|
[0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8
|
||||||
[0.9.7]: https://github.com/torrentclaw/unarr/compare/v0.9.6...v0.9.7
|
[0.9.7]: https://github.com/torrentclaw/unarr/compare/v0.9.6...v0.9.7
|
||||||
|
|
|
||||||
|
|
@ -580,14 +580,23 @@ func runDaemonStart() error {
|
||||||
Transcode: tcRuntime,
|
Transcode: tcRuntime,
|
||||||
Cache: hlsCache,
|
Cache: hlsCache,
|
||||||
}
|
}
|
||||||
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
|
// StartHLSSession runs ffprobe (15 s cap, typical 0.3–1 s) before
|
||||||
if err != nil {
|
// returning. Doing this synchronously inside the sync handler holds
|
||||||
playerSessionRegistry.remove(sess.SessionID)
|
// the next sync HTTP cycle until ffprobe is done, so any other
|
||||||
hlsCancel()
|
// pending actions (new tasks, deletes) wait too. Hand it off so
|
||||||
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
|
// the sync loop returns immediately — browser HEAD probes already
|
||||||
return
|
// have a 30 s retry budget that absorbs the gap until
|
||||||
}
|
// `streamSrv.HLS().Register` lands.
|
||||||
streamSrv.HLS().Register(hsess)
|
go func() {
|
||||||
|
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
|
||||||
|
if err != nil {
|
||||||
|
playerSessionRegistry.remove(sess.SessionID)
|
||||||
|
hlsCancel()
|
||||||
|
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamSrv.HLS().Register(hsess)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Periodic DHT node persistence (every 5 min)
|
// Periodic DHT node persistence (every 5 min)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
||||||
var Version = "0.9.9"
|
var Version = "0.9.10"
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,46 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hlsSegmentDuration is the target seconds per HLS fragment. Four seconds is
|
// hlsSegmentDuration is the target seconds per HLS fragment.
|
||||||
// the Plex/Apple default — short enough that seek granularity is acceptable,
|
//
|
||||||
// long enough that GOP overhead doesn't dominate.
|
// We use 2 seconds (not the more common 4-6 s). Trade-off: 2× more segments
|
||||||
const hlsSegmentDuration = 4
|
// per source (a 2 h movie produces 3600 segments instead of 1800), but the
|
||||||
|
// player's first-frame wait drops to ~half — ffmpeg only needs to encode
|
||||||
|
// 2 s before seg-0 lands. For software encodes on 4K this is ~1 s instead
|
||||||
|
// of ~3 s of cold-cache wait. Well within HLS spec (Apple recommends 6 s,
|
||||||
|
// but 2-6 s is acceptable; Low-Latency HLS uses 1-2 s segments).
|
||||||
|
//
|
||||||
|
// Caveat for existing cached encodes: cache entries from 0.9.9 used 4 s
|
||||||
|
// segments. After this bump, VerifyComplete (which checks the highest
|
||||||
|
// expected segment index) returns false for those entries — they're
|
||||||
|
// invalidated + re-encoded with 2 s segments on next play. Self-healing.
|
||||||
|
const hlsSegmentDuration = 2
|
||||||
|
|
||||||
|
// segmentDurationFor returns the target duration (in whole seconds) for the
|
||||||
|
// segment at index idx. With uniform-duration segments this is always
|
||||||
|
// hlsSegmentDuration; the helper exists so a future short-first-segment
|
||||||
|
// variant can be slotted in here without touching every call site.
|
||||||
|
func segmentDurationFor(idx int) int {
|
||||||
|
return hlsSegmentDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// segmentStartSec returns the wall-clock start time of segment idx. Used
|
||||||
|
// to compute the `-ss` flag when ffmpeg restarts at a mid-file segment.
|
||||||
|
func segmentStartSec(idx int) float64 {
|
||||||
|
if idx <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(idx * hlsSegmentDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// segmentCountForDuration returns how many segments cover a source of the
|
||||||
|
// given duration. Always returns at least 1.
|
||||||
|
func segmentCountForDuration(dur float64) int {
|
||||||
|
if dur <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return int((dur + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
||||||
|
}
|
||||||
|
|
||||||
// hlsSessionTTL is how long a session can sit idle (no segment requests)
|
// hlsSessionTTL is how long a session can sit idle (no segment requests)
|
||||||
// before the manager kills ffmpeg + cleans the tmpdir.
|
// before the manager kills ffmpeg + cleans the tmpdir.
|
||||||
|
|
@ -302,10 +338,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
|
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
|
||||||
// the last segment vanished (external rm, partial-disk failure), we
|
// the last segment vanished (external rm, partial-disk failure), we
|
||||||
// can't actually serve a HIT — drop the dir and re-encode.
|
// can't actually serve a HIT — drop the dir and re-encode.
|
||||||
segCountForVerify := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
segCountForVerify := segmentCountForDuration(probe.DurationSec)
|
||||||
if segCountForVerify < 1 {
|
|
||||||
segCountForVerify = 1
|
|
||||||
}
|
|
||||||
if cfg.Cache.HasComplete(cacheKey) && !cfg.Cache.VerifyComplete(cacheKey, segCountForVerify) {
|
if cfg.Cache.HasComplete(cacheKey) && !cfg.Cache.VerifyComplete(cacheKey, segCountForVerify) {
|
||||||
log.Printf("[hls %s] cache %s sealed but failed integrity check — re-encoding",
|
log.Printf("[hls %s] cache %s sealed but failed integrity check — re-encoding",
|
||||||
shortHLSID(cfg.SessionID), cacheKey)
|
shortHLSID(cfg.SessionID), cacheKey)
|
||||||
|
|
@ -357,10 +390,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
return nil, fmt.Errorf("hls: mkdir subs: %w", err)
|
return nil, fmt.Errorf("hls: mkdir subs: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
segCount := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
segCount := segmentCountForDuration(probe.DurationSec)
|
||||||
if segCount < 1 {
|
|
||||||
segCount = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &HLSSession{
|
s := &HLSSession{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|
@ -911,8 +941,10 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build args for the new ffmpeg with -ss offset.
|
// Build args for the new ffmpeg with -ss offset. Segments are non-uniform
|
||||||
startSec := float64(targetIdx * hlsSegmentDuration)
|
// (seg-0 is hlsInitSegmentDuration s, the rest are hlsSegmentDuration s),
|
||||||
|
// so use segmentStartSec for the seek time instead of multiplying.
|
||||||
|
startSec := segmentStartSec(targetIdx)
|
||||||
args := buildHLSFFmpegArgsAt(s.cfg, s.probe, s.tmpDir, targetIdx, startSec)
|
args := buildHLSFFmpegArgsAt(s.cfg, s.probe, s.tmpDir, targetIdx, startSec)
|
||||||
|
|
||||||
ffCtx, cancel := context.WithCancel(context.Background())
|
ffCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
@ -1244,6 +1276,10 @@ func (s *HLSSession) extractSubtitles(ctx context.Context) {
|
||||||
// renderVideoPlaylist builds the VOD media playlist for the video stream.
|
// renderVideoPlaylist builds the VOD media playlist for the video stream.
|
||||||
// Segment count is derived from the source duration — the player learns the
|
// Segment count is derived from the source duration — the player learns the
|
||||||
// total timeline from the manifest before any segment is fetched.
|
// total timeline from the manifest before any segment is fetched.
|
||||||
|
//
|
||||||
|
// seg-0 is the short init segment (hlsInitSegmentDuration s); seg-1 onward
|
||||||
|
// are hlsSegmentDuration s each. The last segment may be shorter than the
|
||||||
|
// nominal duration when (duration - init) doesn't divide evenly.
|
||||||
func renderVideoPlaylist(durationSec float64, segCount int) string {
|
func renderVideoPlaylist(durationSec float64, segCount int) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("#EXTM3U\n")
|
b.WriteString("#EXTM3U\n")
|
||||||
|
|
@ -1254,7 +1290,7 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
|
||||||
b.WriteString(`#EXT-X-MAP:URI="init.mp4"` + "\n")
|
b.WriteString(`#EXT-X-MAP:URI="init.mp4"` + "\n")
|
||||||
remaining := durationSec
|
remaining := durationSec
|
||||||
for i := 0; i < segCount; i++ {
|
for i := 0; i < segCount; i++ {
|
||||||
segDur := float64(hlsSegmentDuration)
|
segDur := float64(segmentDurationFor(i))
|
||||||
if remaining < segDur {
|
if remaining < segDur {
|
||||||
segDur = remaining
|
segDur = remaining
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,10 +115,11 @@ func TestRenderVideoPlaylist(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
|
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
|
||||||
// 9.5s total, 4s segments → 3 segs of 4/4/1.5
|
// 9.5s total, 2s segments → 5 segs of 2/2/2/2/1.5
|
||||||
out := renderVideoPlaylist(9.5, 3)
|
segCount := segmentCountForDuration(9.5)
|
||||||
|
out := renderVideoPlaylist(9.5, segCount)
|
||||||
if !strings.Contains(out, "#EXTINF:1.500,") {
|
if !strings.Contains(out, "#EXTINF:1.500,") {
|
||||||
t.Errorf("expected final segment 1.5s in playlist, got:\n%s", out)
|
t.Errorf("expected final segment 1.5s in playlist (segCount=%d), got:\n%s", segCount, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue