Skip to content

Commit f537133

Browse files
committed
Make transcode segment duration configurable
1 parent 67c7d16 commit f537133

14 files changed

Lines changed: 134 additions & 24 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Copy `config.example.json` and change the upstream URL:
9898
"max_sessions": 2,
9999
"buffer_pause_seconds": 300,
100100
"buffer_resume_seconds": 120,
101+
"segment_seconds": 2,
101102
"segment_retention_seconds": 300,
102103
"idle_timeout_seconds": 60
103104
}
@@ -116,6 +117,7 @@ EmbyTranscoder keeps local FFmpeg sessions tied to Emby playback check-ins:
116117
- `POST /Sessions/Playing` and `/Sessions/Playing/Progress` update local playback state.
117118
- `POST /Sessions/Playing/Stopped` immediately stops the matching local FFmpeg session.
118119
- HLS playlist and segment requests refresh media activity.
120+
- `segment_seconds` controls HLS segment duration; default `2` balances startup latency with segment count, while `1` is fastest and higher values reduce disk churn.
119121
- When transcoded media gets more than `buffer_pause_seconds` ahead of playback, FFmpeg is paused.
120122
- When buffered media falls back under `buffer_resume_seconds`, FFmpeg resumes.
121123
- Segments older than `segment_retention_seconds` behind the current playback position are deleted from the local cache.

config.example.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"max_sessions": 2,
1717
"buffer_pause_seconds": 300,
1818
"buffer_resume_seconds": 120,
19+
"segment_seconds": 2,
1920
"segment_retention_seconds": 300,
2021
"idle_timeout_seconds": 60
2122
},

