refactor: rename core/transcode to core/stream, simplify MediaStreamer (#5166)
Some checks are pending
Pipeline: Test, Lint, Build / Get version info (push) Waiting to run
Pipeline: Test, Lint, Build / Lint Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test Go code (push) Waiting to run
Pipeline: Test, Lint, Build / Test JS code (push) Waiting to run
Pipeline: Test, Lint, Build / Lint i18n files (push) Waiting to run
Pipeline: Test, Lint, Build / Check Docker configuration (push) Waiting to run
Pipeline: Test, Lint, Build / Build (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-1 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-2 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-3 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-4 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-5 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-6 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-7 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-8 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-9 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build-10 (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to GHCR (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Build Windows installers (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Package/Release (push) Blocked by required conditions
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Blocked by required conditions

* refactor: rename core/transcode directory to core/stream

* refactor: update all imports from core/transcode to core/stream

* refactor: rename exported symbols to fit core/stream package name

* refactor: simplify MediaStreamer interface to single NewStream method

Remove the two-method interface (NewStream + DoStream) in favor of a
single NewStream(ctx, mf, req) method. Callers are now responsible for
fetching the MediaFile before calling NewStream. This removes the
implicit DB lookup from the streamer, making it a pure streaming
concern.

* refactor: update all callers from DoStream to NewStream

* chore: update wire_gen.go and stale comment for core/stream rename

* refactor: update wire command to handle GO_BUILD_TAGS correctly

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: distinguish not-found from internal errors in public stream handler

* refactor: remove unused ID field from stream.Request

* refactor: simplify ResolveRequestFromToken to receive *model.MediaFile

Move MediaFile fetching responsibility to callers, making the method
focused on token validation and request resolution. Remove ErrMediaNotFound
(no longer produced). Update GetTranscodeStream handler to fetch the
media file before calling ResolveRequestFromToken.

* refactor: extend tokenTTL from 12 to 48 hours

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão 2026-03-09 22:22:58 -04:00 committed by GitHub
parent 844dffa2f1
commit 767744a301
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 298 additions and 317 deletions

View file

@ -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

View file

@ -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)

View file

@ -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)
}

View file

@ -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 {

View file

@ -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":

View file

@ -1,4 +1,4 @@
package transcode
package stream
import (
"slices"

View file

@ -1,4 +1,4 @@
package transcode
package stream
import "strings"

View file

@ -1,4 +1,4 @@
package transcode
package stream
import (
. "github.com/onsi/ginkgo/v2"

View file

@ -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) {

View file

@ -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())

View file

@ -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"

View file

@ -1,4 +1,4 @@
package transcode
package stream
import (
"github.com/navidrome/navidrome/conf"

View file

@ -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 })

View file

@ -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

View file

@ -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())
})

View file

@ -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")
}

View file

@ -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
}

View file

@ -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))
})
})

View file

@ -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.

View file

@ -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)),

View file

@ -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(

View file

@ -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)

View file

@ -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())))

View file

@ -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,

View file

@ -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
}

View file

@ -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)

View file

@ -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
}