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/),
|
||||
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
|
||||
|
||||
### 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.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.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.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
|
||||
|
|
|
|||
|
|
@ -580,6 +580,14 @@ func runDaemonStart() error {
|
|||
Transcode: tcRuntime,
|
||||
Cache: hlsCache,
|
||||
}
|
||||
// StartHLSSession runs ffprobe (15 s cap, typical 0.3–1 s) before
|
||||
// returning. Doing this synchronously inside the sync handler holds
|
||||
// the next sync HTTP cycle until ffprobe is done, so any other
|
||||
// pending actions (new tasks, deletes) wait too. Hand it off so
|
||||
// the sync loop returns immediately — browser HEAD probes already
|
||||
// have a 30 s retry budget that absorbs the gap until
|
||||
// `streamSrv.HLS().Register` lands.
|
||||
go func() {
|
||||
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
|
||||
if err != nil {
|
||||
playerSessionRegistry.remove(sess.SessionID)
|
||||
|
|
@ -588,6 +596,7 @@ func runDaemonStart() error {
|
|||
return
|
||||
}
|
||||
streamSrv.HLS().Register(hsess)
|
||||
}()
|
||||
}
|
||||
|
||||
// Periodic DHT node persistence (every 5 min)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package cmd
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// hlsSegmentDuration is the target seconds per HLS fragment. Four seconds is
|
||||
// the Plex/Apple default — short enough that seek granularity is acceptable,
|
||||
// long enough that GOP overhead doesn't dominate.
|
||||
const hlsSegmentDuration = 4
|
||||
// hlsSegmentDuration is the target seconds per HLS fragment.
|
||||
//
|
||||
// We use 2 seconds (not the more common 4-6 s). Trade-off: 2× more segments
|
||||
// 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)
|
||||
// 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
|
||||
// the last segment vanished (external rm, partial-disk failure), we
|
||||
// can't actually serve a HIT — drop the dir and re-encode.
|
||||
segCountForVerify := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
||||
if segCountForVerify < 1 {
|
||||
segCountForVerify = 1
|
||||
}
|
||||
segCountForVerify := segmentCountForDuration(probe.DurationSec)
|
||||
if cfg.Cache.HasComplete(cacheKey) && !cfg.Cache.VerifyComplete(cacheKey, segCountForVerify) {
|
||||
log.Printf("[hls %s] cache %s sealed but failed integrity check — re-encoding",
|
||||
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)
|
||||
}
|
||||
|
||||
segCount := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
|
||||
if segCount < 1 {
|
||||
segCount = 1
|
||||
}
|
||||
segCount := segmentCountForDuration(probe.DurationSec)
|
||||
|
||||
s := &HLSSession{
|
||||
cfg: cfg,
|
||||
|
|
@ -911,8 +941,10 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
|
|||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Build args for the new ffmpeg with -ss offset.
|
||||
startSec := float64(targetIdx * hlsSegmentDuration)
|
||||
// Build args for the new ffmpeg with -ss offset. Segments are non-uniform
|
||||
// (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)
|
||||
|
||||
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.
|
||||
// Segment count is derived from the source duration — the player learns the
|
||||
// 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 {
|
||||
var b strings.Builder
|
||||
b.WriteString("#EXTM3U\n")
|
||||
|
|
@ -1254,7 +1290,7 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
|
|||
b.WriteString(`#EXT-X-MAP:URI="init.mp4"` + "\n")
|
||||
remaining := durationSec
|
||||
for i := 0; i < segCount; i++ {
|
||||
segDur := float64(hlsSegmentDuration)
|
||||
segDur := float64(segmentDurationFor(i))
|
||||
if remaining < segDur {
|
||||
segDur = remaining
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,10 +115,11 @@ func TestRenderVideoPlaylist(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
|
||||
// 9.5s total, 4s segments → 3 segs of 4/4/1.5
|
||||
out := renderVideoPlaylist(9.5, 3)
|
||||
// 9.5s total, 2s segments → 5 segs of 2/2/2/2/1.5
|
||||
segCount := segmentCountForDuration(9.5)
|
||||
out := renderVideoPlaylist(9.5, segCount)
|
||||
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