docker/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Edit `config/config.json` before startup, or copy it to `config/config.local.jso
2828
- startup probes VAAPI support, including device initialization and `h264_vaapi`, and fails startup when the device, driver, or ffmpeg support is unavailable
2929
- the image includes common Intel and AMD VAAPI userspace drivers plus `vainfo`
3030
- video output is capped at 1920x1080 while preserving aspect ratio
31+
- `transcode.segment_seconds` controls HLS segment duration; default `2` balances startup latency with segment count
3132
- playbackinfo rewrites prewarm the transcode session before the first playlist request
3233
- ffmpeg runs with low-latency startup and GOP settings to reduce first-segment delay
3334
- old `segment_*.ts` files are deleted once they are more than `transcode.segment_retention_seconds` behind playback

docker/config/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"max_sessions": 2,
1717
"buffer_pause_seconds": 300,
1818
"buffer_resume_seconds": 120,
19+
"segment_seconds": 2,
1920
"segment_retention_seconds": 300,
2021
"idle_timeout_seconds": 60
2122
},

internal/config/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ type Transcode struct {
3434
MaxSessions int `json:"max_sessions"`
3535
BufferPauseSeconds int `json:"buffer_pause_seconds"`
3636
BufferResumeSeconds int `json:"buffer_resume_seconds"`
37+
SegmentSeconds int `json:"segment_seconds"`
3738
SegmentRetentionSeconds int `json:"segment_retention_seconds"`
3839
IdleTimeoutSeconds int `json:"idle_timeout_seconds"`
3940
BufferPause time.Duration `json:"-"`
4041
BufferResume time.Duration `json:"-"`
42+
SegmentDuration time.Duration `json:"-"`
4143
SegmentRetention time.Duration `json:"-"`
4244
IdleTimeout time.Duration `json:"-"`
4345
}
@@ -93,6 +95,7 @@ func Default() Config {
9395
MaxSessions: 2,
9496
BufferPauseSeconds: 300,
9597
BufferResumeSeconds: 120,
98+
SegmentSeconds: 2,
9699
SegmentRetentionSeconds: 300,
97100
IdleTimeoutSeconds: 60,
98101
},
@@ -152,6 +155,9 @@ func normalize(cfg *Config) {
152155
if cfg.Transcode.BufferResumeSeconds <= 0 {
153156
cfg.Transcode.BufferResumeSeconds = 120
154157
}
158+
if cfg.Transcode.SegmentSeconds <= 0 {
159+
cfg.Transcode.SegmentSeconds = 2
160+
}
155161
if cfg.Transcode.SegmentRetentionSeconds <= 0 {
156162
cfg.Transcode.SegmentRetentionSeconds = 300
157163
}
@@ -160,6 +166,7 @@ func normalize(cfg *Config) {
160166
}
161167
cfg.Transcode.BufferPause = time.Duration(cfg.Transcode.BufferPauseSeconds) * time.Second
162168
cfg.Transcode.BufferResume = time.Duration(cfg.Transcode.BufferResumeSeconds) * time.Second
169+
cfg.Transcode.SegmentDuration = time.Duration(cfg.Transcode.SegmentSeconds) * time.Second
163170
cfg.Transcode.SegmentRetention = time.Duration(cfg.Transcode.SegmentRetentionSeconds) * time.Second
164171
cfg.Transcode.IdleTimeout = time.Duration(cfg.Transcode.IdleTimeoutSeconds) * time.Second
165172
}

internal/config/config_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ func TestDefaultConfigIsUsable(t *testing.T) {
3232
if cfg.Transcode.BufferResumeSeconds != 120 {
3333
t.Fatalf("buffer resume seconds = %d", cfg.Transcode.BufferResumeSeconds)
3434
}
35+
if cfg.Transcode.SegmentSeconds != 2 {
36+
t.Fatalf("segment seconds = %d", cfg.Transcode.SegmentSeconds)
37+
}
3538
if cfg.Transcode.SegmentRetentionSeconds != 300 {
3639
t.Fatalf("segment retention seconds = %d", cfg.Transcode.SegmentRetentionSeconds)
3740
}
@@ -45,7 +48,7 @@ func TestLoadMergesJSONOverDefaults(t *testing.T) {
4548
err := os.WriteFile(path, []byte(`{
4649
"server": {"listen": ":9000", "debug": true},
4750
"upstream": {"url": "http://emby.local:8096"},
48-
"transcode": {"max_sessions": 4, "buffer_pause_seconds": 600, "buffer_resume_seconds": 90, "segment_retention_seconds": 180},
51+
"transcode": {"max_sessions": 4, "buffer_pause_seconds": 600, "buffer_resume_seconds": 90, "segment_seconds": 2, "segment_retention_seconds": 180},
4952
"clients": [{"name": "yamby", "match": ["Yamby"], "transcode": true}]
5053
}`), 0o600)
5154
if err != nil {
@@ -72,6 +75,9 @@ func TestLoadMergesJSONOverDefaults(t *testing.T) {
7275
if cfg.Transcode.BufferPauseSeconds != 600 || cfg.Transcode.BufferResumeSeconds != 90 {
7376
t.Fatalf("buffer thresholds = %d/%d", cfg.Transcode.BufferPauseSeconds, cfg.Transcode.BufferResumeSeconds)
7477
}
78+
if cfg.Transcode.SegmentSeconds != 2 {
79+
t.Fatalf("segment seconds = %d", cfg.Transcode.SegmentSeconds)
80+
}
7581
if cfg.Transcode.SegmentRetentionSeconds != 180 {
7682
t.Fatalf("segment retention seconds = %d", cfg.Transcode.SegmentRetentionSeconds)
7783
}

internal/proxy/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ func NewWithTransport(cfg config.Config, transport http.RoundTripper) (*Server,
5252
HardwareDevice: cfg.Transcode.HardwareDevice,
5353
BufferPauseThreshold: cfg.Transcode.BufferPause,
5454
BufferResumeThreshold: cfg.Transcode.BufferResume,
55+
SegmentDuration: cfg.Transcode.SegmentDuration,
5556
SegmentRetention: cfg.Transcode.SegmentRetention,
5657
IdleTimeout: cfg.Transcode.IdleTimeout,
5758
})

internal/transcode/ffmpeg_args_test.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,29 @@ func TestBuildFFmpegArgsAppliesLocalSeekBeforeInputAndKeepsOutputOffset(t *testi
5555
t.Fatalf("expected unbounded HLS list size, args=%v", args)
5656
}
5757
hlsTimeIndex := slices.Index(args, "-hls_time")
58-
if hlsTimeIndex < 0 || args[hlsTimeIndex+1] != "1" {
59-
t.Fatalf("expected 1 second HLS segments, args=%v", args)
58+
if hlsTimeIndex < 0 || args[hlsTimeIndex+1] != "2" {
59+
t.Fatalf("expected default HLS segment duration, args=%v", args)
6060
}
6161
if args[len(args)-1] != filepath.Join(session.Dir, "master.m3u8") {
6262
t.Fatalf("playlist output = %q", args[len(args)-1])
6363
}
6464
}
6565

66+
func TestBuildFFmpegArgsUsesConfiguredSegmentDuration(t *testing.T) {
67+
session := &Session{
68+
ID: "item123",
69+
Dir: t.TempDir(),
70+
SegmentTicks: 2 * timeSecondTicks,
71+
}
72+
73+
args := buildFFmpegArgs(session, Request{InputURL: "http://upstream/stream"})
74+
75+
hlsTimeIndex := slices.Index(args, "-hls_time")
76+
if hlsTimeIndex < 0 || args[hlsTimeIndex+1] != "2" {
77+
t.Fatalf("expected configured HLS segment duration, args=%v", args)
78+
}
79+
}
80+
6681
func TestBuildFFmpegArgsDoesNotThrottleInputWithRealtimeFlag(t *testing.T) {
6782
session := &Session{
6883
ID: "item123",

internal/transcode/handler.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
4949
logging.Infof("playlist request id=%s start_ticks=%d", id, startTimeTicksFromRawQuery(r.URL.RawQuery))
5050
traceSwitch("playlist_request id=%s start_ticks=%d query=%s elapsed=%s", id, startTimeTicksFromRawQuery(r.URL.RawQuery), redactURLString("?"+r.URL.RawQuery), time.Since(requestStarted))
5151
if info, known := h.Manager.MediaInfo(id); known {
52-
if playlist, ready := VirtualVODPlaylist(info, defaultSegmentTicks, r.URL.RawQuery); ready {
52+
if playlist, ready := VirtualVODPlaylist(info, h.Manager.segmentTicks(), r.URL.RawQuery); ready {
5353
logging.Infof("playlist virtual id=%s duration=%s", id, formatTicks(info.RunTimeTicks))
5454
traceSwitch("playlist_virtual id=%s duration=%s start_ticks=%d query=%s media=%s elapsed=%s", id, formatTicks(info.RunTimeTicks), startTimeTicksFromRawQuery(r.URL.RawQuery), redactURLString("?"+r.URL.RawQuery), info.Summary(), time.Since(requestStarted))
5555
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
@@ -138,14 +138,15 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
138138
}
139139

140140
func (h Handler) sessionForSegment(id string, segmentIndex int, name string, r *http.Request, requestStarted time.Time) (*Session, error) {
141-
segmentReq := requestWithSegmentStart(r, segmentIndex)
141+
segmentReq := requestWithSegmentStart(r, segmentIndex, h.Manager.segmentTicks())
142142
inputURL := ""
143143
if h.InputURLForID != nil {
144144
inputURL = h.InputURLForID(id, segmentReq)
145145
}
146146
request := requestFromHTTP(id, inputURL, segmentReq)
147147
request.SegmentStartIndex = segmentIndex
148148
request.SegmentRequest = true
149+
request.SegmentTicks = h.Manager.segmentTicks()
149150
request.RequestedStartTimeTicks = int64Query(r.URL.Query().Get("StartTimeTicks"))
150151
if request.RequestedStartTimeTicks == 0 {
151152
request.RequestedStartTimeTicks = request.StartTimeTicks
@@ -223,27 +224,30 @@ func segmentInputFingerprint(raw string) string {
223224
return parsed.String()
224225
}
225226

226-
func requestWithSegmentStart(r *http.Request, segmentIndex int) *http.Request {
227+
func requestWithSegmentStart(r *http.Request, segmentIndex int, segmentTicks int64) *http.Request {
227228
req := r.Clone(r.Context())
228229
u := *r.URL
229230
query := u.Query()
230-
query.Set("StartTimeTicks", strconv.FormatInt(segmentStartTicksFromRequest(r, segmentIndex), 10))
231+
query.Set("StartTimeTicks", strconv.FormatInt(segmentStartTicksFromRequest(r, segmentIndex, segmentTicks), 10))
231232
query.Del("runtimeTicks")
232233
query.Del("actualSegmentLengthTicks")
233234
u.RawQuery = query.Encode()
234235
req.URL = &u
235236
return req
236237
}
237238

238-
func segmentStartTicksFromRequest(r *http.Request, segmentIndex int) int64 {
239+
func segmentStartTicksFromRequest(r *http.Request, segmentIndex int, segmentTicks int64) int64 {
239240
if raw := r.URL.Query().Get("runtimeTicks"); raw != "" {
240241
return int64Query(raw)
241242
}
242-
return segmentStartTicks(segmentIndex)
243+
return segmentStartTicks(segmentIndex, segmentTicks)
243244
}
244245

245-
func segmentStartTicks(segmentIndex int) int64 {
246-
return int64(segmentIndex) * defaultSegmentTicks
246+
func segmentStartTicks(segmentIndex int, segmentTicks int64) int64 {
247+
if segmentTicks <= 0 {
248+
segmentTicks = defaultSegmentTicks
249+
}
250+
return int64(segmentIndex) * segmentTicks
247251
}
248252

249253
func removeLocalAndPlaybackOnlyQuery(query url.Values) {

internal/transcode/manager.go

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type Options struct {
3535
HardwareDevice string
3636
BufferPauseThreshold time.Duration
3737
BufferResumeThreshold time.Duration
38+
SegmentDuration time.Duration
3839
SegmentRetention time.Duration
3940
BufferCheckInterval time.Duration
4041
IdleTimeout time.Duration
@@ -55,6 +56,7 @@ type Request struct {
5556
RequestedStartTimeTicks int64
5657
SegmentStartIndex int
5758
SegmentRequest bool
59+
SegmentTicks int64
5860
Media MediaInfo
5961
}
6062

@@ -77,6 +79,7 @@ type Session struct {
7779
SegmentStartIndex int
7880
OldestSegmentKept int
7981
HighestSegmentSeen int
82+
SegmentTicks int64
8083
Media MediaInfo
8184
Dir string
8285
InputURL string
@@ -158,6 +161,9 @@ func normalizeManagerOptions(options Options) Options {
158161
if options.BufferResumeThreshold <= 0 {
159162
options.BufferResumeThreshold = 2 * time.Minute
160163
}
164+
if options.SegmentDuration <= 0 {
165+
options.SegmentDuration = 2 * time.Second
166+
}
161167
if options.SegmentRetention <= 0 {
162168
options.SegmentRetention = 5 * time.Minute
163169
}
@@ -178,6 +184,38 @@ func normalizeManagerOptions(options Options) Options {
178184
return options
179185
}
180186

187+
func (m *Manager) segmentTicks() int64 {
188+
if m == nil {
189+
return defaultSegmentTicks
190+
}
191+
return segmentTicksFromDuration(m.options.SegmentDuration)
192+
}
193+
194+
func segmentTicksFromDuration(duration time.Duration) int64 {
195+
ticks := durationToTicks(duration)
196+
if ticks <= 0 {
197+
return defaultSegmentTicks
198+
}
199+
return ticks
200+
}
201+
202+
func sessionSegmentTicks(session *Session) int64 {
203+
if session == nil || session.SegmentTicks <= 0 {
204+
return defaultSegmentTicks
205+
}
206+
return session.SegmentTicks
207+
}
208+
209+
func hlsTimeValue(segmentTicks int64) string {
210+
if segmentTicks <= 0 {
211+
segmentTicks = defaultSegmentTicks
212+
}
213+
if segmentTicks%timeSecondTicks == 0 {
214+
return strconv.FormatInt(segmentTicks/timeSecondTicks, 10)
215+
}
216+
return strconv.FormatFloat(ticksFloatSeconds(segmentTicks), 'f', 3, 64)
217+
}
218+
181219
type MediaInfo struct {
182220
ItemID string
183221
SourceID string
@@ -349,6 +387,7 @@ func (m *Manager) Ensure(id string, request Request) (*Session, error) {
349387
}
350388
ctx, cancel := context.WithCancel(context.Background())
351389
session := &Session{ID: id, Dir: dir, InputURL: request.InputURL, LastAccess: now, LastMediaAccess: now, cancel: cancel}
390+
session.SegmentTicks = m.segmentTicks()
352391
touchSession(session, request, now, true)
353392
if session.Media.IsZero() {
354393
session.Media = m.media[id]
@@ -587,6 +626,12 @@ func touchSession(session *Session, request Request, now time.Time, mediaAccess
587626
session.StartTimeTicks = request.StartTimeTicks
588627
session.RequestedStartTimeTicks = request.RequestedStartTimeTicks
589628
session.SegmentStartIndex = request.SegmentStartIndex
629+
if request.SegmentTicks > 0 {
630+
session.SegmentTicks = request.SegmentTicks
631+
}
632+
if session.SegmentTicks <= 0 {
633+
session.SegmentTicks = defaultSegmentTicks
634+
}
590635
if session.OldestSegmentKept < request.SegmentStartIndex {
591636
session.OldestSegmentKept = request.SegmentStartIndex
592637
}
@@ -625,7 +670,7 @@ func (m *Manager) segmentCleanupTaskLocked(session *Session) (segmentCleanupTask
625670
return segmentCleanupTask{}, false
626671
}
627672
cutoffTicks := session.PositionTicks - retentionTicks
628-
beforeSegment := int(cutoffTicks / defaultSegmentTicks)
673+
beforeSegment := int(cutoffTicks / sessionSegmentTicks(session))
629674
if beforeSegment <= session.OldestSegmentKept {
630675
return segmentCleanupTask{}, false
631676
}
@@ -679,7 +724,7 @@ func (m *Manager) bufferActionLocked(session *Session) (bufferAction, bool) {
679724
}
680725
generatedTicks := baseTicks
681726
if session.HighestSegmentSeen >= session.SegmentStartIndex {
682-
generatedTicks += int64(session.HighestSegmentSeen-session.SegmentStartIndex+1) * defaultSegmentTicks
727+
generatedTicks += int64(session.HighestSegmentSeen-session.SegmentStartIndex+1) * sessionSegmentTicks(session)
683728
}
684729
playedTicks := session.PositionTicks
685730
if playedTicks < baseTicks {
@@ -1211,7 +1256,7 @@ func buildFFmpegArgs(session *Session, request Request, options ...FFmpegOptions
12111256
}
12121257
args = append(args,
12131258
"-f", "hls",
1214-
"-hls_time", "1",
1259+
"-hls_time", hlsTimeValue(sessionSegmentTicks(session)),
12151260
"-hls_list_size", "0",
12161261
"-hls_flags", "independent_segments",
12171262
"-start_number", strconv.Itoa(session.SegmentStartIndex),

0 commit comments

Comments
 (0)