From a905a010083f97b184d2e74aca73e005b68a45ec Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 8 Mar 2026 21:20:07 -0400 Subject: [PATCH] refactor(transcode): split oversized files into focused modules Split transcode.go and transcode_test.go into focused files by concern: - decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe) - token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken) - legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest) - codec_test.go: isLosslessFormat and normalizeProbeCodec tests - token_test.go: token round-trip and ResolveRequestFromToken tests Moved the Decider interface from types.go to decider.go to keep it near its implementation, and cleaned up types.go to contain only pure type definitions and constants. No public API changes. --- core/transcode/codec_test.go | 69 ++++ core/transcode/{transcode.go => decider.go} | 139 +------- .../{transcode_test.go => decider_test.go} | 301 ------------------ core/transcode/legacy_client.go | 85 +++++ core/transcode/token.go | 155 +++++++++ core/transcode/token_test.go | 272 ++++++++++++++++ core/transcode/types.go | 109 ------- 7 files changed, 591 insertions(+), 539 deletions(-) create mode 100644 core/transcode/codec_test.go rename core/transcode/{transcode.go => decider.go} (78%) rename core/transcode/{transcode_test.go => decider_test.go} (81%) create mode 100644 core/transcode/legacy_client.go create mode 100644 core/transcode/token.go create mode 100644 core/transcode/token_test.go 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 -}