diff --git a/core/transcode/transcode.go b/core/transcode/transcode.go index 0039333b2..dbc3e9389 100644 --- a/core/transcode/transcode.go +++ b/core/transcode/transcode.go @@ -460,9 +460,9 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int return ci } -// ResolveStream uses MakeDecision to resolve legacy Subsonic stream parameters +// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters // into a fully specified StreamRequest. -func (s *deciderService) ResolveStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) 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 @@ -503,7 +503,7 @@ func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, erro return auth.EncodeToken(decision.toClaimsMap()) } -func (s *deciderService) parseTranscodeParams(tokenStr string) (*Params, error) { +func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) { token, err := auth.DecodeAndVerifyToken(tokenStr) if err != nil { return nil, err @@ -511,25 +511,34 @@ func (s *deciderService) parseTranscodeParams(tokenStr string) (*Params, error) return paramsFromToken(token) } -func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) { - params, err := s.parseTranscodeParams(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 nil, nil, errors.Join(ErrTokenInvalid, err) + return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err) } - if params.MediaID != mediaID { - return nil, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, params.MediaID, mediaID) + 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 nil, nil, ErrMediaNotFound + return StreamRequest{}, nil, ErrMediaNotFound } - return nil, nil, err + return StreamRequest{}, nil, err } - if !mf.UpdatedAt.Truncate(time.Second).Equal(params.SourceUpdatedAt) { + if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { log.Info(ctx, "Transcode token is stale", "mediaID", mediaID, - "tokenUpdatedAt", params.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) - return nil, nil, ErrTokenStale + "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) + return StreamRequest{}, nil, ErrTokenStale } - return params, mf, nil + + 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/transcode_test.go index 699fe45f4..c2d3a4ff1 100644 --- a/core/transcode/transcode_test.go +++ b/core/transcode/transcode_test.go @@ -1252,7 +1252,7 @@ var _ = Describe("Decider", func() { }) }) - Describe("ValidateTranscodeParams", func() { + Describe("ResolveRequestFromToken", func() { var ( mockMFRepo *tests.MockMediaFileRepo sourceTime time.Time @@ -1275,35 +1275,35 @@ var _ = Describe("Decider", func() { return token } - It("returns params and media file for valid token", func() { + 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) - params, mf, err := svc.ValidateTranscodeParams(ctx, token, "song-1") + req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) Expect(err).ToNot(HaveOccurred()) - Expect(params.MediaID).To(Equal("song-1")) - Expect(params.DirectPlay).To(BeTrue()) + 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.ValidateTranscodeParams(ctx, "bad-token", "song-1") + _, _, 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.ValidateTranscodeParams(ctx, token, "song-2") + _, _, 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.ValidateTranscodeParams(ctx, token, "gone-id") + _, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0) Expect(err).To(MatchError(ErrMediaNotFound)) }) @@ -1314,7 +1314,7 @@ var _ = Describe("Decider", func() { }) token := createTokenForMedia("song-1", sourceTime) - _, _, err := svc.ValidateTranscodeParams(ctx, token, "song-1") + _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) Expect(err).To(MatchError(ErrTokenStale)) }) }) diff --git a/core/transcode/types.go b/core/transcode/types.go index 4e24a17c9..b567e7464 100644 --- a/core/transcode/types.go +++ b/core/transcode/types.go @@ -37,9 +37,9 @@ type StreamRequest struct { // 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) - ResolveStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest CreateTranscodeParams(decision *Decision) (string, error) - ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, 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. @@ -175,9 +175,9 @@ type StreamDetails struct { IsLossless bool } -// Params contains the parameters extracted from a transcode token. +// params contains the parameters extracted from a transcode token. // TargetBitrate is in kilobits per second (kbps). -type Params struct { +type params struct { MediaID string DirectPlay bool TargetFormat string @@ -190,8 +190,8 @@ type Params struct { // 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 +func paramsFromToken(token jwt.Token) (*params, error) { + var p params var mid string if err := token.Get("mid", &mid); err == nil { p.MediaID = mid diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4be79f940..07f9a771c 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -243,7 +243,7 @@ func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode. return nil, nil } -func (n noopDecider) ResolveStream(context.Context, *model.MediaFile, string, int, int) transcode.StreamRequest { +func (n noopDecider) ResolveRequest(context.Context, *model.MediaFile, string, int, int) transcode.StreamRequest { return transcode.StreamRequest{Format: "raw"} } @@ -251,8 +251,8 @@ func (n noopDecider) CreateTranscodeParams(*transcode.Decision) (string, error) return "", nil } -func (n noopDecider) ValidateTranscodeParams(context.Context, string, string) (*transcode.Params, *model.MediaFile, error) { - return nil, nil, nil +func (n noopDecider) ResolveRequestFromToken(context.Context, string, string, int) (transcode.StreamRequest, *model.MediaFile, error) { + return transcode.StreamRequest{}, nil, nil } // noopArchiver implements core.Archiver diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 78ccb28d7..753e408c1 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -65,7 +65,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su return nil, err } - streamReq := api.transcodeDecision.ResolveStream(ctx, mf, format, maxBitRate, timeOffset) + streamReq := api.transcodeDecision.ResolveRequest(ctx, mf, format, maxBitRate, timeOffset) stream, err := api.streamer.DoStream(ctx, mf, streamReq) if err != nil { return nil, err @@ -135,7 +135,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: - streamReq := api.transcodeDecision.ResolveStream(ctx, v, format, maxBitRate, 0) + streamReq := api.transcodeDecision.ResolveRequest(ctx, v, format, maxBitRate, 0) stream, err := api.streamer.DoStream(ctx, v, streamReq) if err != nil { return nil, err diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 429b859a5..ffc4cfcd7 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -343,8 +343,8 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* return nil, nil } - // Validate the token, mediaID match, file existence, and freshness - params, mf, err := api.transcodeDecision.ValidateTranscodeParams(ctx, transcodeParamsToken, mediaID) + // Validate the token and resolve streaming parameters + streamReq, mf, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mediaID, p.IntOr("offset", 0)) if err != nil { switch { case errors.Is(err, transcode.ErrMediaNotFound): @@ -358,16 +358,6 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* return nil, nil } - // Build streaming parameters from the token - streamReq := transcode.StreamRequest{ID: mediaID, Offset: p.IntOr("offset", 0)} - if !params.DirectPlay && params.TargetFormat != "" { - 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 - } - // Create stream (use DoStream to avoid duplicate DB fetch) stream, err := api.streamer.DoStream(ctx, mf, streamReq) if err != nil { diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 062837974..c2ca289fe 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -231,7 +231,7 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 410 for invalid token", func() { - mockTD.validateErr = transcode.ErrTokenInvalid + mockTD.resolveErr = transcode.ErrTokenInvalid r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -240,7 +240,7 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 410 when mediaId doesn't match token", func() { - mockTD.validateErr = transcode.ErrTokenInvalid + mockTD.resolveErr = transcode.ErrTokenInvalid r := newGetRequest("mediaId=wrong-id", "mediaType=song", "transcodeParams=valid-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -249,7 +249,7 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 404 when media file not found", func() { - mockTD.validateErr = transcode.ErrMediaNotFound + mockTD.resolveErr = transcode.ErrMediaNotFound r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -258,7 +258,7 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 410 when media file has changed (stale token)", func() { - mockTD.validateErr = transcode.ErrTokenStale + mockTD.resolveErr = transcode.ErrTokenStale r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -269,8 +269,8 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) - mockTD.validateParams = &transcode.Params{MediaID: "song-1", DirectPlay: true} - mockTD.validateMF = &model.MediaFile{ID: "song-1"} + mockTD.resolvedReq = transcode.StreamRequest{ID: "song-1"} + mockTD.resolvedMF = &model.MediaFile{ID: "song-1"} r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token") _, _ = router.GetTranscodeStream(w, r) @@ -287,16 +287,15 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) - mockTD.validateParams = &transcode.Params{ - MediaID: "song-2", - DirectPlay: false, - TargetFormat: "mp3", - TargetBitrate: 256, - TargetSampleRate: 44100, - TargetBitDepth: 16, - TargetChannels: 2, + mockTD.resolvedReq = transcode.StreamRequest{ + ID: "song-2", + Format: "mp3", + BitRate: 256, + SampleRate: 44100, + BitDepth: 16, + Channels: 2, } - mockTD.validateMF = &model.MediaFile{ID: "song-2"} + mockTD.resolvedMF = &model.MediaFile{ID: "song-2"} r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10") _, _ = router.GetTranscodeStream(w, r) @@ -365,12 +364,12 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { // mockTranscodeDecision is a test double for transcode.Decider type mockTranscodeDecision struct { - decision *transcode.Decision - token string - tokenErr error - validateParams *transcode.Params - validateMF *model.MediaFile - validateErr error + decision *transcode.Decision + token string + tokenErr error + resolvedReq transcode.StreamRequest + resolvedMF *model.MediaFile + resolveErr error } func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo, _ transcode.DecisionOptions) (*transcode.Decision, error) { @@ -380,7 +379,7 @@ func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFi return &transcode.Decision{}, nil } -func (m *mockTranscodeDecision) ResolveStream(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) transcode.StreamRequest { +func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) transcode.StreamRequest { return transcode.StreamRequest{Format: "raw"} } @@ -388,11 +387,13 @@ func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (st return m.token, m.tokenErr } -func (m *mockTranscodeDecision) ValidateTranscodeParams(_ context.Context, _ string, _ string) (*transcode.Params, *model.MediaFile, error) { - if m.validateErr != nil { - return nil, nil, m.validateErr +func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ string, offset int) (transcode.StreamRequest, *model.MediaFile, error) { + if m.resolveErr != nil { + return transcode.StreamRequest{}, nil, m.resolveErr } - return m.validateParams, m.validateMF, nil + req := m.resolvedReq + req.Offset = offset + return req, m.resolvedMF, nil } // fakeMediaStreamer captures the StreamRequest and returns a sentinel error,