From d86675df3bf6de3e9a2d7c3eb5df00f8478b363e Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 8 Feb 2026 20:41:07 -0500 Subject: [PATCH] refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping Signed-off-by: Deluan --- core/archiver.go | 2 +- core/archiver_test.go | 4 +- core/ffmpeg/ffmpeg.go | 14 +- core/media_streamer.go | 37 ++- core/media_streamer_test.go | 12 +- core/transcode/aliases.go | 87 ++++++ core/transcode/codec.go | 59 ++++ core/transcode/limitations.go | 206 +++++++++++++ core/transcode/transcode.go | 520 +++----------------------------- core/transcode/types.go | 129 ++++++++ server/public/handle_streams.go | 5 +- server/subsonic/stream.go | 8 +- server/subsonic/transcode.go | 102 ++++--- 13 files changed, 627 insertions(+), 558 deletions(-) create mode 100644 core/transcode/aliases.go create mode 100644 core/transcode/codec.go create mode 100644 core/transcode/limitations.go create mode 100644 core/transcode/types.go diff --git a/core/archiver.go b/core/archiver.go index f2fd108b7..fef9188a2 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -176,7 +176,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med var r io.ReadCloser if format != "raw" && format != "" { - r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0, 0, 0, 0) + r, err = a.ms.DoStream(ctx, &mf, StreamRequest{Format: format, BitRate: bitrate}) } else { r, err = os.Open(path) } diff --git a/core/archiver_test.go b/core/archiver_test.go index 1291dce38..b0cf13ae1 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -217,8 +217,8 @@ type mockMediaStreamer struct { core.MediaStreamer } -func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqChannels int, reqOffset int) (*core.Stream, error) { - args := m.Called(ctx, mf, reqFormat, reqBitRate, reqSampleRate, reqBitDepth, reqChannels, reqOffset) +func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req core.StreamRequest) (*core.Stream, error) { + args := m.Called(ctx, mf, req.Format, req.BitRate, req.SampleRate, req.BitDepth, req.Channels, req.Offset) if args.Error(1) != nil { return nil, args.Error(1) } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index bed0eff0e..d530891bb 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -7,6 +7,7 @@ import ( "io" "os" "os/exec" + "slices" "strconv" "strings" "sync" @@ -202,16 +203,7 @@ var formatOutputMap = map[string]string{ // isDefaultCommand returns true if the command matches any known default for this format. func isDefaultCommand(format, command string) bool { - defaults, ok := defaultCommands[format] - if !ok { - return false - } - for _, d := range defaults { - if command == d { - return true - } - } - return false + return slices.Contains(defaultCommands[format], command) } // buildDynamicArgs programmatically constructs ffmpeg arguments for known formats, @@ -292,6 +284,8 @@ func injectBeforeOutput(args []string, flag, value string) []string { // isLosslessOutputFormat returns true if the format is a lossless audio format // where preserving bit depth via -sample_fmt is meaningful. +// Note: this covers only formats ffmpeg can produce as output. For the full set of +// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat. func isLosslessOutputFormat(format string) bool { switch strings.ToLower(format) { case "flac", "alac", "wav", "aiff": diff --git a/core/media_streamer.go b/core/media_streamer.go index 6c154256f..9d642de55 100644 --- a/core/media_streamer.go +++ b/core/media_streamer.go @@ -18,9 +18,20 @@ import ( "github.com/navidrome/navidrome/utils/cache" ) +// StreamRequest contains all parameters for creating a media stream. +type StreamRequest struct { + ID string + Format string + BitRate int // kbps + SampleRate int + BitDepth int + Channels int + Offset int // seconds +} + type MediaStreamer interface { - NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqChannels int, offset int) (*Stream, error) - DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqChannels int, reqOffset int) (*Stream, error) + NewStream(ctx context.Context, req StreamRequest) (*Stream, error) + DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) } type TranscodingCache cache.FileCache @@ -51,16 +62,16 @@ func (j *streamJob) Key() string { return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) } -func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqChannels int, reqOffset int) (*Stream, error) { - mf, err := ms.ds.MediaFile(ctx).Get(id) +func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) { + mf, err := ms.ds.MediaFile(ctx).Get(req.ID) if err != nil { return nil, err } - return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqSampleRate, reqBitDepth, reqChannels, reqOffset) + return ms.DoStream(ctx, mf, req) } -func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqSampleRate int, reqBitDepth int, reqChannels int, reqOffset int) (*Stream, error) { +func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) { var format string var bitRate int var cached bool @@ -70,13 +81,13 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) }() - format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate, reqSampleRate) + format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, req.Format, req.BitRate, req.SampleRate) s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} filePath := mf.AbsolutePath() if format == "raw" { log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath, - "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "selectedBitrate", bitRate, "selectedFormat", format) f, err := os.Open(filePath) @@ -95,10 +106,10 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF filePath: filePath, format: format, bitRate: bitRate, - sampleRate: reqSampleRate, - bitDepth: reqBitDepth, - channels: reqChannels, - offset: reqOffset, + sampleRate: req.SampleRate, + bitDepth: req.BitDepth, + channels: req.Channels, + offset: req.Offset, } r, err := ms.cache.Get(ctx, job) if err != nil { @@ -111,7 +122,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF s.Seeker = r.Seeker log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath, - "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable()) diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go index 700877df1..0652cf008 100644 --- a/core/media_streamer_test.go +++ b/core/media_streamer_test.go @@ -39,34 +39,34 @@ var _ = Describe("MediaStreamer", func() { Context("NewStream", func() { It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, "123", "raw", 0, 0, 0, 0, 0) + s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "raw"}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a seekable stream if maxBitRate is 0", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0, 0, 0, 0) + s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3"}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a seekable stream if maxBitRate is higher than file bitRate", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0, 0, 0, 0) + s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 320}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a NON seekable stream if transcode is required", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 64, 0, 0, 0, 0) + s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 64}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) It("returns a seekable stream if the file is complete in the cache", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 32, 0, 0, 0, 0) + s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) _, _ = io.ReadAll(s) _ = s.Close() Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) - s, err = streamer.NewStream(ctx, "123", "mp3", 32, 0, 0, 0, 0) + s, err = streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/aliases.go b/core/transcode/aliases.go new file mode 100644 index 000000000..67a641511 --- /dev/null +++ b/core/transcode/aliases.go @@ -0,0 +1,87 @@ +package transcode + +import ( + "slices" + "strings" +) + +// containerAliasGroups maps each container alias to a canonical group name. +var containerAliasGroups = func() map[string]string { + groups := [][]string{ + {"aac", "adts", "m4a", "mp4", "m4b", "m4p"}, + {"mpeg", "mp3", "mp2"}, + {"ogg", "oga"}, + {"aif", "aiff"}, + {"asf", "wma"}, + {"mpc", "mpp"}, + {"wv"}, + } + m := make(map[string]string) + for _, g := range groups { + canonical := g[0] + for _, name := range g { + m[name] = canonical + } + } + return m +}() + +// codecAliasGroups maps each codec alias to a canonical group name. +// Codecs within the same group are considered equivalent. +var codecAliasGroups = func() map[string]string { + groups := [][]string{ + {"aac", "adts"}, + {"ac3", "ac-3"}, + {"eac3", "e-ac3", "e-ac-3", "eac-3"}, + {"mpc7", "musepack7"}, + {"mpc8", "musepack8"}, + {"wma1", "wmav1"}, + {"wma2", "wmav2"}, + {"wmalossless", "wma9lossless"}, + {"wmapro", "wma9pro"}, + {"shn", "shorten"}, + {"mp4als", "als"}, + } + m := make(map[string]string) + for _, g := range groups { + for _, name := range g { + m[name] = g[0] // canonical = first entry + } + } + return m +}() + +// matchesWithAliases checks if a value matches any entry in candidates, +// consulting the alias map for equivalent names. +func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool { + value = strings.ToLower(value) + canonical := aliases[value] + for _, c := range candidates { + c = strings.ToLower(c) + if c == value { + return true + } + if canonical != "" && aliases[c] == canonical { + return true + } + } + return false +} + +// matchesContainer checks if a file suffix matches any of the container names, +// including common aliases. +func matchesContainer(suffix string, containers []string) bool { + return matchesWithAliases(suffix, containers, containerAliasGroups) +} + +// matchesCodec checks if a codec matches any of the codec names, +// including common aliases. +func matchesCodec(codec string, codecs []string) bool { + return matchesWithAliases(codec, codecs, codecAliasGroups) +} + +func containsIgnoreCase(slice []string, s string) bool { + return slices.ContainsFunc(slice, func(item string) bool { + return strings.EqualFold(item, s) + }) +} diff --git a/core/transcode/codec.go b/core/transcode/codec.go new file mode 100644 index 000000000..641c7c124 --- /dev/null +++ b/core/transcode/codec.go @@ -0,0 +1,59 @@ +package transcode + +import "strings" + +// isLosslessFormat returns true if the format is a lossless audio codec/format. +// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats +// ffmpeg can produce as output (a smaller set). This function covers all known lossless formats +// for transcoding decision purposes. +func isLosslessFormat(format string) bool { + switch strings.ToLower(format) { + case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd": + return true + } + return false +} + +// normalizeSourceSampleRate adjusts the source sample rate for codecs that store +// it differently than PCM. Currently handles DSD (÷8): +// DSD64=2822400→352800, DSD128=5644800→705600, etc. +// For other codecs, returns the rate unchanged. +func normalizeSourceSampleRate(sampleRate int, codec string) int { + if strings.EqualFold(codec, "dsd") && sampleRate > 0 { + return sampleRate / 8 + } + return sampleRate +} + +// normalizeSourceBitDepth adjusts the source bit depth for codecs that use +// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is +// what ffmpeg produces). For other codecs, returns the depth unchanged. +func normalizeSourceBitDepth(bitDepth int, codec string) int { + if strings.EqualFold(codec, "dsd") && bitDepth == 1 { + return 24 + } + return bitDepth +} + +// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs +// that always resample regardless of input (e.g., Opus always outputs 48000Hz). +// Returns 0 if the codec has no fixed output rate. +func codecFixedOutputSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "opus": + return 48000 + } + return 0 +} + +// codecMaxSampleRate returns the hard maximum output sample rate for a codec. +// Returns 0 if the codec has no hard limit. +func codecMaxSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "mp3": + return 48000 + case "aac": + return 96000 + } + return 0 +} diff --git a/core/transcode/limitations.go b/core/transcode/limitations.go new file mode 100644 index 000000000..e3c4361f3 --- /dev/null +++ b/core/transcode/limitations.go @@ -0,0 +1,206 @@ +package transcode + +import ( + "strconv" + "strings" + + "github.com/navidrome/navidrome/model" +) + +// adjustResult represents the outcome of applying a limitation to a transcoded stream value +type adjustResult int + +const ( + adjustNone adjustResult = iota // Value already satisfies the limitation + adjustAdjusted // Value was changed to fit the limitation + adjustCannotFit // Cannot satisfy the limitation (reject this profile) +) + +// checkLimitations checks codec profile limitations against source media. +// Returns "" if all limitations pass, or a typed reason string for the first failure. +func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string { + for _, lim := range limitations { + var ok bool + var reason string + + switch lim.Name { + case LimitationAudioChannels: + ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values) + reason = "audio channels not supported" + case LimitationAudioSamplerate: + ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values) + reason = "audio samplerate not supported" + case LimitationAudioBitrate: + ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values) + reason = "audio bitrate not supported" + case LimitationAudioBitdepth: + ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values) + reason = "audio bitdepth not supported" + case LimitationAudioProfile: + // TODO: populate source profile when MediaFile has audio profile info + ok = checkStringLimitation("", lim.Comparison, lim.Values) + reason = "audio profile not supported" + default: + continue + } + + if !ok && lim.Required { + return reason + } + } + return "" +} + +// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. +// Returns the adjustment result. +func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult { + switch lim.Name { + case LimitationAudioChannels: + return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v }) + case LimitationAudioBitrate: + current := ts.Bitrate + if current == 0 { + current = sourceBitrate + } + return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v }) + case LimitationAudioSamplerate: + return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v }) + case LimitationAudioBitdepth: + if ts.BitDepth > 0 { + return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v }) + } + case LimitationAudioProfile: + // TODO: implement when audio profile data is available + } + return adjustNone +} + +// applyIntLimitation applies a limitation comparison to a value. +// If the value needs adjusting, calls the setter and returns the result. +func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult { + if len(values) == 0 { + return adjustNone + } + + switch comparison { + case ComparisonLessThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current <= limit { + return adjustNone + } + setter(limit) + return adjustAdjusted + case ComparisonGreaterThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current >= limit { + return adjustNone + } + // Cannot upscale + return adjustCannotFit + case ComparisonEquals: + // Check if current value matches any allowed value + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustNone + } + } + // Find the closest allowed value below current (don't upscale) + var closest int + found := false + for _, v := range values { + if limit, ok := parseInt(v); ok && limit < current { + if !found || limit > closest { + closest = limit + found = true + } + } + } + if found { + setter(closest) + return adjustAdjusted + } + return adjustCannotFit + case ComparisonNotEquals: + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustCannotFit + } + } + return adjustNone + } + + return adjustNone +} + +func checkIntLimitation(value int, comparison string, values []string) bool { + if len(values) == 0 { + return true + } + + switch comparison { + case ComparisonLessThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return true + } + return value <= limit + case ComparisonGreaterThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return true + } + return value >= limit + case ComparisonEquals: + for _, v := range values { + if limit, ok := parseInt(v); ok && value == limit { + return true + } + } + return false + case ComparisonNotEquals: + for _, v := range values { + if limit, ok := parseInt(v); ok && value == limit { + return false + } + } + return true + } + return true +} + +// checkStringLimitation checks a string value against a limitation. +// Only Equals and NotEquals comparisons are meaningful for strings. +// LessThanEqual/GreaterThanEqual are not applicable and always pass. +func checkStringLimitation(value string, comparison string, values []string) bool { + switch comparison { + case ComparisonEquals: + for _, v := range values { + if strings.EqualFold(value, v) { + return true + } + } + return false + case ComparisonNotEquals: + for _, v := range values { + if strings.EqualFold(value, v) { + return false + } + } + return true + } + return true +} + +func parseInt(s string) (int, bool) { + v, err := strconv.Atoi(s) + if err != nil || v < 0 { + return 0, false + } + return v, true +} diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 98f652a0b..acee439bb 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -2,8 +2,8 @@ package transcode import ( "context" - "slices" - "strconv" + "errors" + "fmt" "strings" "time" @@ -17,128 +17,6 @@ const ( defaultBitrate = 256 // kbps ) -// Decider is the core service interface for making transcoding decisions -type Decider interface { - MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) - CreateTranscodeParams(decision *Decision) (string, error) - ParseTranscodeParams(token string) (*Params, error) -} - -// ClientInfo represents client playback capabilities. -// All bitrate values are in kilobits per second (kbps) -type ClientInfo struct { - Name string - Platform string - MaxAudioBitrate int - MaxTranscodingAudioBitrate int - DirectPlayProfiles []DirectPlayProfile - TranscodingProfiles []Profile - CodecProfiles []CodecProfile -} - -// DirectPlayProfile describes a format the client can play directly -type DirectPlayProfile struct { - Containers []string - AudioCodecs []string - Protocols []string - MaxAudioChannels int -} - -// Profile describes a transcoding target the client supports -type Profile struct { - Container string - AudioCodec string - Protocol string - MaxAudioChannels int -} - -// CodecProfile describes codec-specific limitations -type CodecProfile struct { - Type string - Name string - Limitations []Limitation -} - -// Limitation describes a specific codec limitation -type Limitation struct { - Name string - Comparison string - Values []string - Required bool -} - -// Protocol values (OpenSubsonic spec enum) -const ( - ProtocolHTTP = "http" - ProtocolHLS = "hls" -) - -// Comparison operators (OpenSubsonic spec enum) -const ( - ComparisonEquals = "Equals" - ComparisonNotEquals = "NotEquals" - ComparisonLessThanEqual = "LessThanEqual" - ComparisonGreaterThanEqual = "GreaterThanEqual" -) - -// Limitation names (OpenSubsonic spec enum) -const ( - LimitationAudioChannels = "audioChannels" - LimitationAudioBitrate = "audioBitrate" - LimitationAudioProfile = "audioProfile" - LimitationAudioSamplerate = "audioSamplerate" - LimitationAudioBitdepth = "audioBitdepth" -) - -// Codec profile types (OpenSubsonic spec enum) -const ( - CodecProfileTypeAudio = "AudioCodec" -) - -// Decision represents the internal decision result. -// All bitrate values are in kilobits per second (kbps). -type Decision struct { - MediaID string - CanDirectPlay bool - CanTranscode bool - TranscodeReasons []string - ErrorReason string - TargetFormat string - TargetBitrate int - TargetChannels int - TargetSampleRate int - TargetBitDepth int - SourceStream StreamDetails - TranscodeStream *StreamDetails -} - -// StreamDetails describes audio stream properties. -// Bitrate is in kilobits per second (kbps). -type StreamDetails struct { - Container string - Codec string - Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added. - Bitrate int - SampleRate int - BitDepth int - Channels int - Duration float32 - Size int64 - IsLossless bool -} - -// Params contains the parameters extracted from a transcode token. -// TargetBitrate is in kilobits per second (kbps). -type Params struct { - MediaID string - DirectPlay bool - TargetFormat string - TargetBitrate int - TargetChannels int - TargetSampleRate int - TargetBitDepth int -} - func NewDecider(ds model.DataStore) Decider { return &deciderService{ ds: ds, @@ -161,17 +39,7 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, "sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name) // Build source stream details - decision.SourceStream = StreamDetails{ - Container: mf.Suffix, - Codec: mf.AudioCodec(), - Bitrate: sourceBitrate, - SampleRate: mf.SampleRate, - BitDepth: mf.BitDepth, - Channels: mf.Channels, - Duration: mf.Duration, - Size: mf.Size, - IsLossless: mf.IsLossless(), - } + decision.SourceStream = buildSourceStream(mf) // Check global bitrate constraint first. if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate { @@ -228,6 +96,20 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, return decision, nil } +func buildSourceStream(mf *model.MediaFile) StreamDetails { + return StreamDetails{ + Container: mf.Suffix, + Codec: mf.AudioCodec(), + Bitrate: mf.BitRate, + SampleRate: mf.SampleRate, + BitDepth: mf.BitDepth, + Channels: mf.Channels, + Duration: mf.Duration, + Size: mf.Size, + IsLossless: mf.IsLossless(), + } +} + // checkDirectPlayProfile returns "" if the profile matches (direct play OK), // or a typed reason string if it doesn't match. func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, profile *DirectPlayProfile, clientInfo *ClientInfo) string { @@ -263,50 +145,6 @@ func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitra return "" } -// checkLimitations checks codec profile limitations against source media. -// Returns "" if all limitations pass, or a typed reason string for the first failure. -func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string { - for _, lim := range limitations { - var ok bool - var reason string - - switch lim.Name { - case LimitationAudioChannels: - ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values) - reason = "audio channels not supported" - case LimitationAudioSamplerate: - ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values) - reason = "audio samplerate not supported" - case LimitationAudioBitrate: - ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values) - reason = "audio bitrate not supported" - case LimitationAudioBitdepth: - ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values) - reason = "audio bitdepth not supported" - case LimitationAudioProfile: - // TODO: populate source profile when MediaFile has audio profile info - ok = checkStringLimitation("", lim.Comparison, lim.Values) - reason = "audio profile not supported" - default: - continue - } - - if !ok && lim.Required { - return reason - } - } - return "" -} - -// adjustResult represents the outcome of applying a limitation to a transcoded stream value -type adjustResult int - -const ( - adjustNone adjustResult = iota // Value already satisfies the limitation - adjustAdjusted // Value was changed to fit the limitation - adjustCannotFit // Cannot satisfy the limitation (reject this profile) -) - // computeTranscodedStream attempts to build a valid transcoded stream for the given profile. // Returns the stream details and the internal transcoding format (which may differ from the // response container when a codec fallback occurs, e.g., "mp4"→"aac"). @@ -380,17 +218,21 @@ func (s *deciderService) resolveTargetFormat(ctx context.Context, profile *Profi } // Try the container first, then fall back to the audioCodec (e.g. "ogg" → "opus", "mp4" → "aac"). - tc, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat) - if (err != nil || tc == nil) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) { + _, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat) + if errors.Is(err, model.ErrNotFound) && profile.AudioCodec != "" && !strings.EqualFold(targetFormat, profile.AudioCodec) { codec := strings.ToLower(profile.AudioCodec) log.Trace(ctx, "No transcoding config for container, trying audioCodec", "container", targetFormat, "audioCodec", codec) - tc, err = s.ds.Transcoding(ctx).FindByFormat(codec) - if err == nil && tc != nil { + _, err = s.ds.Transcoding(ctx).FindByFormat(codec) + if err == nil { targetFormat = codec } } - if err != nil || tc == nil { - log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat) + if err != nil { + if !errors.Is(err, model.ErrNotFound) { + log.Error(ctx, "Error looking up transcoding config", "format", targetFormat, err) + } else { + log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat) + } return "", "" } return responseContainer, targetFormat @@ -453,93 +295,6 @@ func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrat return true } -// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. -// Returns the adjustment result. -func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult { - switch lim.Name { - case LimitationAudioChannels: - return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v }) - case LimitationAudioBitrate: - current := ts.Bitrate - if current == 0 { - current = sourceBitrate - } - return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v }) - case LimitationAudioSamplerate: - return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v }) - case LimitationAudioBitdepth: - if ts.BitDepth > 0 { - return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v }) - } - case LimitationAudioProfile: - // TODO: implement when audio profile data is available - } - return adjustNone -} - -// applyIntLimitation applies a limitation comparison to a value. -// If the value needs adjusting, calls the setter and returns the result. -func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult { - if len(values) == 0 { - return adjustNone - } - - switch comparison { - case ComparisonLessThanEqual: - limit, ok := parseInt(values[0]) - if !ok { - return adjustNone - } - if current <= limit { - return adjustNone - } - setter(limit) - return adjustAdjusted - case ComparisonGreaterThanEqual: - limit, ok := parseInt(values[0]) - if !ok { - return adjustNone - } - if current >= limit { - return adjustNone - } - // Cannot upscale - return adjustCannotFit - case ComparisonEquals: - // Check if current value matches any allowed value - for _, v := range values { - if limit, ok := parseInt(v); ok && current == limit { - return adjustNone - } - } - // Find the closest allowed value below current (don't upscale) - var closest int - found := false - for _, v := range values { - if limit, ok := parseInt(v); ok && limit < current { - if !found || limit > closest { - closest = limit - found = true - } - } - } - if found { - setter(closest) - return adjustAdjusted - } - return adjustCannotFit - case ComparisonNotEquals: - for _, v := range values { - if limit, ok := parseInt(v); ok && current == limit { - return adjustCannotFit - } - } - return adjustNone - } - - return adjustNone -} - func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { exp := time.Now().Add(tokenTTL) claims := map[string]any{ @@ -569,14 +324,23 @@ func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) { } params := &Params{} - if mid, ok := claims["mid"].(string); ok { - params.MediaID = mid + + // Required claims + mid, ok := claims["mid"].(string) + if !ok || mid == "" { + return nil, fmt.Errorf("invalid transcode token: missing media ID") } - if dp, ok := claims["dp"].(bool); ok { - params.DirectPlay = dp + params.MediaID = mid + + dp, ok := claims["dp"].(bool) + if !ok { + return nil, fmt.Errorf("invalid transcode token: missing direct play flag") } - if fmt, ok := claims["fmt"].(string); ok { - params.TargetFormat = fmt + params.DirectPlay = dp + + // Optional claims (legitimately absent for direct-play tokens) + if f, ok := claims["fmt"].(string); ok { + params.TargetFormat = f } if br, ok := claims["br"].(float64); ok { params.TargetBitrate = int(br) @@ -593,203 +357,3 @@ func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) { return params, nil } - -func containsIgnoreCase(slice []string, s string) bool { - return slices.ContainsFunc(slice, func(item string) bool { - return strings.EqualFold(item, s) - }) -} - -// containerAliasGroups maps each container alias to a canonical group name. -var containerAliasGroups = func() map[string]string { - groups := [][]string{ - {"aac", "adts", "m4a", "mp4", "m4b", "m4p"}, - {"mpeg", "mp3", "mp2"}, - {"ogg", "oga"}, - {"aif", "aiff"}, - {"asf", "wma"}, - {"mpc", "mpp"}, - {"wv"}, - } - m := make(map[string]string) - for _, g := range groups { - canonical := g[0] - for _, name := range g { - m[name] = canonical - } - } - return m -}() - -// matchesWithAliases checks if a value matches any entry in candidates, -// consulting the alias map for equivalent names. -func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool { - value = strings.ToLower(value) - canonical := aliases[value] - for _, c := range candidates { - c = strings.ToLower(c) - if c == value { - return true - } - if canonical != "" && aliases[c] == canonical { - return true - } - } - return false -} - -// matchesContainer checks if a file suffix matches any of the container names, -// including common aliases. -func matchesContainer(suffix string, containers []string) bool { - return matchesWithAliases(suffix, containers, containerAliasGroups) -} - -// codecAliasGroups maps each codec alias to a canonical group name. -// Codecs within the same group are considered equivalent. -var codecAliasGroups = func() map[string]string { - groups := [][]string{ - {"aac", "adts"}, - {"ac3", "ac-3"}, - {"eac3", "e-ac3", "e-ac-3", "eac-3"}, - {"mpc7", "musepack7"}, - {"mpc8", "musepack8"}, - {"wma1", "wmav1"}, - {"wma2", "wmav2"}, - {"wmalossless", "wma9lossless"}, - {"wmapro", "wma9pro"}, - {"shn", "shorten"}, - {"mp4als", "als"}, - } - m := make(map[string]string) - for _, g := range groups { - for _, name := range g { - m[name] = g[0] // canonical = first entry - } - } - return m -}() - -// matchesCodec checks if a codec matches any of the codec names, -// including common aliases. -func matchesCodec(codec string, codecs []string) bool { - return matchesWithAliases(codec, codecs, codecAliasGroups) -} - -func checkIntLimitation(value int, comparison string, values []string) bool { - if len(values) == 0 { - return true - } - - switch comparison { - case ComparisonLessThanEqual: - limit, ok := parseInt(values[0]) - if !ok { - return true - } - return value <= limit - case ComparisonGreaterThanEqual: - limit, ok := parseInt(values[0]) - if !ok { - return true - } - return value >= limit - case ComparisonEquals: - for _, v := range values { - if limit, ok := parseInt(v); ok && value == limit { - return true - } - } - return false - case ComparisonNotEquals: - for _, v := range values { - if limit, ok := parseInt(v); ok && value == limit { - return false - } - } - return true - } - return true -} - -// checkStringLimitation checks a string value against a limitation. -// Only Equals and NotEquals comparisons are meaningful for strings. -// LessThanEqual/GreaterThanEqual are not applicable and always pass. -func checkStringLimitation(value string, comparison string, values []string) bool { - switch comparison { - case ComparisonEquals: - for _, v := range values { - if strings.EqualFold(value, v) { - return true - } - } - return false - case ComparisonNotEquals: - for _, v := range values { - if strings.EqualFold(value, v) { - return false - } - } - return true - } - return true -} - -func parseInt(s string) (int, bool) { - v, err := strconv.Atoi(s) - if err != nil || v < 0 { - return 0, false - } - return v, true -} - -func isLosslessFormat(format string) bool { - switch strings.ToLower(format) { - case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd": - return true - } - return false -} - -// normalizeSourceSampleRate adjusts the source sample rate for codecs that store -// it differently than PCM. Currently handles DSD (÷8): -// DSD64=2822400→352800, DSD128=5644800→705600, etc. -// For other codecs, returns the rate unchanged. -func normalizeSourceSampleRate(sampleRate int, codec string) int { - if strings.EqualFold(codec, "dsd") && sampleRate > 0 { - return sampleRate / 8 - } - return sampleRate -} - -// normalizeSourceBitDepth adjusts the source bit depth for codecs that use -// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is -// what ffmpeg produces). For other codecs, returns the depth unchanged. -func normalizeSourceBitDepth(bitDepth int, codec string) int { - if strings.EqualFold(codec, "dsd") && bitDepth == 1 { - return 24 - } - return bitDepth -} - -// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs -// that always resample regardless of input (e.g., Opus always outputs 48000Hz). -// Returns 0 if the codec has no fixed output rate. -func codecFixedOutputSampleRate(codec string) int { - switch strings.ToLower(codec) { - case "opus": - return 48000 - } - return 0 -} - -// codecMaxSampleRate returns the hard maximum output sample rate for a codec. -// Returns 0 if the codec has no hard limit. -func codecMaxSampleRate(codec string) int { - switch strings.ToLower(codec) { - case "mp3": - return 48000 - case "aac": - return 96000 - } - return 0 -} diff --git a/core/transcode/types.go b/core/transcode/types.go new file mode 100644 index 000000000..13344740c --- /dev/null +++ b/core/transcode/types.go @@ -0,0 +1,129 @@ +package transcode + +import ( + "context" + + "github.com/navidrome/navidrome/model" +) + +// Decider is the core service interface for making transcoding decisions +type Decider interface { + MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) + CreateTranscodeParams(decision *Decision) (string, error) + ParseTranscodeParams(token string) (*Params, error) +} + +// ClientInfo represents client playback capabilities. +// All bitrate values are in kilobits per second (kbps) +type ClientInfo struct { + Name string + Platform string + MaxAudioBitrate int + MaxTranscodingAudioBitrate int + DirectPlayProfiles []DirectPlayProfile + TranscodingProfiles []Profile + CodecProfiles []CodecProfile +} + +// DirectPlayProfile describes a format the client can play directly +type DirectPlayProfile struct { + Containers []string + AudioCodecs []string + Protocols []string + MaxAudioChannels int +} + +// Profile describes a transcoding target the client supports +type Profile struct { + Container string + AudioCodec string + Protocol string + MaxAudioChannels int +} + +// CodecProfile describes codec-specific limitations +type CodecProfile struct { + Type string + Name string + Limitations []Limitation +} + +// Limitation describes a specific codec limitation +type Limitation struct { + Name string + Comparison string + Values []string + Required bool +} + +// Protocol values (OpenSubsonic spec enum) +const ( + ProtocolHTTP = "http" + ProtocolHLS = "hls" +) + +// Comparison operators (OpenSubsonic spec enum) +const ( + ComparisonEquals = "Equals" + ComparisonNotEquals = "NotEquals" + ComparisonLessThanEqual = "LessThanEqual" + ComparisonGreaterThanEqual = "GreaterThanEqual" +) + +// Limitation names (OpenSubsonic spec enum) +const ( + LimitationAudioChannels = "audioChannels" + LimitationAudioBitrate = "audioBitrate" + LimitationAudioProfile = "audioProfile" + LimitationAudioSamplerate = "audioSamplerate" + LimitationAudioBitdepth = "audioBitdepth" +) + +// Codec profile types (OpenSubsonic spec enum) +const ( + CodecProfileTypeAudio = "AudioCodec" +) + +// Decision represents the internal decision result. +// All bitrate values are in kilobits per second (kbps). +type Decision struct { + MediaID string + CanDirectPlay bool + CanTranscode bool + TranscodeReasons []string + ErrorReason string + TargetFormat string + TargetBitrate int + TargetChannels int + TargetSampleRate int + TargetBitDepth int + SourceStream StreamDetails + TranscodeStream *StreamDetails +} + +// StreamDetails describes audio stream properties. +// Bitrate is in kilobits per second (kbps). +type StreamDetails struct { + Container string + Codec string + Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added. + Bitrate int + SampleRate int + BitDepth int + Channels int + Duration float32 + Size int64 + IsLossless bool +} + +// Params contains the parameters extracted from a transcode token. +// TargetBitrate is in kilobits per second (kbps). +type Params struct { + MediaID string + DirectPlay bool + TargetFormat string + TargetBitrate int + TargetChannels int + TargetSampleRate int + TargetBitDepth int +} diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 1cd92e30c..27240b1fe 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/req" @@ -22,7 +23,9 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0, 0, 0, 0) + stream, err := pub.streamer.NewStream(ctx, core.StreamRequest{ + ID: info.id, Format: info.format, BitRate: info.bitrate, + }) if err != nil { log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index e7b5701ce..9540f32c8 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -60,7 +60,9 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su format, _ := p.String("format") timeOffset := p.IntOr("timeOffset", 0) - stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0, 0, 0, timeOffset) + stream, err := api.streamer.NewStream(ctx, core.StreamRequest{ + ID: id, Format: format, BitRate: maxBitRate, Offset: timeOffset, + }) if err != nil { return nil, err } @@ -129,7 +131,9 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: - stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0, 0, 0, 0) + stream, err := api.streamer.NewStream(ctx, core.StreamRequest{ + ID: id, Format: format, BitRate: maxBitRate, + }) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 81a927d8c..c1d195249 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -4,8 +4,10 @@ import ( "encoding/json" "fmt" "net/http" + "slices" "strconv" + "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -16,40 +18,40 @@ import ( // clientInfoRequest represents client playback capabilities from the request body type clientInfoRequest struct { - Name string `json:"name,omitempty"` - Platform string `json:"platform,omitempty"` - MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"` - MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"` - DirectPlayProfiles []directPlayProfileReq `json:"directPlayProfiles,omitempty"` - TranscodingProfiles []transcodingProfileReq `json:"transcodingProfiles,omitempty"` - CodecProfiles []codecProfileReq `json:"codecProfiles,omitempty"` + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"` + MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"` + DirectPlayProfiles []directPlayProfileRequest `json:"directPlayProfiles,omitempty"` + TranscodingProfiles []transcodingProfileRequest `json:"transcodingProfiles,omitempty"` + CodecProfiles []codecProfileRequest `json:"codecProfiles,omitempty"` } -// directPlayProfileReq describes a format the client can play directly -type directPlayProfileReq struct { +// directPlayProfileRequest describes a format the client can play directly +type directPlayProfileRequest struct { Containers []string `json:"containers,omitempty"` AudioCodecs []string `json:"audioCodecs,omitempty"` Protocols []string `json:"protocols,omitempty"` MaxAudioChannels int `json:"maxAudioChannels,omitempty"` } -// transcodingProfileReq describes a transcoding target the client supports -type transcodingProfileReq struct { +// transcodingProfileRequest describes a transcoding target the client supports +type transcodingProfileRequest struct { Container string `json:"container,omitempty"` AudioCodec string `json:"audioCodec,omitempty"` Protocol string `json:"protocol,omitempty"` MaxAudioChannels int `json:"maxAudioChannels,omitempty"` } -// codecProfileReq describes codec-specific limitations -type codecProfileReq struct { - Type string `json:"type,omitempty"` - Name string `json:"name,omitempty"` - Limitations []limitationReq `json:"limitations,omitempty"` +// codecProfileRequest describes codec-specific limitations +type codecProfileRequest struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Limitations []limitationRequest `json:"limitations,omitempty"` } -// limitationReq describes a specific codec limitation -type limitationReq struct { +// limitationRequest describes a specific codec limitation +type limitationRequest struct { Name string `json:"name,omitempty"` Comparison string `json:"comparison,omitempty"` Values []string `json:"values,omitempty"` @@ -124,7 +126,7 @@ func convertBitrateValues(bpsValues []string) []string { for i, v := range bpsValues { n, err := strconv.Atoi(v) if err == nil { - result[i] = strconv.Itoa(n / 1000) + result[i] = strconv.Itoa(bpsToKbps(n)) } else { result[i] = v // preserve unparseable values as-is } @@ -162,27 +164,44 @@ func (r *clientInfoRequest) validate() error { return nil } +var validProtocols = []string{ + transcode.ProtocolHTTP, + transcode.ProtocolHLS, +} + func isValidProtocol(p string) bool { - return p == transcode.ProtocolHTTP || p == transcode.ProtocolHLS + return slices.Contains(validProtocols, p) +} + +var validCodecProfileTypes = []string{ + transcode.CodecProfileTypeAudio, } func isValidCodecProfileType(t string) bool { - return t == transcode.CodecProfileTypeAudio + return slices.Contains(validCodecProfileTypes, t) +} + +var validLimitationNames = []string{ + transcode.LimitationAudioChannels, + transcode.LimitationAudioBitrate, + transcode.LimitationAudioProfile, + transcode.LimitationAudioSamplerate, + transcode.LimitationAudioBitdepth, } func isValidLimitationName(n string) bool { - return n == transcode.LimitationAudioChannels || - n == transcode.LimitationAudioBitrate || - n == transcode.LimitationAudioProfile || - n == transcode.LimitationAudioSamplerate || - n == transcode.LimitationAudioBitdepth + return slices.Contains(validLimitationNames, n) +} + +var validComparisons = []string{ + transcode.ComparisonEquals, + transcode.ComparisonNotEquals, + transcode.ComparisonLessThanEqual, + transcode.ComparisonGreaterThanEqual, } func isValidComparison(c string) bool { - return c == transcode.ComparisonEquals || - c == transcode.ComparisonNotEquals || - c == transcode.ComparisonLessThanEqual || - c == transcode.ComparisonGreaterThanEqual + return slices.Contains(validComparisons, c) } // GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint. @@ -320,25 +339,18 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* return nil, newError(responses.ErrorDataNotFound, "mediaId does not match token") } - // Determine streaming parameters - format := "" - maxBitRate := 0 - sampleRate := 0 - bitDepth := 0 - channels := 0 + // Build streaming parameters from the token + streamReq := core.StreamRequest{ID: mediaID, Offset: p.IntOr("offset", 0)} if !params.DirectPlay && params.TargetFormat != "" { - format = params.TargetFormat - maxBitRate = params.TargetBitrate // Already in kbps, matching the streamer - sampleRate = params.TargetSampleRate - bitDepth = params.TargetBitDepth - channels = params.TargetChannels + streamReq.Format = params.TargetFormat + streamReq.BitRate = params.TargetBitrate // Already in kbps, matching the streamer + streamReq.SampleRate = params.TargetSampleRate + streamReq.BitDepth = params.TargetBitDepth + streamReq.Channels = params.TargetChannels } - // Get offset parameter - offset := p.IntOr("offset", 0) - // Create stream - stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, sampleRate, bitDepth, channels, offset) + stream, err := api.streamer.NewStream(ctx, streamReq) if err != nil { return nil, err }