diff --git a/CHANGELOG.md b/CHANGELOG.md index f31f458..09ee8af 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 7cd1023..b0cca22 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -580,14 +580,23 @@ func runDaemonStart() error { Transcode: tcRuntime, Cache: hlsCache, } - 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) + // 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) + 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) diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 8d939c0..5db1635 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -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" diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 7d0cf21..634f193 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -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 } diff --git a/internal/engine/hls_test.go b/internal/engine/hls_test.go index 7c7cfa4..7d4cde3 100644 --- a/internal/engine/hls_test.go +++ b/internal/engine/hls_test.go @@ -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) } }