diff --git a/Makefile b/Makefile index be3c221b..c9c88f50 100644 --- a/Makefile +++ b/Makefile @@ -109,7 +109,7 @@ format: ##@Development Format code .PHONY: format wire: check_go_env ##@Development Update Dependency Injection - go tool wire gen -tags=$(GO_BUILD_TAGS) ./... + go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./... .PHONY: wire gen: check_go_env ##@Development Run go generate for code generation diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index a7a0769d..d045c12e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,7 +21,7 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -95,8 +95,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := transcode.GetTranscodingCache() - mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) players := core.NewPlayers(dataStore) @@ -106,8 +106,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) - decider := transcode.NewDecider(dataStore, fFmpeg) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, decider) + transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider) return router } @@ -122,8 +122,8 @@ func CreatePublicRouter() *public.Router { agentsAgents := agents.GetAgents(dataStore, manager) provider := external.NewProvider(dataStore, agentsAgents) artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) - transcodingCache := transcode.GetTranscodingCache() - mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + transcodingCache := stream.GetTranscodingCache() + mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) share := core.NewShare(dataStore) archiver := core.NewArchiver(mediaStreamer, dataStore, share) router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) diff --git a/core/archiver.go b/core/archiver.go index 88b2d5b0..8305c4f6 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" @@ -23,13 +23,13 @@ type Archiver interface { ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error } -func NewArchiver(ms transcode.MediaStreamer, ds model.DataStore, shares Share) Archiver { +func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver { return &archiver{ds: ds, ms: ms, shares: shares} } type archiver struct { ds model.DataStore - ms transcode.MediaStreamer + ms stream.MediaStreamer shares Share } @@ -177,7 +177,7 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med var r io.ReadCloser if format != "raw" && format != "" { - r, err = a.ms.DoStream(ctx, &mf, transcode.StreamRequest{Format: format, BitRate: bitrate}) + r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) } else { r, err = os.Open(path) } diff --git a/core/archiver_test.go b/core/archiver_test.go index bfce641c..4f7aed27 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -9,7 +9,7 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -45,7 +45,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) out := new(bytes.Buffer) err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) @@ -74,7 +74,7 @@ var _ = Describe("Archiver", func() { }}).Return(mfs, nil) ds.On("MediaFile", mock.Anything).Return(mfRepo) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out) @@ -105,7 +105,7 @@ var _ = Describe("Archiver", func() { } sh.On("Load", mock.Anything, "1").Return(share, nil) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipShare(context.Background(), "1", out) @@ -137,7 +137,7 @@ var _ = Describe("Archiver", func() { plRepo := &mockPlaylistRepository{} plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) ds.On("Playlist", mock.Anything).Return(plRepo) - ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) out := new(bytes.Buffer) err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out) @@ -215,15 +215,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, type mockMediaStreamer struct { mock.Mock - transcode.MediaStreamer + stream.MediaStreamer } -func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { +func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { args := m.Called(ctx, mf, req) if args.Error(1) != nil { return nil, args.Error(1) } - return &transcode.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil + return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil } type mockShare struct { diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 7202d02d..d0cf0755 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -382,7 +382,7 @@ func injectBeforeOutput(args []string, flag, value string) []string { // isLosslessOutputFormat returns true if the format is a lossless audio format // where preserving bit depth via -sample_fmt is meaningful. // Note: this covers only formats ffmpeg can produce as output. For the full set of -// lossless formats used in transcoding decisions, see core/transcode/codec.go:isLosslessFormat. +// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat. func isLosslessOutputFormat(format string) bool { switch strings.ToLower(format) { case "flac", "alac", "wav", "aiff": diff --git a/core/transcode/aliases.go b/core/stream/aliases.go similarity index 99% rename from core/transcode/aliases.go rename to core/stream/aliases.go index 0c9bfcb4..6a4c8386 100644 --- a/core/transcode/aliases.go +++ b/core/stream/aliases.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "slices" diff --git a/core/transcode/codec.go b/core/stream/codec.go similarity index 99% rename from core/transcode/codec.go rename to core/stream/codec.go index aa276d43..88d1ae45 100644 --- a/core/transcode/codec.go +++ b/core/stream/codec.go @@ -1,4 +1,4 @@ -package transcode +package stream import "strings" diff --git a/core/transcode/codec_test.go b/core/stream/codec_test.go similarity index 99% rename from core/transcode/codec_test.go rename to core/stream/codec_test.go index 6d3fbd78..4c76b3ec 100644 --- a/core/transcode/codec_test.go +++ b/core/stream/codec_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( . "github.com/onsi/ginkgo/v2" diff --git a/core/transcode/decider.go b/core/stream/decider.go similarity index 93% rename from core/transcode/decider.go rename to core/stream/decider.go index e870e9af..5cca0cb0 100644 --- a/core/transcode/decider.go +++ b/core/stream/decider.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -16,15 +16,15 @@ import ( const fallbackBitrate = 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 +// TranscodeDecider is the core service interface for making transcoding decisions +type TranscodeDecider interface { + MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) + CreateTranscodeParams(decision *TranscodeDecision) (string, error) + ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) + ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request } -func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider { +func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider { return &deciderService{ ds: ds, ff: ff, @@ -36,8 +36,8 @@ type deciderService struct { ff ffmpeg.FFmpeg } -func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) { - decision := &Decision{ +func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) { + decision := &TranscodeDecision{ MediaID: mf.ID, SourceUpdatedAt: mf.UpdatedAt, } @@ -126,8 +126,8 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, return decision, nil } -func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) StreamDetails { - sd := StreamDetails{ +func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details { + sd := Details{ Container: mf.Suffix, Duration: mf.Duration, Size: mf.Size, @@ -197,7 +197,7 @@ func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { // checkDirectPlayProfile returns "" if the profile matches (direct play OK), // or a typed reason string if it doesn't match. -func (s *deciderService) checkDirectPlayProfile(src *StreamDetails, profile *DirectPlayProfile, clientInfo *ClientInfo) string { +func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string { // Check protocol (only http for now) if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) { return "protocol not supported" @@ -234,7 +234,7 @@ func (s *deciderService) checkDirectPlayProfile(src *StreamDetails, profile *Dir // Returns the stream details and the internal transcoding format (which may differ from the // response container when a codec fallback occurs, e.g., "mp4"→"aac"). // Returns nil, "" if the profile cannot produce a valid output. -func (s *deciderService) computeTranscodedStream(ctx context.Context, src *StreamDetails, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, string) { +func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) { // Check protocol (only http for now) if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) { log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol) @@ -260,7 +260,7 @@ func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Strea return nil, "" } - ts := &StreamDetails{ + ts := &Details{ Container: responseContainer, Codec: strings.ToLower(profile.AudioCodec), SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), @@ -358,7 +358,7 @@ func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat stri // computeBitrate determines the target bitrate for the transcoded stream. // Returns false if the profile should be rejected. -func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { +func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool { if src.IsLossless { if !targetIsLossless { if clientInfo.MaxTranscodingAudioBitrate > 0 { @@ -388,7 +388,7 @@ func (s *deciderService) computeBitrate(ctx context.Context, src *StreamDetails, // applyCodecLimitations applies codec profile limitations to the transcoded stream. // Returns false if the profile should be rejected. -func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool { +func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool { targetCodec := ts.Codec for _, codecProfile := range clientInfo.CodecProfiles { if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) { diff --git a/core/transcode/decider_test.go b/core/stream/decider_test.go similarity index 90% rename from core/transcode/decider_test.go rename to core/stream/decider_test.go index cc9b5fb6..42ebd84f 100644 --- a/core/transcode/decider_test.go +++ b/core/stream/decider_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -35,7 +35,7 @@ var _ = Describe("Decider", func() { var ( ds *tests.MockDataStore ff *tests.MockFFmpeg - svc Decider + svc TranscodeDecider ctx context.Context ) @@ -47,7 +47,7 @@ var _ = Describe("Decider", func() { } ff = tests.NewMockFFmpeg("") auth.Init(ds) - svc = NewDecider(ds, ff) + svc = NewTranscodeDecider(ds, ff) }) Describe("MakeDecision", func() { @@ -59,7 +59,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) Expect(decision.CanTranscode).To(BeFalse()) @@ -73,7 +73,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) @@ -86,7 +86,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) @@ -99,7 +99,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) @@ -112,7 +112,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -124,7 +124,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -136,7 +136,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"ogg"}, AudioCodecs: []string{"opus"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -148,7 +148,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -160,7 +160,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -172,7 +172,7 @@ var _ = Describe("Decider", func() { {Containers: []string{}, AudioCodecs: []string{}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -190,7 +190,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -210,7 +210,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -226,7 +226,7 @@ var _ = Describe("Decider", func() { {Container: "flac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -238,7 +238,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(160)) // mp3 default from mock transcoding repo @@ -252,7 +252,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps @@ -265,7 +265,7 @@ var _ = Describe("Decider", func() { {Container: "wav", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -278,7 +278,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate @@ -296,7 +296,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("opus")) @@ -315,7 +315,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -330,7 +330,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy @@ -341,7 +341,7 @@ var _ = Describe("Decider", func() { It("returns error when nothing matches", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) ci := &ClientInfo{} - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeFalse()) @@ -366,7 +366,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) @@ -388,7 +388,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -409,7 +409,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -430,7 +430,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) }) @@ -452,7 +452,7 @@ var _ = Describe("Decider", func() { }, } // Source profile is empty (not yet populated from scanner), so Equals("LC") fails - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported")) @@ -474,7 +474,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -495,7 +495,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported")) @@ -520,7 +520,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.Bitrate).To(Equal(96)) @@ -543,7 +543,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.Channels).To(Equal(2)) @@ -566,7 +566,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -588,7 +588,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) @@ -602,7 +602,7 @@ var _ = Describe("Decider", func() { {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) @@ -626,7 +626,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeFalse()) }) @@ -641,7 +641,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -660,7 +660,7 @@ var _ = Describe("Decider", func() { {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("flac")) @@ -688,7 +688,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 @@ -715,7 +715,7 @@ var _ = Describe("Decider", func() { }, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit @@ -740,7 +740,7 @@ var _ = Describe("Decider", func() { }, MaxTranscodingAudioBitrate: 256, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.IsLossless).To(BeTrue()) Expect(decision.SourceStream.Codec).To(Equal("wavpack")) @@ -762,7 +762,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.IsLossless).To(BeFalse()) Expect(decision.CanDirectPlay).To(BeTrue()) @@ -778,7 +778,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("opus")) @@ -795,7 +795,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -811,7 +811,7 @@ var _ = Describe("Decider", func() { {Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // TargetFormat is the internal format used for transcoding ("aac") @@ -829,7 +829,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -846,7 +846,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) @@ -860,7 +860,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TranscodeStream.SampleRate).To(Equal(44100)) @@ -876,7 +876,7 @@ var _ = Describe("Decider", func() { {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 @@ -897,7 +897,7 @@ var _ = Describe("Decider", func() { {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.TranscodeReasons).To(HaveLen(3)) @@ -915,7 +915,7 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.SourceStream.Container).To(Equal("flac")) Expect(decision.SourceStream.Codec).To(Equal("flac")) @@ -939,7 +939,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -957,7 +957,7 @@ var _ = Describe("Decider", func() { } overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) Expect(decision.CanTranscode).To(BeFalse()) @@ -970,7 +970,7 @@ var _ = Describe("Decider", func() { } overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeFalse()) Expect(decision.CanTranscode).To(BeTrue()) @@ -986,7 +986,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -1001,7 +1001,7 @@ var _ = Describe("Decider", func() { overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetFormat).To(Equal("mp3")) @@ -1018,7 +1018,7 @@ var _ = Describe("Decider", func() { }, } // No override in context — client profiles used as-is - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -1039,7 +1039,7 @@ var _ = Describe("Decider", func() { } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) // Source bitrate 1000 > player cap 320, so direct play is not possible Expect(decision.CanDirectPlay).To(BeFalse()) @@ -1060,7 +1060,7 @@ var _ = Describe("Decider", func() { } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) // Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins @@ -1077,7 +1077,7 @@ var _ = Describe("Decider", func() { } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) @@ -1091,7 +1091,7 @@ var _ = Describe("Decider", func() { {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(96)) // opus default from mock @@ -1104,7 +1104,7 @@ var _ = Describe("Decider", func() { {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, }, } - decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanTranscode).To(BeTrue()) Expect(decision.TargetBitrate).To(Equal(256)) // aac default from mock @@ -1133,7 +1133,7 @@ var _ = Describe("Decider", func() { Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2, } - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) probe, err := svc.ensureProbed(ctx, mf) Expect(err).ToNot(HaveOccurred()) Expect(mf.ProbeData).ToNot(BeEmpty()) @@ -1154,7 +1154,7 @@ var _ = Describe("Decider", func() { // Set error on mock — if ffprobe were called, this would fail ff.Error = fmt.Errorf("should not be called") - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) probe, err := svc.ensureProbed(ctx, mf) Expect(err).ToNot(HaveOccurred()) Expect(probe).To(BeNil()) @@ -1164,7 +1164,7 @@ var _ = Describe("Decider", func() { mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"} ff.Error = fmt.Errorf("ffprobe not found") - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) _, err := svc.ensureProbed(ctx, mf) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("probing media file")) @@ -1179,7 +1179,7 @@ var _ = Describe("Decider", func() { // Set a result — if ffprobe were called, ProbeData would be populated ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"} - svc := NewDecider(ds, ff).(*deciderService) + svc := NewTranscodeDecider(ds, ff).(*deciderService) probe, err := svc.ensureProbed(ctx, mf) Expect(err).ToNot(HaveOccurred()) Expect(probe).To(BeNil()) diff --git a/core/transcode/legacy_client.go b/core/stream/legacy_client.go similarity index 91% rename from core/transcode/legacy_client.go rename to core/stream/legacy_client.go index 83190ec9..e813a352 100644 --- a/core/transcode/legacy_client.go +++ b/core/stream/legacy_client.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -46,10 +46,9 @@ func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int } // 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 +// into a fully specified Request. +func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request { + var req Request req.Offset = offset if reqFormat == "raw" { @@ -58,7 +57,7 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) - decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{SkipProbe: true}) + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) if err != nil { log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) req.Format = "raw" diff --git a/core/transcode/legacy_client_test.go b/core/stream/legacy_client_test.go similarity index 99% rename from core/transcode/legacy_client_test.go rename to core/stream/legacy_client_test.go index 9628764f..d163464a 100644 --- a/core/transcode/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "github.com/navidrome/navidrome/conf" diff --git a/core/transcode/limitations.go b/core/stream/limitations.go similarity index 96% rename from core/transcode/limitations.go rename to core/stream/limitations.go index aefc87d9..ab70d6c0 100644 --- a/core/transcode/limitations.go +++ b/core/stream/limitations.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "strconv" @@ -16,7 +16,7 @@ const ( // checkLimitations checks codec profile limitations against source stream details. // Returns "" if all limitations pass, or a typed reason string for the first failure. -func checkLimitations(src *StreamDetails, limitations []Limitation) string { +func checkLimitations(src *Details, limitations []Limitation) string { for _, lim := range limitations { var ok bool var reason string @@ -50,7 +50,7 @@ func checkLimitations(src *StreamDetails, limitations []Limitation) string { // applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. // Returns the adjustment result. -func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult { +func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult { switch lim.Name { case LimitationAudioChannels: return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v }) diff --git a/core/transcode/media_streamer.go b/core/stream/media_streamer.go similarity index 92% rename from core/transcode/media_streamer.go rename to core/stream/media_streamer.go index 88fb61e2..062a1388 100644 --- a/core/transcode/media_streamer.go +++ b/core/stream/media_streamer.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -20,8 +20,7 @@ import ( ) type MediaStreamer interface { - NewStream(ctx context.Context, req StreamRequest) (*Stream, error) - DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) + NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) } type TranscodingCache cache.FileCache @@ -52,16 +51,7 @@ func (j *streamJob) Key() string { return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) } -func (ms *mediaStreamer) NewStream(ctx context.Context, req StreamRequest) (*Stream, error) { - mf, err := ms.ds.MediaFile(ctx).Get(req.ID) - if err != nil { - return nil, err - } - - return ms.DoStream(ctx, mf, req) -} - -func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) { +func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) { var format string var bitRate int var cached bool diff --git a/core/transcode/media_streamer_test.go b/core/stream/media_streamer_test.go similarity index 71% rename from core/transcode/media_streamer_test.go rename to core/stream/media_streamer_test.go index f49dcb8d..1bc21e23 100644 --- a/core/transcode/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -1,4 +1,4 @@ -package transcode_test +package stream_test import ( "context" @@ -7,7 +7,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" @@ -16,7 +16,7 @@ import ( ) var _ = Describe("MediaStreamer", func() { - var streamer transcode.MediaStreamer + var streamer stream.MediaStreamer var ds model.DataStore ffmpeg := tests.NewMockFFmpeg("fake data") ctx := log.NewContext(context.TODO()) @@ -29,39 +29,45 @@ var _ = Describe("MediaStreamer", func() { ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ {ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0}, }) - testCache := transcode.NewTranscodingCache() + testCache := stream.NewTranscodingCache() Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue()) - streamer = transcode.NewMediaStreamer(ds, ffmpeg, testCache) + streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { _ = os.RemoveAll(conf.Server.CacheFolder) }) Context("NewStream", func() { + var mf *model.MediaFile + BeforeEach(func() { + var err error + mf, err = ds.MediaFile(ctx).Get("123") + Expect(err).ToNot(HaveOccurred()) + }) It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "raw"}) + s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a seekable stream if no format is specified (direct play)", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123"}) + s, err := streamer.NewStream(ctx, mf, stream.Request{}) Expect(err).ToNot(HaveOccurred()) Expect(s.Seekable()).To(BeTrue()) }) It("returns a NON seekable stream if transcode is required", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 64}) + s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) It("returns a seekable stream if the file is complete in the cache", func() { - s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) + s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) _, _ = io.ReadAll(s) _ = s.Close() Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) - s, err = streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) + s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/transcode_suite_test.go b/core/stream/stream_suite_test.go similarity index 74% rename from core/transcode/transcode_suite_test.go rename to core/stream/stream_suite_test.go index e35471b0..36e9e7f4 100644 --- a/core/transcode/transcode_suite_test.go +++ b/core/stream/stream_suite_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "testing" @@ -9,9 +9,9 @@ import ( . "github.com/onsi/gomega" ) -func TestTranscode(t *testing.T) { +func TestStream(t *testing.T) { tests.Init(t, false) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) - RunSpecs(t, "Transcode Suite") + RunSpecs(t, "Stream Suite") } diff --git a/core/transcode/token.go b/core/stream/token.go similarity index 79% rename from core/transcode/token.go rename to core/stream/token.go index e110320d..24a154b5 100644 --- a/core/transcode/token.go +++ b/core/stream/token.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -12,7 +12,7 @@ import ( "github.com/navidrome/navidrome/model" ) -const tokenTTL = 12 * time.Hour +const tokenTTL = 48 * time.Hour // params contains the parameters extracted from a transcode token. // TargetBitrate is in kilobits per second (kbps). @@ -29,7 +29,7 @@ type params struct { // 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 { +func (d *TranscodeDecision) toClaimsMap() map[string]any { m := map[string]any{ "mid": d.MediaID, "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(), @@ -110,7 +110,7 @@ func getIntClaim(token jwt.Token, key string) int { return 0 } -func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { +func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) { return auth.EncodeToken(decision.toClaimsMap()) } @@ -122,28 +122,21 @@ func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) return paramsFromToken(token) } -func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) { +func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) { p, err := s.parseTranscodeParams(token) if err != nil { - return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err) + return Request{}, 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 p.MediaID != mf.ID { + return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID) } if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { - log.Info(ctx, "Transcode token is stale", "mediaID", mediaID, + log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID, "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) - return StreamRequest{}, nil, ErrTokenStale + return Request{}, ErrTokenStale } - req := StreamRequest{ID: mediaID, Offset: offset} + req := Request{Offset: offset} if !p.DirectPlay && p.TargetFormat != "" { req.Format = p.TargetFormat req.BitRate = p.TargetBitrate @@ -151,5 +144,5 @@ func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token stri req.BitDepth = p.TargetBitDepth req.Channels = p.TargetChannels } - return req, mf, nil + return req, nil } diff --git a/core/transcode/token_test.go b/core/stream/token_test.go similarity index 84% rename from core/transcode/token_test.go rename to core/stream/token_test.go index b9b74c8f..7409a753 100644 --- a/core/transcode/token_test.go +++ b/core/stream/token_test.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "context" @@ -16,7 +16,7 @@ var _ = Describe("Token", func() { var ( ds *tests.MockDataStore ff *tests.MockFFmpeg - svc Decider + svc TranscodeDecider ctx context.Context ) @@ -28,7 +28,7 @@ var _ = Describe("Token", func() { } ff = tests.NewMockFFmpeg("") auth.Init(ds) - svc = NewDecider(ds, ff) + svc = NewTranscodeDecider(ds, ff) }) Describe("Token round-trip", func() { @@ -43,7 +43,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a direct play token", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-123", CanDirectPlay: true, SourceUpdatedAt: sourceTime, @@ -61,7 +61,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a transcode token with kbps bitrate", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-456", CanDirectPlay: false, CanTranscode: true, @@ -84,7 +84,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a transcode token with sample rate", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-789", CanDirectPlay: false, CanTranscode: true, @@ -107,7 +107,7 @@ var _ = Describe("Token", func() { }) It("creates and parses a transcode token with bit depth", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-bd", CanDirectPlay: false, CanTranscode: true, @@ -127,7 +127,7 @@ var _ = Describe("Token", func() { }) It("omits bit depth from token when 0", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-nobd", CanDirectPlay: false, CanTranscode: true, @@ -145,7 +145,7 @@ var _ = Describe("Token", func() { }) It("omits sample rate from token when 0", func() { - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-100", CanDirectPlay: false, CanTranscode: true, @@ -164,7 +164,7 @@ var _ = Describe("Token", func() { It("truncates SourceUpdatedAt to seconds", func() { timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC) - decision := &Decision{ + decision := &TranscodeDecision{ MediaID: "media-trunc", CanDirectPlay: true, SourceUpdatedAt: timeWithNanos, @@ -184,19 +184,14 @@ var _ = Describe("Token", func() { }) Describe("ResolveRequestFromToken", func() { - var ( - mockMFRepo *tests.MockMediaFileRepo - sourceTime time.Time - ) + var 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{ + decision := &TranscodeDecision{ MediaID: mediaID, CanDirectPlay: true, SourceUpdatedAt: updatedAt, @@ -206,46 +201,35 @@ var _ = Describe("Token", func() { return token } - It("returns stream request and media file for valid token", func() { - mockMFRepo.SetData(model.MediaFiles{ - {ID: "song-1", UpdatedAt: sourceTime}, - }) + It("returns stream request for valid token", func() { + mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} token := createTokenForMedia("song-1", sourceTime) - req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + req, err := svc.ResolveRequestFromToken(ctx, token, mf, 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) + mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime} + _, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0) Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) }) It("returns ErrTokenInvalid when mediaID does not match token", func() { + mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime} token := createTokenForMedia("song-1", sourceTime) - _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0) + _, err := svc.ResolveRequestFromToken(ctx, token, mf, 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}, - }) + mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime} token := createTokenForMedia("song-1", sourceTime) - _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + _, err := svc.ResolveRequestFromToken(ctx, token, mf, 0) Expect(err).To(MatchError(ErrTokenStale)) }) }) diff --git a/core/transcode/types.go b/core/stream/types.go similarity index 78% rename from core/transcode/types.go rename to core/stream/types.go index d7a63fbc..0cb4ac47 100644 --- a/core/transcode/types.go +++ b/core/stream/types.go @@ -1,4 +1,4 @@ -package transcode +package stream import ( "errors" @@ -6,21 +6,19 @@ import ( ) var ( - ErrTokenInvalid = errors.New("invalid or expired transcode token") - ErrMediaNotFound = errors.New("media file not found") - ErrTokenStale = errors.New("transcode token is stale: media file has changed") + ErrTokenInvalid = errors.New("invalid or expired transcode token") + ErrTokenStale = errors.New("transcode token is stale: media file has changed") ) -// DecisionOptions controls optional behavior of MakeDecision. -type DecisionOptions struct { - // SkipProbe prevents MakeDecision from running ffprobe on the media file. +// TranscodeOptions controls optional behavior of MakeTranscodeDecision. +type TranscodeOptions struct { + // SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file. // When true, source stream details are derived from tag metadata only. SkipProbe bool } -// StreamRequest contains the resolved parameters for creating a media stream. -type StreamRequest struct { - ID string +// Request contains the resolved parameters for creating a media stream. +type Request struct { Format string BitRate int // kbps SampleRate int @@ -100,9 +98,9 @@ const ( CodecProfileTypeAudio = "AudioCodec" ) -// Decision represents the internal decision result. +// TranscodeDecision represents the internal decision result. // All bitrate values are in kilobits per second (kbps). -type Decision struct { +type TranscodeDecision struct { MediaID string CanDirectPlay bool CanTranscode bool @@ -113,14 +111,14 @@ type Decision struct { TargetChannels int TargetSampleRate int TargetBitDepth int - SourceStream StreamDetails + SourceStream Details SourceUpdatedAt time.Time - TranscodeStream *StreamDetails + TranscodeStream *Details } -// StreamDetails describes audio stream properties. +// Details describes audio stream properties. // Bitrate is in kilobits per second (kbps). -type StreamDetails struct { +type Details struct { Container string Codec string Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data. diff --git a/core/wire_providers.go b/core/wire_providers.go index 20b5eb9a..153df726 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,12 +10,12 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" ) var Set = wire.NewSet( - transcode.NewMediaStreamer, - transcode.GetTranscodingCache, + stream.NewMediaStreamer, + stream.GetTranscodingCache, NewArchiver, NewPlayers, NewShare, @@ -23,7 +23,7 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, - transcode.NewDecider, + stream.NewTranscodeDecider, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index e55130ff..be8a894d 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -28,7 +28,7 @@ import ( "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/storage/storagetest" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -284,25 +284,21 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// spyStreamer captures the StreamRequest passed to DoStream for test assertions, +// spyStreamer captures the Request passed to NewStream for test assertions, // then returns a minimal fake Stream so the handler completes without error. type spyStreamer struct { - LastRequest transcode.StreamRequest + LastRequest stream.Request LastMediaFile *model.MediaFile } -func (s *spyStreamer) NewStream(ctx context.Context, req transcode.StreamRequest) (*transcode.Stream, error) { - return nil, model.ErrNotFound -} - -func (s *spyStreamer) DoStream(_ context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { +func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { s.LastRequest = req s.LastMediaFile = mf format := req.Format if format == "" || format == "raw" { format = mf.Suffix } - return transcode.NewTestStream(mf, format, req.BitRate), nil + return stream.NewTestStream(mf, format, req.BitRate), nil } // noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. @@ -391,12 +387,12 @@ func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ transcode.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ scrobbler.PlayTracker = noopPlayTracker{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -477,7 +473,7 @@ func setupTestDB() { // Create the Subsonic Router with real DS, spy streamer, and real Decider spy = &spyStreamer{} - decider := transcode.NewDecider(ds, noopFFmpeg{}) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) router = subsonic.New( diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index a147a2ac..6cdf8b44 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -7,8 +7,9 @@ import ( "strconv" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/req" ) @@ -23,8 +24,19 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, transcode.StreamRequest{ - ID: info.id, Format: info.format, BitRate: info.bitrate, + mf, err := pub.ds.MediaFile(ctx).Get(info.id) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + } else { + log.Error(ctx, "Error retrieving media file for shared stream", "id", info.id, err) + http.Error(w, "internal error", http.StatusInternalServerError) + } + return + } + + stream, err := pub.streamer.NewStream(ctx, mf, stream.Request{ + Format: info.format, BitRate: info.bitrate, }) if err != nil { log.Error(ctx, "Error starting shared stream", err) diff --git a/server/public/public.go b/server/public/public.go index 7d8a4e00..5e3407c1 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -11,7 +11,7 @@ import ( "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/publicurl" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -21,14 +21,14 @@ import ( type Router struct { http.Handler artwork artwork.Artwork - streamer transcode.MediaStreamer + streamer stream.MediaStreamer archiver core.Archiver share core.Share assetsHandler http.Handler ds model.DataStore } -func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.MediaStreamer, share core.Share, archiver core.Archiver) *Router { +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router { p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver} shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic) p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets()))) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 6f355d16..e91c02aa 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,7 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -39,7 +39,7 @@ type Router struct { http.Handler ds model.DataStore artwork artwork.Artwork - streamer transcode.MediaStreamer + streamer stream.MediaStreamer archiver core.Archiver players core.Players provider external.Provider @@ -51,13 +51,13 @@ type Router struct { playback playback.PlaybackServer metrics metrics.Metrics lyrics lyricssvc.Lyrics - transcodeDecision transcode.Decider + transcodeDecision stream.TranscodeDecider } -func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.MediaStreamer, archiver core.Archiver, +func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, - metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision transcode.Decider, + metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, ) *Router { r := &Router{ ds: ds, diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index 753e408c..ebebb97f 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -17,7 +17,7 @@ import ( "github.com/navidrome/navidrome/utils/req" ) -func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *transcode.Stream, id string) { +func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *stream.Stream, id string) { if stream.Seekable() { http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) } else { @@ -66,7 +66,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su } streamReq := api.transcodeDecision.ResolveRequest(ctx, mf, format, maxBitRate, timeOffset) - stream, err := api.streamer.DoStream(ctx, mf, streamReq) + stream, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { return nil, err } @@ -136,7 +136,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: streamReq := api.transcodeDecision.ResolveRequest(ctx, v, format, maxBitRate, 0) - stream, err := api.streamer.DoStream(ctx, v, streamReq) + stream, err := api.streamer.NewStream(ctx, v, streamReq) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index ffc4cfcd..79250487 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -8,7 +8,7 @@ import ( "slices" "strconv" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" @@ -59,10 +59,10 @@ type limitationRequest struct { Required bool `json:"required,omitempty"` } -// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct. +// toCoreClientInfo converts the API request struct to the stream.ClientInfo struct. // The OpenSubsonic spec uses bps for bitrate values; core uses kbps. -func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { - ci := &transcode.ClientInfo{ +func (r *clientInfoRequest) toCoreClientInfo() *stream.ClientInfo { + ci := &stream.ClientInfo{ Name: r.Name, Platform: r.Platform, MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate), @@ -70,7 +70,7 @@ func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { } for _, dp := range r.DirectPlayProfiles { - ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{ + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, stream.DirectPlayProfile{ Containers: dp.Containers, AudioCodecs: dp.AudioCodecs, Protocols: dp.Protocols, @@ -79,7 +79,7 @@ func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { } for _, tp := range r.TranscodingProfiles { - ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{ + ci.TranscodingProfiles = append(ci.TranscodingProfiles, stream.Profile{ Container: tp.Container, AudioCodec: tp.AudioCodec, Protocol: tp.Protocol, @@ -88,19 +88,19 @@ func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { } for _, cp := range r.CodecProfiles { - coreCP := transcode.CodecProfile{ + coreCP := stream.CodecProfile{ Type: cp.Type, Name: cp.Name, } for _, lim := range cp.Limitations { - coreLim := transcode.Limitation{ + coreLim := stream.Limitation{ Name: lim.Name, Comparison: lim.Comparison, Values: lim.Values, Required: lim.Required, } // Convert audioBitrate limitation values from bps to kbps - if lim.Name == transcode.LimitationAudioBitrate { + if lim.Name == stream.LimitationAudioBitrate { coreLim.Values = convertBitrateValues(lim.Values) } coreCP.Limitations = append(coreCP.Limitations, coreLim) @@ -178,8 +178,8 @@ func isValidMediaType(mediaType string) bool { } var validProtocols = []string{ - transcode.ProtocolHTTP, - transcode.ProtocolHLS, + stream.ProtocolHTTP, + stream.ProtocolHLS, } func isValidProtocol(p string) bool { @@ -187,7 +187,7 @@ func isValidProtocol(p string) bool { } var validCodecProfileTypes = []string{ - transcode.CodecProfileTypeAudio, + stream.CodecProfileTypeAudio, } func isValidCodecProfileType(t string) bool { @@ -195,11 +195,11 @@ func isValidCodecProfileType(t string) bool { } var validLimitationNames = []string{ - transcode.LimitationAudioChannels, - transcode.LimitationAudioBitrate, - transcode.LimitationAudioProfile, - transcode.LimitationAudioSamplerate, - transcode.LimitationAudioBitdepth, + stream.LimitationAudioChannels, + stream.LimitationAudioBitrate, + stream.LimitationAudioProfile, + stream.LimitationAudioSamplerate, + stream.LimitationAudioBitdepth, } func isValidLimitationName(n string) bool { @@ -207,10 +207,10 @@ func isValidLimitationName(n string) bool { } var validComparisons = []string{ - transcode.ComparisonEquals, - transcode.ComparisonNotEquals, - transcode.ComparisonLessThanEqual, - transcode.ComparisonGreaterThanEqual, + stream.ComparisonEquals, + stream.ComparisonNotEquals, + stream.ComparisonLessThanEqual, + stream.ComparisonGreaterThanEqual, } func isValidComparison(c string) bool { @@ -218,9 +218,9 @@ func isValidComparison(c string) bool { } // toResponseStreamDetails converts a core StreamDetails to the API response type. -func toResponseStreamDetails(sd *transcode.StreamDetails) *responses.StreamDetails { +func toResponseStreamDetails(sd *stream.Details) *responses.StreamDetails { return &responses.StreamDetails{ - Protocol: transcode.ProtocolHTTP, // TODO: derive from decision when HLS support is added + Protocol: stream.ProtocolHTTP, // TODO: derive from decision when HLS support is added Container: sd.Container, Codec: sd.Codec, AudioBitrate: int32(kbpsToBps(sd.Bitrate)), @@ -232,7 +232,7 @@ func toResponseStreamDetails(sd *transcode.StreamDetails) *responses.StreamDetai } // GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint. -// It receives client capabilities and returns a decision on whether to direct play or transcode. +// It receives client capabilities and returns a decision on whether to direct play or stream. func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { if r.Method != http.MethodPost { w.Header().Set("Allow", "POST") @@ -279,7 +279,7 @@ func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) } // Make the decision - decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, transcode.DecisionOptions{}) + decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, stream.TranscodeOptions{}) if err != nil { log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") @@ -343,13 +343,23 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* return nil, nil } + // Fetch the media file + mf, err := api.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "Not Found", http.StatusNotFound) + } else { + log.Error(ctx, "Error retrieving media file", "mediaID", mediaID, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return nil, nil + } + // Validate the token and resolve streaming parameters - streamReq, mf, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mediaID, p.IntOr("offset", 0)) + streamReq, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mf, p.IntOr("offset", 0)) if err != nil { switch { - case errors.Is(err, transcode.ErrMediaNotFound): - http.Error(w, "Not Found", http.StatusNotFound) - case errors.Is(err, transcode.ErrTokenInvalid), errors.Is(err, transcode.ErrTokenStale): + case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale): http.Error(w, "Gone", http.StatusGone) default: log.Error(ctx, "Error validating transcode params", err) @@ -358,8 +368,8 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* return nil, nil } - // Create stream (use DoStream to avoid duplicate DB fetch) - stream, err := api.streamer.DoStream(ctx, mf, streamReq) + // Create stream + stream, err := api.streamer.NewStream(ctx, mf, streamReq) if err != nil { log.Error(ctx, "Error creating stream", "mediaID", mediaID, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 717eeb1f..8db729c6 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -7,7 +7,7 @@ import ( "net/http" "net/http/httptest" - "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" @@ -156,10 +156,10 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo.SetData(model.MediaFiles{ {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}, }) - mockTD.decision = &transcode.Decision{ + mockTD.decision = &stream.TranscodeDecision{ MediaID: "song-1", CanDirectPlay: true, - SourceStream: transcode.StreamDetails{ + SourceStream: stream.Details{ Container: "mp3", Codec: "mp3", Bitrate: 320, SampleRate: 44100, Channels: 2, }, @@ -184,18 +184,18 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo.SetData(model.MediaFiles{ {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, }) - mockTD.decision = &transcode.Decision{ + mockTD.decision = &stream.TranscodeDecision{ MediaID: "song-2", CanDirectPlay: false, CanTranscode: true, TargetFormat: "mp3", TargetBitrate: 256, TranscodeReasons: []string{"container not supported"}, - SourceStream: transcode.StreamDetails{ + SourceStream: stream.Details{ Container: "flac", Codec: "flac", Bitrate: 1000, SampleRate: 96000, BitDepth: 24, Channels: 2, }, - TranscodeStream: &transcode.StreamDetails{ + TranscodeStream: &stream.Details{ Container: "mp3", Codec: "mp3", Bitrate: 256, SampleRate: 96000, Channels: 2, }, @@ -231,7 +231,8 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 410 for invalid or mismatched token", func() { - mockTD.resolveErr = transcode.ErrTokenInvalid + mockMFRepo.SetData(model.MediaFiles{{ID: "123"}}) + mockTD.resolveErr = stream.ErrTokenInvalid r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -240,7 +241,7 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 404 when media file not found", func() { - mockTD.resolveErr = transcode.ErrMediaNotFound + // mockMFRepo has no data, so Get() returns ErrNotFound r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -249,7 +250,8 @@ var _ = Describe("Transcode endpoints", func() { }) It("returns 410 when media file has changed (stale token)", func() { - mockTD.resolveErr = transcode.ErrTokenStale + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) + mockTD.resolveErr = stream.ErrTokenStale r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token") resp, err := router.GetTranscodeStream(w, r) Expect(err).ToNot(HaveOccurred()) @@ -260,14 +262,13 @@ 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.resolvedReq = transcode.StreamRequest{ID: "song-1"} - mockTD.resolvedMF = &model.MediaFile{ID: "song-1"} + mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) + mockTD.resolvedReq = stream.Request{} r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=valid-token") _, _ = router.GetTranscodeStream(w, r) Expect(fakeStreamer.captured).ToNot(BeNil()) - Expect(fakeStreamer.captured.ID).To(Equal("song-1")) Expect(fakeStreamer.captured.Format).To(BeEmpty()) Expect(fakeStreamer.captured.BitRate).To(BeZero()) Expect(fakeStreamer.captured.SampleRate).To(BeZero()) @@ -278,21 +279,19 @@ 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.resolvedReq = transcode.StreamRequest{ - ID: "song-2", + mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) + mockTD.resolvedReq = stream.Request{ Format: "mp3", BitRate: 256, SampleRate: 44100, BitDepth: 16, Channels: 2, } - mockTD.resolvedMF = &model.MediaFile{ID: "song-2"} r := newGetRequest("mediaId=song-2", "mediaType=song", "transcodeParams=valid-token", "offset=10") _, _ = router.GetTranscodeStream(w, r) Expect(fakeStreamer.captured).ToNot(BeNil()) - Expect(fakeStreamer.captured.ID).To(Equal("song-2")) Expect(fakeStreamer.captured.Format).To(Equal("mp3")) Expect(fakeStreamer.captured.BitRate).To(Equal(256)) Expect(fakeStreamer.captured.SampleRate).To(Equal(44100)) @@ -353,38 +352,37 @@ func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { return r } -// mockTranscodeDecision is a test double for transcode.Decider +// mockTranscodeDecision is a test double for stream.TranscodeDecider type mockTranscodeDecision struct { - decision *transcode.Decision + decision *stream.TranscodeDecision token string tokenErr error - resolvedReq transcode.StreamRequest - resolvedMF *model.MediaFile + resolvedReq stream.Request resolveErr error } -func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo, _ transcode.DecisionOptions) (*transcode.Decision, error) { +func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *stream.ClientInfo, _ stream.TranscodeOptions) (*stream.TranscodeDecision, error) { if m.decision != nil { return m.decision, nil } - return &transcode.Decision{}, nil + return &stream.TranscodeDecision{}, nil } -func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) transcode.StreamRequest { - return transcode.StreamRequest{Format: "raw"} +func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) stream.Request { + return stream.Request{Format: "raw"} } -func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) { +func (m *mockTranscodeDecision) CreateTranscodeParams(_ *stream.TranscodeDecision) (string, error) { return m.token, m.tokenErr } -func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ string, offset int) (transcode.StreamRequest, *model.MediaFile, error) { +func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ *model.MediaFile, offset int) (stream.Request, error) { if m.resolveErr != nil { - return transcode.StreamRequest{}, nil, m.resolveErr + return stream.Request{}, m.resolveErr } req := m.resolvedReq req.Offset = offset - return req, m.resolvedMF, nil + return req, nil } // fakeMediaStreamer captures the StreamRequest and returns a sentinel error, @@ -392,15 +390,10 @@ func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ str var errStreamCaptured = errors.New("stream request captured") type fakeMediaStreamer struct { - captured *transcode.StreamRequest + captured *stream.Request } -func (f *fakeMediaStreamer) NewStream(_ context.Context, req transcode.StreamRequest) (*transcode.Stream, error) { - f.captured = &req - return nil, errStreamCaptured -} - -func (f *fakeMediaStreamer) DoStream(_ context.Context, _ *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) { +func (f *fakeMediaStreamer) NewStream(_ context.Context, _ *model.MediaFile, req stream.Request) (*stream.Stream, error) { f.captured = &req return nil, errStreamCaptured }