Skip to content

Commit 4d4f949

Browse files
committed
Map selected Emby audio tracks
1 parent 2f349b6 commit 4d4f949

10 files changed

Lines changed: 209 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ It is intentionally narrow: normal API traffic is forwarded to the upstream serv
1111
- Client profile matching by `User-Agent` and `X-Emby-Authorization`.
1212
- PlaybackInfo rewriting for matched profiles.
1313
- Local FFmpeg HLS sessions under `/streambridge/transcode/`.
14+
- Audio track selection through Emby `AudioStreamIndex`, mapped to the matching FFmpeg audio stream.
1415
- Playback lifecycle tracking through Emby `/Sessions/Playing*` check-ins plus HLS access.
1516
- Conservative output target: H.264 video, AAC audio, HLS MPEG-TS segments.
1617

internal/emby/playback.go

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type SourceReport struct {
3232
AudioCodec string
3333
AudioChannels int
3434
AudioTitle string
35+
AudioStreams []AudioStreamReport
3536
Bitrate int64
3637
RunTimeTicks int64
3738
BeforeSupportsDirectPlay bool
@@ -41,6 +42,14 @@ type SourceReport struct {
4142
AfterTranscodingURL string
4243
}
4344

45+
type AudioStreamReport struct {
46+
Index int
47+
Ordinal int
48+
Codec string
49+
Channels int
50+
Title string
51+
}
52+
4453
func RewritePlaybackInfoWithReport(body []byte, itemID string, publicURL string, rawQuery ...string) ([]byte, bool, RewriteReport, error) {
4554
report := RewriteReport{ItemID: itemID}
4655
var root map[string]any
@@ -92,6 +101,7 @@ func RewritePlaybackInfoWithReport(body []byte, itemID string, publicURL string,
92101
AudioCodec: sourceReport.AudioCodec,
93102
AudioChannels: sourceReport.AudioChannels,
94103
AudioTitle: sourceReport.AudioTitle,
104+
AudioStreams: sourceReport.AudioStreams,
95105
Bitrate: sourceReport.Bitrate,
96106
RunTimeTicks: sourceReport.RunTimeTicks,
97107
BeforeSupportsDirectPlay: sourceReport.BeforeSupportsDirectPlay,
@@ -132,6 +142,7 @@ func sourceReportFromMap(source map[string]any) SourceReport {
132142
}
133143

134144
rawStreams, _ := source["MediaStreams"].([]any)
145+
audioOrdinal := 0
135146
for _, rawStream := range rawStreams {
136147
stream, ok := rawStream.(map[string]any)
137148
if !ok {
@@ -142,10 +153,21 @@ func sourceReportFromMap(source map[string]any) SourceReport {
142153
report.VideoCodec = stringValue(stream, "Codec")
143154
report.Width = intValue(stream, "Width")
144155
report.Height = intValue(stream, "Height")
145-
case strings.EqualFold(stringValue(stream, "Type"), "Audio") && report.AudioCodec == "":
146-
report.AudioCodec = stringValue(stream, "Codec")
147-
report.AudioChannels = intValue(stream, "Channels")
148-
report.AudioTitle = stringValue(stream, "DisplayTitle")
156+
case strings.EqualFold(stringValue(stream, "Type"), "Audio"):
157+
audio := AudioStreamReport{
158+
Index: intValue(stream, "Index"),
159+
Ordinal: audioOrdinal,
160+
Codec: stringValue(stream, "Codec"),
161+
Channels: intValue(stream, "Channels"),
162+
Title: stringValue(stream, "DisplayTitle"),
163+
}
164+
report.AudioStreams = append(report.AudioStreams, audio)
165+
audioOrdinal++
166+
if report.AudioCodec == "" {
167+
report.AudioCodec = audio.Codec
168+
report.AudioChannels = audio.Channels
169+
report.AudioTitle = audio.Title
170+
}
149171
}
150172
}
151173

internal/emby/playback_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func TestRewritePlaybackInfoPreservesQueryString(t *testing.T) {
6363
}
6464

6565
func TestRewritePlaybackInfoWithReportDescribesSources(t *testing.T) {
66-
input := []byte(`{"MediaSources":[{"Id":"source1","Name":"4K - 80 Mbps","Path":"/media/Movie.mkv","Container":"mkv","Bitrate":80000000,"RunTimeTicks":72000000000,"SupportsDirectPlay":true,"DirectStreamUrl":"/Videos/1/stream","MediaStreams":[{"Type":"Video","Codec":"hevc","Width":3840,"Height":2160},{"Type":"Audio","Codec":"dts","Channels":6,"DisplayTitle":"DTS 5.1"}]}]}`)
66+
input := []byte(`{"MediaSources":[{"Id":"source1","Name":"4K - 80 Mbps","Path":"/media/Movie.mkv","Container":"mkv","Bitrate":80000000,"RunTimeTicks":72000000000,"SupportsDirectPlay":true,"DirectStreamUrl":"/Videos/1/stream","MediaStreams":[{"Type":"Video","Index":0,"Codec":"hevc","Width":3840,"Height":2160},{"Type":"Audio","Index":1,"Codec":"dts","Channels":6,"DisplayTitle":"DTS 5.1"},{"Type":"Audio","Index":2,"Codec":"aac","Channels":2,"DisplayTitle":"AAC 2.0"}]}]}`)
6767

6868
_, changed, report, err := emby.RewritePlaybackInfoWithReport(input, "item123", "http://proxy.local")
6969
if err != nil {
@@ -106,6 +106,15 @@ func TestRewritePlaybackInfoWithReportDescribesSources(t *testing.T) {
106106
if source.AudioCodec != "dts" || source.AudioChannels != 6 || source.AudioTitle != "DTS 5.1" {
107107
t.Fatalf("audio = %s channels=%d title=%q", source.AudioCodec, source.AudioChannels, source.AudioTitle)
108108
}
109+
if len(source.AudioStreams) != 2 {
110+
t.Fatalf("audio streams = %+v", source.AudioStreams)
111+
}
112+
if source.AudioStreams[0].Index != 1 || source.AudioStreams[0].Ordinal != 0 || source.AudioStreams[0].Codec != "dts" {
113+
t.Fatalf("first audio stream = %+v", source.AudioStreams[0])
114+
}
115+
if source.AudioStreams[1].Index != 2 || source.AudioStreams[1].Ordinal != 1 || source.AudioStreams[1].Codec != "aac" {
116+
t.Fatalf("second audio stream = %+v", source.AudioStreams[1])
117+
}
109118
if source.Bitrate != 80000000 || source.RunTimeTicks != 72000000000 {
110119
t.Fatalf("bitrate/runtime = %d/%d", source.Bitrate, source.RunTimeTicks)
111120
}

internal/proxy/server.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,15 @@ func (s *Server) handlePlaybackInfo(w http.ResponseWriter, r *http.Request) {
201201
Bitrate: source.Bitrate,
202202
RunTimeTicks: source.RunTimeTicks,
203203
}
204+
for _, audio := range source.AudioStreams {
205+
mediaInfo.AudioStreams = append(mediaInfo.AudioStreams, transcode.AudioStreamInfo{
206+
Index: audio.Index,
207+
Ordinal: audio.Ordinal,
208+
Codec: audio.Codec,
209+
Channels: audio.Channels,
210+
Title: audio.Title,
211+
})
212+
}
204213
s.transcodeManager.RememberMedia(source.SessionID, mediaInfo)
205214
logging.Debugf(
206215
"playbackinfo source item=%s session=%s index=%d source_id=%q before_direct=%t before_transcode=%t had_direct_stream_url=%t had_transcoding_url=%t after=%s media=%s",
@@ -233,7 +242,9 @@ func (s *Server) upstreamURL(in *url.URL) string {
233242
func transcodeInputURL(upstream *url.URL, id string, r *http.Request) string {
234243
u := *upstream
235244
u.Path = singleJoiningSlash(upstream.Path, path.Join("/emby/Videos", id, "stream"))
236-
u.RawQuery = r.URL.RawQuery
245+
query := r.URL.Query()
246+
query.Del("AudioStreamIndex")
247+
u.RawQuery = query.Encode()
237248
return u.String()
238249
}
239250

internal/proxy/transcode_input_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,20 @@ func TestTranscodeInputURLPreservesStartTimeTicks(t *testing.T) {
2626
t.Fatalf("input url = %s", got)
2727
}
2828
}
29+
30+
func TestTranscodeInputURLStripsAudioStreamIndexForLocalMapping(t *testing.T) {
31+
upstream, err := url.Parse("http://upstream.local")
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
req := httptest.NewRequest("GET", "/streambridge/transcode/item123/master.m3u8?AudioStreamIndex=2&MediaSourceId=source1&X-Emby-Token=abc", nil)
36+
37+
got := transcodeInputURL(upstream, "item123", req)
38+
39+
if strings.Contains(got, "AudioStreamIndex=") {
40+
t.Fatalf("input url should not forward AudioStreamIndex to upstream when local ffmpeg maps audio: %s", got)
41+
}
42+
if !strings.Contains(got, "MediaSourceId=source1") || !strings.Contains(got, "X-Emby-Token=abc") {
43+
t.Fatalf("input url should preserve non-audio query params: %s", got)
44+
}
45+
}

internal/transcode/ffmpeg_args_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,43 @@ func TestBuildFFmpegArgsAppliesVAAPIHardwareDecodeBeforeInput(t *testing.T) {
103103
}
104104
}
105105

106+
func TestBuildFFmpegArgsMapsRequestedEmbyAudioStreamIndex(t *testing.T) {
107+
session := &Session{
108+
ID: "item123",
109+
Dir: t.TempDir(),
110+
Media: MediaInfo{
111+
AudioStreams: []AudioStreamInfo{
112+
{Index: 1, Ordinal: 0, Codec: "dts"},
113+
{Index: 2, Ordinal: 1, Codec: "aac"},
114+
},
115+
},
116+
}
117+
request := Request{
118+
InputURL: "http://upstream/stream?AudioStreamIndex=2",
119+
AudioStreamIndex: 2,
120+
}
121+
122+
args := buildFFmpegArgs(session, request)
123+
124+
mapIndexes := allIndexes(args, "-map")
125+
if len(mapIndexes) < 2 {
126+
t.Fatalf("missing map args: %v", args)
127+
}
128+
if got := args[mapIndexes[1]+1]; got != "0:a:1?" {
129+
t.Fatalf("audio map = %q, args=%v", got, args)
130+
}
131+
}
132+
133+
func allIndexes(values []string, needle string) []int {
134+
var indexes []int
135+
for i, value := range values {
136+
if value == needle {
137+
indexes = append(indexes, i)
138+
}
139+
}
140+
return indexes
141+
}
142+
106143
func TestResolveHardwareDecodeKeepsVAAPIWhenProbePasses(t *testing.T) {
107144
options := resolveHardwareDecodeOptions("/usr/bin/ffmpeg", FFmpegOptions{
108145
HardwareDecode: "vaapi",

internal/transcode/handler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ func (h Handler) sessionForSegment(id string, segmentIndex int, name string, r *
160160
traceSwitch("segment_decision id=%s file=%s segment=%d decision=restart reason=process_done old_segment_start=%d old_start_ticks=%d elapsed=%s", id, name, segmentIndex, session.SegmentStartIndex, session.StartTimeTicks, time.Since(requestStarted))
161161
case !segmentInputCompatible(session.InputURL, inputURL):
162162
traceSwitch("segment_decision id=%s file=%s segment=%d decision=restart reason=input_changed old_input=%s new_input=%s elapsed=%s", id, name, segmentIndex, redactURLString(session.InputURL), redactURLString(inputURL), time.Since(requestStarted))
163+
case session.AudioStreamIndex != request.AudioStreamIndex:
164+
traceSwitch("segment_decision id=%s file=%s segment=%d decision=restart reason=audio_changed old_audio=%d new_audio=%d elapsed=%s", id, name, segmentIndex, session.AudioStreamIndex, request.AudioStreamIndex, time.Since(requestStarted))
163165
case segmentReusable(session, segmentIndex, name):
164166
traceSwitch("segment_decision id=%s file=%s segment=%d decision=hit session_start=%d dir=%s elapsed=%s", id, name, segmentIndex, session.SegmentStartIndex, session.Dir, time.Since(requestStarted))
165167
h.Manager.RecordSegmentRequest(id, segmentIndex)
@@ -284,6 +286,7 @@ func requestFromHTTP(id string, inputURL string, r *http.Request) Request {
284286
ItemID: id,
285287
MediaSourceID: query.Get("MediaSourceId"),
286288
PlaySessionID: playSessionID,
289+
AudioStreamIndex: int(int64Query(query.Get("AudioStreamIndex"))),
287290
StartTimeTicks: int64Query(query.Get("StartTimeTicks")),
288291
RequestedStartTimeTicks: int64Query(query.Get("StartTimeTicks")),
289292
}

internal/transcode/manager.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type Request struct {
4646
ItemID string
4747
MediaSourceID string
4848
PlaySessionID string
49+
AudioStreamIndex int
4950
StartTimeTicks int64
5051
RequestedStartTimeTicks int64
5152
SegmentStartIndex int
@@ -65,6 +66,7 @@ type Session struct {
6566
ItemID string
6667
MediaSourceID string
6768
PlaySessionID string
69+
AudioStreamIndex int
6870
StartTimeTicks int64
6971
RequestedStartTimeTicks int64
7072
SegmentStartIndex int
@@ -153,10 +155,19 @@ type MediaInfo struct {
153155
AudioCodec string
154156
AudioChannels int
155157
AudioTitle string
158+
AudioStreams []AudioStreamInfo
156159
Bitrate int64
157160
RunTimeTicks int64
158161
}
159162

163+
type AudioStreamInfo struct {
164+
Index int
165+
Ordinal int
166+
Codec string
167+
Channels int
168+
Title string
169+
}
170+
160171
func (info MediaInfo) Summary() string {
161172
var parts []string
162173
if info.Name != "" {
@@ -191,6 +202,9 @@ func (info MediaInfo) Summary() string {
191202
}
192203
parts = append(parts, "audio="+audio)
193204
}
205+
if len(info.AudioStreams) > 0 {
206+
parts = append(parts, fmt.Sprintf("audio_streams=%d", len(info.AudioStreams)))
207+
}
194208
if info.Bitrate > 0 {
195209
parts = append(parts, fmt.Sprintf("bitrate=%d", info.Bitrate))
196210
}
@@ -204,7 +218,20 @@ func (info MediaInfo) Summary() string {
204218
}
205219

206220
func (info MediaInfo) IsZero() bool {
207-
return info == MediaInfo{}
221+
return info.ItemID == "" &&
222+
info.SourceID == "" &&
223+
info.Name == "" &&
224+
info.Path == "" &&
225+
info.Container == "" &&
226+
info.VideoCodec == "" &&
227+
info.Width == 0 &&
228+
info.Height == 0 &&
229+
info.AudioCodec == "" &&
230+
info.AudioChannels == 0 &&
231+
info.AudioTitle == "" &&
232+
len(info.AudioStreams) == 0 &&
233+
info.Bitrate == 0 &&
234+
info.RunTimeTicks == 0
208235
}
209236

210237
func (m *Manager) RememberMedia(id string, info MediaInfo) {
@@ -488,6 +515,9 @@ func shouldRestart(session *Session, request Request) bool {
488515
if request.SegmentStartIndex != session.SegmentStartIndex {
489516
return true
490517
}
518+
if request.AudioStreamIndex != session.AudioStreamIndex {
519+
return true
520+
}
491521
return request.InputURL != "" && session.InputURL != "" && request.InputURL != session.InputURL
492522
}
493523

@@ -508,6 +538,7 @@ func touchSession(session *Session, request Request, now time.Time, mediaAccess
508538
if request.PlaySessionID != "" {
509539
session.PlaySessionID = request.PlaySessionID
510540
}
541+
session.AudioStreamIndex = request.AudioStreamIndex
511542
session.StartTimeTicks = request.StartTimeTicks
512543
session.RequestedStartTimeTicks = request.RequestedStartTimeTicks
513544
session.SegmentStartIndex = request.SegmentStartIndex
@@ -764,7 +795,7 @@ func (r FFmpegRunner) Start(ctx context.Context, session *Session, request Reque
764795
args := buildFFmpegArgs(session, request, r.Options)
765796
playlist := filepath.Join(session.Dir, "master.m3u8")
766797
logPath := filepath.Join(session.Dir, "ffmpeg.log")
767-
logging.Infof("transcode start id=%s segment=%d decode=%s audio=optional-aac log=%s", session.ID, session.SegmentStartIndex, ffmpegOptionsSummary(r.Options), logPath)
798+
logging.Infof("transcode start id=%s segment=%d decode=%s audio_stream_index=%d audio_map=%s audio=optional-aac log=%s", session.ID, session.SegmentStartIndex, ffmpegOptionsSummary(r.Options), request.AudioStreamIndex, audioMapArg(session, request), logPath)
768799
logging.Debugf("ffmpeg start id=%s item=%s media_source=%s start_ticks=%d segment_start=%d path=%s input=%s playlist=%s media=%s args=%s", session.ID, session.ItemID, session.MediaSourceID, session.StartTimeTicks, session.SegmentStartIndex, r.Path, redactURLString(request.InputURL), playlist, session.Media.Summary(), redactFFmpegArgs(args))
769800
cmd := exec.CommandContext(ctx, r.Path, args...)
770801
stdin, err := cmd.StdinPipe()
@@ -845,7 +876,7 @@ func buildFFmpegArgs(session *Session, request Request, options ...FFmpegOptions
845876
args = append(args,
846877
"-i", request.InputURL,
847878
"-map", "0:v:0",
848-
"-map", "0:a:0?",
879+
"-map", audioMapArg(session, request),
849880
"-c:v", "libx264",
850881
"-preset", "veryfast",
851882
"-profile:v", "high",
@@ -872,6 +903,27 @@ func buildFFmpegArgs(session *Session, request Request, options ...FFmpegOptions
872903
return args
873904
}
874905

906+
func audioMapArg(session *Session, request Request) string {
907+
ordinal := audioOrdinalForRequest(session, request)
908+
return fmt.Sprintf("0:a:%d?", ordinal)
909+
}
910+
911+
func audioOrdinalForRequest(session *Session, request Request) int {
912+
if request.AudioStreamIndex < 0 {
913+
return 0
914+
}
915+
media := request.Media
916+
if media.IsZero() && session != nil {
917+
media = session.Media
918+
}
919+
for _, stream := range media.AudioStreams {
920+
if stream.Index == request.AudioStreamIndex && stream.Ordinal >= 0 {
921+
return stream.Ordinal
922+
}
923+
}
924+
return 0
925+
}
926+
875927
func appendHardwareDecodeArgs(args []string, options FFmpegOptions) []string {
876928
switch strings.ToLower(strings.TrimSpace(options.HardwareDecode)) {
877929
case "", "none", "off", "false":

internal/transcode/manager_test.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,40 @@ func TestManagerRestartsSessionWhenSegmentStartIndexChanges(t *testing.T) {
195195
}
196196
}
197197

198+
func TestManagerRestartsSessionWhenAudioStreamIndexChanges(t *testing.T) {
199+
var stopped atomic.Int32
200+
m := transcode.NewManager(transcode.Options{
201+
MaxSessions: 1,
202+
TempDir: t.TempDir(),
203+
Runner: runnerFunc(func(ctx context.Context, session *transcode.Session, request transcode.Request) (transcode.Process, error) {
204+
return stopFunc(func() error {
205+
stopped.Add(1)
206+
return nil
207+
}), nil
208+
}),
209+
})
210+
t.Cleanup(m.Close)
211+
212+
first, err := m.Ensure("item123", transcode.Request{InputURL: "http://upstream/stream", AudioStreamIndex: 1})
213+
if err != nil {
214+
t.Fatal(err)
215+
}
216+
second, err := m.Ensure("item123", transcode.Request{InputURL: "http://upstream/stream", AudioStreamIndex: 2})
217+
if err != nil {
218+
t.Fatal(err)
219+
}
220+
221+
if first == second {
222+
t.Fatal("expected a new session when audio stream index changes")
223+
}
224+
if second.AudioStreamIndex != 2 {
225+
t.Fatalf("audio stream index = %d", second.AudioStreamIndex)
226+
}
227+
if stopped.Load() != 1 {
228+
t.Fatalf("stopped = %d", stopped.Load())
229+
}
230+
}
231+
198232
func TestManagerRestartsSessionWhenRequestedStartTimeTicksChangesWithinSameSegment(t *testing.T) {
199233
var stopped atomic.Int32
200234
m := transcode.NewManager(transcode.Options{
@@ -354,10 +388,10 @@ func TestManagerPausesAndResumesBufferedProcess(t *testing.T) {
354388
func TestManagerDoesNotPauseImmediatelyForSeekedSession(t *testing.T) {
355389
process := &pausingProcess{}
356390
m := transcode.NewManager(transcode.Options{
357-
MaxSessions: 1,
358-
TempDir: t.TempDir(),
359-
BufferPauseThreshold: 5 * time.Second,
360-
BufferResumeThreshold: 2 * time.Second,
391+
MaxSessions: 1,
392+
TempDir: t.TempDir(),
393+
BufferPauseThreshold: 5 * time.Second,
394+
BufferResumeThreshold: 2 * time.Second,
361395
Runner: runnerFunc(func(ctx context.Context, session *transcode.Session, request transcode.Request) (transcode.Process, error) {
362396
return process, nil
363397
}),

0 commit comments

Comments
 (0)