diff --git a/core/transcode/codec_test.go b/core/transcode/codec_test.go new file mode 100644 index 000000000..6d3fbd78c --- /dev/null +++ b/core/transcode/codec_test.go @@ -0,0 +1,69 @@ +package transcode + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Codec", func() { + Describe("isLosslessFormat", func() { + It("returns true for known lossless codecs", func() { + Expect(isLosslessFormat("flac")).To(BeTrue()) + Expect(isLosslessFormat("alac")).To(BeTrue()) + Expect(isLosslessFormat("pcm")).To(BeTrue()) + Expect(isLosslessFormat("wav")).To(BeTrue()) + Expect(isLosslessFormat("dsd")).To(BeTrue()) + Expect(isLosslessFormat("ape")).To(BeTrue()) + Expect(isLosslessFormat("wv")).To(BeTrue()) + Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack + }) + + It("returns false for lossy codecs", func() { + Expect(isLosslessFormat("mp3")).To(BeFalse()) + Expect(isLosslessFormat("aac")).To(BeFalse()) + Expect(isLosslessFormat("opus")).To(BeFalse()) + Expect(isLosslessFormat("vorbis")).To(BeFalse()) + }) + + It("returns false for unknown codecs", func() { + Expect(isLosslessFormat("unknown_codec")).To(BeFalse()) + }) + + It("is case-insensitive", func() { + Expect(isLosslessFormat("FLAC")).To(BeTrue()) + Expect(isLosslessFormat("Alac")).To(BeTrue()) + }) + }) + + Describe("normalizeProbeCodec", func() { + It("passes through common codec names unchanged", func() { + Expect(normalizeProbeCodec("mp3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("aac")).To(Equal("aac")) + Expect(normalizeProbeCodec("flac")).To(Equal("flac")) + Expect(normalizeProbeCodec("opus")).To(Equal("opus")) + Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis")) + Expect(normalizeProbeCodec("alac")).To(Equal("alac")) + Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2")) + }) + + It("normalizes DSD variants to dsd", func() { + Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd")) + }) + + It("normalizes PCM variants to pcm", func() { + Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm")) + }) + + It("lowercases input", func() { + Expect(normalizeProbeCodec("MP3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("AAC")).To(Equal("aac")) + Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd")) + }) + }) +}) diff --git a/core/transcode/transcode.go b/core/transcode/decider.go similarity index 78% rename from core/transcode/transcode.go rename to core/transcode/decider.go index dbc3e9389..3dd4a7b4a 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/decider.go @@ -2,26 +2,27 @@ package transcode import ( "context" - "errors" + "encoding/json" "fmt" "strings" - "time" - - "encoding/json" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" - "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" ) -const ( - tokenTTL = 12 * time.Hour - defaultBitrate = 256 // kbps -) +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, opts DecisionOptions) (*Decision, error) + CreateTranscodeParams(decision *Decision) (string, error) + ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) + ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest +} func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider { return &deciderService{ @@ -422,123 +423,3 @@ func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) "sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels) return result, nil } - -// buildLegacyClientInfo translates legacy Subsonic stream/download parameters -// into a ClientInfo for use with MakeDecision. -// It does NOT read request.TranscodingFrom(ctx) — that is handled by -// MakeDecision's applyServerOverride. -func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { - ci := &ClientInfo{Name: "legacy"} - - // Determine target format for transcoding - var targetFormat string - switch { - case reqFormat != "": - targetFormat = reqFormat - case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": - targetFormat = conf.Server.DefaultDownsamplingFormat - } - - if targetFormat != "" { - ci.DirectPlayProfiles = []DirectPlayProfile{ - {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, - } - ci.TranscodingProfiles = []Profile{ - {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP}, - } - if reqBitRate > 0 { - ci.MaxAudioBitrate = reqBitRate - ci.MaxTranscodingAudioBitrate = reqBitRate - } - } else { - // No transcoding requested — direct play everything - ci.DirectPlayProfiles = []DirectPlayProfile{ - {Protocols: []string{ProtocolHTTP}}, - } - } - - return ci -} - -// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters -// into a fully specified StreamRequest. -func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest { - var req StreamRequest - req.ID = mf.ID - req.Offset = offset - - if reqFormat == "raw" { - req.Format = "raw" - return req - } - - clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) - decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{SkipProbe: true}) - if err != nil { - log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) - req.Format = "raw" - return req - } - - if decision.CanDirectPlay { - req.Format = "raw" - return req - } - - if decision.CanTranscode { - req.Format = decision.TargetFormat - req.BitRate = decision.TargetBitrate - req.SampleRate = decision.TargetSampleRate - req.BitDepth = decision.TargetBitDepth - req.Channels = decision.TargetChannels - return req - } - - // No compatible profile — fallback to raw - req.Format = "raw" - return req -} - -func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { - return auth.EncodeToken(decision.toClaimsMap()) -} - -func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) { - token, err := auth.DecodeAndVerifyToken(tokenStr) - if err != nil { - return nil, err - } - return paramsFromToken(token) -} - -func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) { - p, err := s.parseTranscodeParams(token) - if err != nil { - return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err) - } - if p.MediaID != mediaID { - return StreamRequest{}, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mediaID) - } - mf, err := s.ds.MediaFile(ctx).Get(mediaID) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return StreamRequest{}, nil, ErrMediaNotFound - } - return StreamRequest{}, nil, err - } - if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { - log.Info(ctx, "Transcode token is stale", "mediaID", mediaID, - "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) - return StreamRequest{}, nil, ErrTokenStale - } - - req := StreamRequest{ID: mediaID, Offset: offset} - if !p.DirectPlay && p.TargetFormat != "" { - req.Format = p.TargetFormat - req.BitRate = p.TargetBitrate - req.SampleRate = p.TargetSampleRate - req.BitDepth = p.TargetBitDepth - req.Channels = p.TargetChannels - } - return req, mf, nil -} diff --git a/core/transcode/transcode_test.go b/core/transcode/decider_test.go similarity index 81% rename from core/transcode/transcode_test.go rename to core/transcode/decider_test.go index cf690e81b..e5ad2f621 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/decider_test.go @@ -4,9 +4,7 @@ import ( "context" "encoding/json" "fmt" - "time" - "github.com/go-chi/jwtauth/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" @@ -1086,303 +1084,4 @@ var _ = Describe("Decider", func() { }) }) - Describe("Token round-trip", func() { - var ( - sourceTime time.Time - impl *deciderService - ) - - BeforeEach(func() { - sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) - impl = svc.(*deciderService) - }) - - It("creates and parses a direct play token", func() { - decision := &Decision{ - MediaID: "media-123", - CanDirectPlay: true, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - Expect(token).ToNot(BeEmpty()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-123")) - Expect(params.DirectPlay).To(BeTrue()) - Expect(params.TargetFormat).To(BeEmpty()) - Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) - }) - - It("creates and parses a transcode token with kbps bitrate", func() { - decision := &Decision{ - MediaID: "media-456", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "mp3", - TargetBitrate: 256, // kbps - TargetChannels: 2, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-456")) - Expect(params.DirectPlay).To(BeFalse()) - Expect(params.TargetFormat).To(Equal("mp3")) - Expect(params.TargetBitrate).To(Equal(256)) // kbps - Expect(params.TargetChannels).To(Equal(2)) - Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) - }) - - It("creates and parses a transcode token with sample rate", func() { - decision := &Decision{ - MediaID: "media-789", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "flac", - TargetBitrate: 0, - TargetChannels: 2, - TargetSampleRate: 48000, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-789")) - Expect(params.DirectPlay).To(BeFalse()) - Expect(params.TargetFormat).To(Equal("flac")) - Expect(params.TargetSampleRate).To(Equal(48000)) - Expect(params.TargetChannels).To(Equal(2)) - }) - - It("creates and parses a transcode token with bit depth", func() { - decision := &Decision{ - MediaID: "media-bd", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "flac", - TargetBitrate: 0, - TargetChannels: 2, - TargetBitDepth: 24, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("media-bd")) - Expect(params.TargetBitDepth).To(Equal(24)) - }) - - It("omits bit depth from token when 0", func() { - decision := &Decision{ - MediaID: "media-nobd", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "mp3", - TargetBitrate: 256, - TargetBitDepth: 0, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.TargetBitDepth).To(Equal(0)) - }) - - It("omits sample rate from token when 0", func() { - decision := &Decision{ - MediaID: "media-100", - CanDirectPlay: false, - CanTranscode: true, - TargetFormat: "mp3", - TargetBitrate: 256, - TargetSampleRate: 0, - SourceUpdatedAt: sourceTime, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.TargetSampleRate).To(Equal(0)) - }) - - It("truncates SourceUpdatedAt to seconds", func() { - timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC) - decision := &Decision{ - MediaID: "media-trunc", - CanDirectPlay: true, - SourceUpdatedAt: timeWithNanos, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - - params, err := impl.parseTranscodeParams(token) - Expect(err).ToNot(HaveOccurred()) - Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix())) - }) - - It("rejects an invalid token", func() { - _, err := impl.parseTranscodeParams("invalid-token") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("ResolveRequestFromToken", func() { - var ( - mockMFRepo *tests.MockMediaFileRepo - sourceTime time.Time - ) - - BeforeEach(func() { - sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) - mockMFRepo = &tests.MockMediaFileRepo{} - ds.MockedMediaFile = mockMFRepo - }) - - createTokenForMedia := func(mediaID string, updatedAt time.Time) string { - decision := &Decision{ - MediaID: mediaID, - CanDirectPlay: true, - SourceUpdatedAt: updatedAt, - } - token, err := svc.CreateTranscodeParams(decision) - Expect(err).ToNot(HaveOccurred()) - return token - } - - It("returns stream request and media file for valid token", func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", UpdatedAt: sourceTime}, - }) - token := createTokenForMedia("song-1", sourceTime) - - req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(req.ID).To(Equal("song-1")) - Expect(req.Format).To(BeEmpty()) // direct play has no target format - Expect(mf.ID).To(Equal("song-1")) - }) - - It("returns ErrTokenInvalid for invalid token", func() { - _, _, err := svc.ResolveRequestFromToken(ctx, "bad-token", "song-1", 0) - Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) - }) - - It("returns ErrTokenInvalid when mediaID does not match token", func() { - token := createTokenForMedia("song-1", sourceTime) - - _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0) - Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) - }) - - It("returns ErrMediaNotFound when media file does not exist", func() { - token := createTokenForMedia("gone-id", sourceTime) - - _, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0) - Expect(err).To(MatchError(ErrMediaNotFound)) - }) - - It("returns ErrTokenStale when media file has changed", func() { - newTime := sourceTime.Add(1 * time.Hour) - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", UpdatedAt: newTime}, - }) - token := createTokenForMedia("song-1", sourceTime) - - _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) - Expect(err).To(MatchError(ErrTokenStale)) - }) - }) - - Describe("isLosslessFormat", func() { - It("returns true for known lossless codecs", func() { - Expect(isLosslessFormat("flac")).To(BeTrue()) - Expect(isLosslessFormat("alac")).To(BeTrue()) - Expect(isLosslessFormat("pcm")).To(BeTrue()) - Expect(isLosslessFormat("wav")).To(BeTrue()) - Expect(isLosslessFormat("dsd")).To(BeTrue()) - Expect(isLosslessFormat("ape")).To(BeTrue()) - Expect(isLosslessFormat("wv")).To(BeTrue()) - Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack - }) - - It("returns false for lossy codecs", func() { - Expect(isLosslessFormat("mp3")).To(BeFalse()) - Expect(isLosslessFormat("aac")).To(BeFalse()) - Expect(isLosslessFormat("opus")).To(BeFalse()) - Expect(isLosslessFormat("vorbis")).To(BeFalse()) - }) - - It("returns false for unknown codecs", func() { - Expect(isLosslessFormat("unknown_codec")).To(BeFalse()) - }) - - It("is case-insensitive", func() { - Expect(isLosslessFormat("FLAC")).To(BeTrue()) - Expect(isLosslessFormat("Alac")).To(BeTrue()) - }) - }) - - Describe("normalizeProbeCodec", func() { - It("passes through common codec names unchanged", func() { - Expect(normalizeProbeCodec("mp3")).To(Equal("mp3")) - Expect(normalizeProbeCodec("aac")).To(Equal("aac")) - Expect(normalizeProbeCodec("flac")).To(Equal("flac")) - Expect(normalizeProbeCodec("opus")).To(Equal("opus")) - Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis")) - Expect(normalizeProbeCodec("alac")).To(Equal("alac")) - Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2")) - }) - - It("normalizes DSD variants to dsd", func() { - Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd")) - Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd")) - Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd")) - Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd")) - }) - - It("normalizes PCM variants to pcm", func() { - Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm")) - Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm")) - Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm")) - Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm")) - }) - - It("lowercases input", func() { - Expect(normalizeProbeCodec("MP3")).To(Equal("mp3")) - Expect(normalizeProbeCodec("AAC")).To(Equal("aac")) - Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd")) - }) - }) - - Describe("paramsFromToken", func() { - It("returns error when media ID is missing", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)}) - Expect(err).NotTo(HaveOccurred()) - - _, err = paramsFromToken(token) - Expect(err).To(MatchError(ContainSubstring("missing media ID"))) - }) - - It("returns error when source timestamp is missing", func() { - tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) - token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"}) - Expect(err).NotTo(HaveOccurred()) - - _, err = paramsFromToken(token) - Expect(err).To(MatchError(ContainSubstring("missing source timestamp"))) - }) - }) }) diff --git a/core/transcode/legacy_client.go b/core/transcode/legacy_client.go new file mode 100644 index 000000000..83190ec92 --- /dev/null +++ b/core/transcode/legacy_client.go @@ -0,0 +1,85 @@ +package transcode + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// buildLegacyClientInfo translates legacy Subsonic stream/download parameters +// into a ClientInfo for use with MakeDecision. +// It does NOT read request.TranscodingFrom(ctx) — that is handled by +// MakeDecision's applyServerOverride. +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { + ci := &ClientInfo{Name: "legacy"} + + // Determine target format for transcoding + var targetFormat string + switch { + case reqFormat != "": + targetFormat = reqFormat + case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": + targetFormat = conf.Server.DefaultDownsamplingFormat + } + + if targetFormat != "" { + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, + } + ci.TranscodingProfiles = []Profile{ + {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP}, + } + if reqBitRate > 0 { + ci.MaxAudioBitrate = reqBitRate + ci.MaxTranscodingAudioBitrate = reqBitRate + } + } else { + // No transcoding requested — direct play everything + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Protocols: []string{ProtocolHTTP}}, + } + } + + return ci +} + +// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters +// into a fully specified StreamRequest. +func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest { + var req StreamRequest + req.ID = mf.ID + req.Offset = offset + + if reqFormat == "raw" { + req.Format = "raw" + return req + } + + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req + } + + if decision.CanDirectPlay { + req.Format = "raw" + return req + } + + if decision.CanTranscode { + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + return req + } + + // No compatible profile — fallback to raw + req.Format = "raw" + return req +} diff --git a/core/transcode/token.go b/core/transcode/token.go new file mode 100644 index 000000000..e110320d0 --- /dev/null +++ b/core/transcode/token.go @@ -0,0 +1,155 @@ +package transcode + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const tokenTTL = 12 * time.Hour + +// 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 + SourceUpdatedAt time.Time +} + +// toClaimsMap converts a Decision into a JWT claims map for token encoding. +// Only non-zero transcode fields are included. +func (d *Decision) toClaimsMap() map[string]any { + m := map[string]any{ + "mid": d.MediaID, + "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(), + jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(), + } + if d.CanDirectPlay { + m["dp"] = true + } + if d.CanTranscode && d.TargetFormat != "" { + m["f"] = d.TargetFormat + if d.TargetBitrate != 0 { + m["b"] = d.TargetBitrate + } + if d.TargetChannels != 0 { + m["ch"] = d.TargetChannels + } + if d.TargetSampleRate != 0 { + m["sr"] = d.TargetSampleRate + } + if d.TargetBitDepth != 0 { + m["bd"] = d.TargetBitDepth + } + } + return m +} + +// paramsFromToken extracts and validates Params from a parsed JWT token. +// Returns an error if required claims (media ID, source timestamp) are missing. +func paramsFromToken(token jwt.Token) (*params, error) { + var p params + var mid string + if err := token.Get("mid", &mid); err == nil { + p.MediaID = mid + } + if p.MediaID == "" { + return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid) + } + + var dp bool + if err := token.Get("dp", &dp); err == nil { + p.DirectPlay = dp + } + + ua := getIntClaim(token, "ua") + if ua != 0 { + p.SourceUpdatedAt = time.Unix(int64(ua), 0) + } + if p.SourceUpdatedAt.IsZero() { + return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid) + } + + var f string + if err := token.Get("f", &f); err == nil { + p.TargetFormat = f + } + p.TargetBitrate = getIntClaim(token, "b") + p.TargetChannels = getIntClaim(token, "ch") + p.TargetSampleRate = getIntClaim(token, "sr") + p.TargetBitDepth = getIntClaim(token, "bd") + return &p, nil +} + +// getIntClaim extracts an int claim from a JWT token, handling the case where +// the value may be stored as int64 or float64 (common in JSON-based JWT libraries). +func getIntClaim(token jwt.Token, key string) int { + var v int + if err := token.Get(key, &v); err == nil { + return v + } + var v64 int64 + if err := token.Get(key, &v64); err == nil { + return int(v64) + } + var f float64 + if err := token.Get(key, &f); err == nil { + return int(f) + } + return 0 +} + +func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { + return auth.EncodeToken(decision.toClaimsMap()) +} + +func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) { + token, err := auth.DecodeAndVerifyToken(tokenStr) + if err != nil { + return nil, err + } + return paramsFromToken(token) +} + +func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) { + p, err := s.parseTranscodeParams(token) + if err != nil { + return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err) + } + if p.MediaID != mediaID { + return StreamRequest{}, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mediaID) + } + mf, err := s.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return StreamRequest{}, nil, ErrMediaNotFound + } + return StreamRequest{}, nil, err + } + if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { + log.Info(ctx, "Transcode token is stale", "mediaID", mediaID, + "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) + return StreamRequest{}, nil, ErrTokenStale + } + + req := StreamRequest{ID: mediaID, Offset: offset} + if !p.DirectPlay && p.TargetFormat != "" { + req.Format = p.TargetFormat + req.BitRate = p.TargetBitrate + req.SampleRate = p.TargetSampleRate + req.BitDepth = p.TargetBitDepth + req.Channels = p.TargetChannels + } + return req, mf, nil +} diff --git a/core/transcode/token_test.go b/core/transcode/token_test.go new file mode 100644 index 000000000..b9b74c8fc --- /dev/null +++ b/core/transcode/token_test.go @@ -0,0 +1,272 @@ +package transcode + +import ( + "context" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Token", func() { + var ( + ds *tests.MockDataStore + ff *tests.MockFFmpeg + svc Decider + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + ff = tests.NewMockFFmpeg("") + auth.Init(ds) + svc = NewDecider(ds, ff) + }) + + Describe("Token round-trip", func() { + var ( + sourceTime time.Time + impl *deciderService + ) + + BeforeEach(func() { + sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + impl = svc.(*deciderService) + }) + + It("creates and parses a direct play token", func() { + decision := &Decision{ + MediaID: "media-123", + CanDirectPlay: true, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + Expect(token).ToNot(BeEmpty()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-123")) + Expect(params.DirectPlay).To(BeTrue()) + Expect(params.TargetFormat).To(BeEmpty()) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) + }) + + It("creates and parses a transcode token with kbps bitrate", func() { + decision := &Decision{ + MediaID: "media-456", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, // kbps + TargetChannels: 2, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-456")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("mp3")) + Expect(params.TargetBitrate).To(Equal(256)) // kbps + Expect(params.TargetChannels).To(Equal(2)) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) + }) + + It("creates and parses a transcode token with sample rate", func() { + decision := &Decision{ + MediaID: "media-789", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetSampleRate: 48000, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-789")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("flac")) + Expect(params.TargetSampleRate).To(Equal(48000)) + Expect(params.TargetChannels).To(Equal(2)) + }) + + It("creates and parses a transcode token with bit depth", func() { + decision := &Decision{ + MediaID: "media-bd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetBitDepth: 24, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-bd")) + Expect(params.TargetBitDepth).To(Equal(24)) + }) + + It("omits bit depth from token when 0", func() { + decision := &Decision{ + MediaID: "media-nobd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetBitDepth: 0, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetBitDepth).To(Equal(0)) + }) + + It("omits sample rate from token when 0", func() { + decision := &Decision{ + MediaID: "media-100", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetSampleRate: 0, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetSampleRate).To(Equal(0)) + }) + + It("truncates SourceUpdatedAt to seconds", func() { + timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC) + decision := &Decision{ + MediaID: "media-trunc", + CanDirectPlay: true, + SourceUpdatedAt: timeWithNanos, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix())) + }) + + It("rejects an invalid token", func() { + _, err := impl.parseTranscodeParams("invalid-token") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ResolveRequestFromToken", func() { + var ( + mockMFRepo *tests.MockMediaFileRepo + sourceTime time.Time + ) + + BeforeEach(func() { + sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + mockMFRepo = &tests.MockMediaFileRepo{} + ds.MockedMediaFile = mockMFRepo + }) + + createTokenForMedia := func(mediaID string, updatedAt time.Time) string { + decision := &Decision{ + MediaID: mediaID, + CanDirectPlay: true, + SourceUpdatedAt: updatedAt, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + return token + } + + It("returns stream request and media file for valid token", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", UpdatedAt: sourceTime}, + }) + token := createTokenForMedia("song-1", sourceTime) + + req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(req.ID).To(Equal("song-1")) + Expect(req.Format).To(BeEmpty()) // direct play has no target format + Expect(mf.ID).To(Equal("song-1")) + }) + + It("returns ErrTokenInvalid for invalid token", func() { + _, _, err := svc.ResolveRequestFromToken(ctx, "bad-token", "song-1", 0) + Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) + }) + + It("returns ErrTokenInvalid when mediaID does not match token", func() { + token := createTokenForMedia("song-1", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0) + Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) + }) + + It("returns ErrMediaNotFound when media file does not exist", func() { + token := createTokenForMedia("gone-id", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0) + Expect(err).To(MatchError(ErrMediaNotFound)) + }) + + It("returns ErrTokenStale when media file has changed", func() { + newTime := sourceTime.Add(1 * time.Hour) + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", UpdatedAt: newTime}, + }) + token := createTokenForMedia("song-1", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + Expect(err).To(MatchError(ErrTokenStale)) + }) + }) + + Describe("paramsFromToken", func() { + It("returns error when media ID is missing", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)}) + Expect(err).NotTo(HaveOccurred()) + + _, err = paramsFromToken(token) + Expect(err).To(MatchError(ContainSubstring("missing media ID"))) + }) + + It("returns error when source timestamp is missing", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"}) + Expect(err).NotTo(HaveOccurred()) + + _, err = paramsFromToken(token) + Expect(err).To(MatchError(ContainSubstring("missing source timestamp"))) + }) + }) +}) diff --git a/core/transcode/types.go b/core/transcode/types.go index b567e7464..d7a63fbc4 100644 --- a/core/transcode/types.go +++ b/core/transcode/types.go @@ -1,13 +1,8 @@ package transcode import ( - "context" "errors" - "fmt" "time" - - "github.com/lestrrat-go/jwx/v3/jwt" - "github.com/navidrome/navidrome/model" ) var ( @@ -34,14 +29,6 @@ type StreamRequest struct { Offset int // seconds } -// Decider is the core service interface for making transcoding decisions -type Decider interface { - MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) - CreateTranscodeParams(decision *Decision) (string, error) - ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) - ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest -} - // ClientInfo represents client playback capabilities. // All bitrate values are in kilobits per second (kbps) type ClientInfo struct { @@ -131,35 +118,6 @@ type Decision struct { TranscodeStream *StreamDetails } -// toClaimsMap converts a Decision into a JWT claims map for token encoding. -// Only non-zero transcode fields are included. -func (d *Decision) toClaimsMap() map[string]any { - m := map[string]any{ - "mid": d.MediaID, - "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(), - jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(), - } - if d.CanDirectPlay { - m["dp"] = true - } - if d.CanTranscode && d.TargetFormat != "" { - m["f"] = d.TargetFormat - if d.TargetBitrate != 0 { - m["b"] = d.TargetBitrate - } - if d.TargetChannels != 0 { - m["ch"] = d.TargetChannels - } - if d.TargetSampleRate != 0 { - m["sr"] = d.TargetSampleRate - } - if d.TargetBitDepth != 0 { - m["bd"] = d.TargetBitDepth - } - } - return m -} - // StreamDetails describes audio stream properties. // Bitrate is in kilobits per second (kbps). type StreamDetails struct { @@ -174,70 +132,3 @@ type StreamDetails struct { 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 - SourceUpdatedAt time.Time -} - -// paramsFromToken extracts and validates Params from a parsed JWT token. -// Returns an error if required claims (media ID, source timestamp) are missing. -func paramsFromToken(token jwt.Token) (*params, error) { - var p params - var mid string - if err := token.Get("mid", &mid); err == nil { - p.MediaID = mid - } - if p.MediaID == "" { - return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid) - } - - var dp bool - if err := token.Get("dp", &dp); err == nil { - p.DirectPlay = dp - } - - ua := getIntClaim(token, "ua") - if ua != 0 { - p.SourceUpdatedAt = time.Unix(int64(ua), 0) - } - if p.SourceUpdatedAt.IsZero() { - return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid) - } - - var f string - if err := token.Get("f", &f); err == nil { - p.TargetFormat = f - } - p.TargetBitrate = getIntClaim(token, "b") - p.TargetChannels = getIntClaim(token, "ch") - p.TargetSampleRate = getIntClaim(token, "sr") - p.TargetBitDepth = getIntClaim(token, "bd") - return &p, nil -} - -// getIntClaim extracts an int claim from a JWT token, handling the case where -// the value may be stored as int64 or float64 (common in JSON-based JWT libraries). -func getIntClaim(token jwt.Token, key string) int { - var v int - if err := token.Get(key, &v); err == nil { - return v - } - var v64 int64 - if err := token.Get(key, &v64); err == nil { - return int(v64) - } - var f float64 - if err := token.Get(key, &f); err == nil { - return int(f) - } - return 0 -}