mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
refactor(transcode): move MediaStreamer into core/transcode and unify StreamRequest
Moved MediaStreamer, Stream, TranscodingCache and related types from core/media_streamer.go into core/transcode/, eliminating the duplicate StreamRequest type. The transcode.StreamRequest now carries all fields (ID, Format, BitRate, SampleRate, BitDepth, Channels, Offset) and ResolveStream returns a fully-populated value, removing manual field copying at every call site. Also moved buildLegacyClientInfo into the transcode package alongside ResolveStream, and unexported ParseTranscodeParams since it was only used internally by ValidateTranscodeParams.
This commit is contained in:
parent
1ce16d8315
commit
3259040db0
@ -1,6 +1,6 @@
|
||||
// Code generated by Wire. DO NOT EDIT.
|
||||
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
||||
//go:build !wireinject
|
||||
// +build !wireinject
|
||||
|
||||
@ -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 := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodingCache := transcode.GetTranscodingCache()
|
||||
mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
@ -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 := core.GetTranscodingCache()
|
||||
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodingCache := transcode.GetTranscodingCache()
|
||||
mediaStreamer := transcode.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
share := core.NewShare(dataStore)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@ -22,13 +23,13 @@ type Archiver interface {
|
||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||
}
|
||||
|
||||
func NewArchiver(ms MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
func NewArchiver(ms transcode.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||
}
|
||||
|
||||
type archiver struct {
|
||||
ds model.DataStore
|
||||
ms MediaStreamer
|
||||
ms transcode.MediaStreamer
|
||||
shares Share
|
||||
}
|
||||
|
||||
@ -176,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, StreamRequest{Format: format, BitRate: bitrate})
|
||||
r, err = a.ms.DoStream(ctx, &mf, transcode.StreamRequest{Format: format, BitRate: bitrate})
|
||||
} else {
|
||||
r, err = os.Open(path)
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -44,7 +45,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{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)
|
||||
@ -73,7 +74,7 @@ var _ = Describe("Archiver", func() {
|
||||
}}).Return(mfs, nil)
|
||||
|
||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{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)
|
||||
@ -104,7 +105,7 @@ var _ = Describe("Archiver", func() {
|
||||
}
|
||||
|
||||
sh.On("Load", mock.Anything, "1").Return(share, nil)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
|
||||
out := new(bytes.Buffer)
|
||||
err := arch.ZipShare(context.Background(), "1", out)
|
||||
@ -136,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, core.StreamRequest{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||
ms.On("DoStream", mock.Anything, mock.Anything, transcode.StreamRequest{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)
|
||||
@ -214,15 +215,15 @@ func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists,
|
||||
|
||||
type mockMediaStreamer struct {
|
||||
mock.Mock
|
||||
core.MediaStreamer
|
||||
transcode.MediaStreamer
|
||||
}
|
||||
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req core.StreamRequest) (*core.Stream, error) {
|
||||
func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) {
|
||||
args := m.Called(ctx, mf, req)
|
||||
if args.Error(1) != nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
return &transcode.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||
}
|
||||
|
||||
type mockShare struct {
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
package subsonic
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -23,13 +22,13 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
|
||||
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(transcode.ProtocolHTTP))
|
||||
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(BeZero())
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{transcode.ProtocolHTTP}))
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
})
|
||||
|
||||
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
|
||||
@ -50,7 +49,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{transcode.ProtocolHTTP}))
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
@ -64,7 +63,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(transcode.ProtocolHTTP))
|
||||
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(128))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
@ -78,7 +77,7 @@ var _ = Describe("buildLegacyClientInfo", func() {
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{transcode.ProtocolHTTP}))
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
@ -1,4 +1,4 @@
|
||||
package core
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -12,24 +12,12 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
// StreamRequest contains all parameters for creating a media stream.
|
||||
type StreamRequest struct {
|
||||
ID string
|
||||
Format string
|
||||
BitRate int // kbps
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, req StreamRequest) (*Stream, error)
|
||||
DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error)
|
||||
@ -171,7 +159,7 @@ func NewTranscodingCache() TranscodingCache {
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
command := transcode.LookupTranscodeCommand(ctx, job.ms.ds, job.format)
|
||||
command := LookupTranscodeCommand(ctx, job.ms.ds, job.format)
|
||||
if command == "" {
|
||||
log.Error(ctx, "No transcoding command available", "format", job.format)
|
||||
return nil, os.ErrInvalid
|
||||
@ -206,3 +194,12 @@ func NewTranscodingCache() TranscodingCache {
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// userName extracts the username from the context for logging purposes.
|
||||
func userName(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package core_test
|
||||
package transcode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
@ -7,7 +7,7 @@ import (
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"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 core.MediaStreamer
|
||||
var streamer transcode.MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := tests.NewMockFFmpeg("fake data")
|
||||
ctx := log.NewContext(context.TODO())
|
||||
@ -29,9 +29,9 @@ 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 := core.NewTranscodingCache()
|
||||
testCache := transcode.NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
streamer = core.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
streamer = transcode.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.CacheFolder)
|
||||
@ -39,29 +39,29 @@ var _ = Describe("MediaStreamer", func() {
|
||||
|
||||
Context("NewStream", func() {
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, core.StreamRequest{ID: "123", Format: "raw"})
|
||||
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", 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, core.StreamRequest{ID: "123"})
|
||||
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123"})
|
||||
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, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 64})
|
||||
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", 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, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
|
||||
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", 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, core.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
|
||||
s, err = streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
@ -423,11 +423,87 @@ func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
|
||||
// into a ClientInfo for use with MakeDecision.
|
||||
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
|
||||
// MakeDecision's applyServerOverride.
|
||||
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
|
||||
ci := &ClientInfo{Name: "legacy"}
|
||||
|
||||
// Determine target format for transcoding
|
||||
var targetFormat string
|
||||
switch {
|
||||
case reqFormat != "":
|
||||
targetFormat = reqFormat
|
||||
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
|
||||
targetFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
if targetFormat != "" {
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
ci.TranscodingProfiles = []Profile{
|
||||
{Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP},
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
ci.MaxAudioBitrate = reqBitRate
|
||||
ci.MaxTranscodingAudioBitrate = reqBitRate
|
||||
}
|
||||
} else {
|
||||
// No transcoding requested — direct play everything
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
}
|
||||
|
||||
return ci
|
||||
}
|
||||
|
||||
// ResolveStream uses MakeDecision to resolve legacy Subsonic stream parameters
|
||||
// into a fully specified StreamRequest.
|
||||
func (s *deciderService) ResolveStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest {
|
||||
var req StreamRequest
|
||||
req.ID = mf.ID
|
||||
req.Offset = offset
|
||||
|
||||
if reqFormat == "raw" {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
|
||||
decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{SkipProbe: true})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanDirectPlay {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
req.Format = decision.TargetFormat
|
||||
req.BitRate = decision.TargetBitrate
|
||||
req.SampleRate = decision.TargetSampleRate
|
||||
req.BitDepth = decision.TargetBitDepth
|
||||
req.Channels = decision.TargetChannels
|
||||
return req
|
||||
}
|
||||
|
||||
// No compatible profile — fallback to raw
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
|
||||
return auth.EncodeToken(decision.toClaimsMap())
|
||||
}
|
||||
|
||||
func (s *deciderService) ParseTranscodeParams(tokenStr string) (*Params, error) {
|
||||
func (s *deciderService) parseTranscodeParams(tokenStr string) (*Params, error) {
|
||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -436,7 +512,7 @@ func (s *deciderService) ParseTranscodeParams(tokenStr string) (*Params, error)
|
||||
}
|
||||
|
||||
func (s *deciderService) ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error) {
|
||||
params, err := s.ParseTranscodeParams(token)
|
||||
params, err := s.parseTranscodeParams(token)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Join(ErrTokenInvalid, err)
|
||||
}
|
||||
|
||||
@ -1101,10 +1101,14 @@ var _ = Describe("Decider", func() {
|
||||
})
|
||||
|
||||
Describe("Token round-trip", func() {
|
||||
var sourceTime time.Time
|
||||
var (
|
||||
sourceTime time.Time
|
||||
impl *deciderService
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
|
||||
impl = svc.(*deciderService)
|
||||
})
|
||||
|
||||
It("creates and parses a direct play token", func() {
|
||||
@ -1117,7 +1121,7 @@ var _ = Describe("Decider", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-123"))
|
||||
Expect(params.DirectPlay).To(BeTrue())
|
||||
@ -1138,7 +1142,7 @@ var _ = Describe("Decider", func() {
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-456"))
|
||||
Expect(params.DirectPlay).To(BeFalse())
|
||||
@ -1162,7 +1166,7 @@ var _ = Describe("Decider", func() {
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-789"))
|
||||
Expect(params.DirectPlay).To(BeFalse())
|
||||
@ -1185,7 +1189,7 @@ var _ = Describe("Decider", func() {
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-bd"))
|
||||
Expect(params.TargetBitDepth).To(Equal(24))
|
||||
@ -1204,7 +1208,7 @@ var _ = Describe("Decider", func() {
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.TargetBitDepth).To(Equal(0))
|
||||
})
|
||||
@ -1222,7 +1226,7 @@ var _ = Describe("Decider", func() {
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.TargetSampleRate).To(Equal(0))
|
||||
})
|
||||
@ -1237,13 +1241,13 @@ var _ = Describe("Decider", func() {
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix()))
|
||||
})
|
||||
|
||||
It("rejects an invalid token", func() {
|
||||
_, err := svc.ParseTranscodeParams("invalid-token")
|
||||
_, err := impl.parseTranscodeParams("invalid-token")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,11 +23,22 @@ type DecisionOptions struct {
|
||||
SkipProbe bool
|
||||
}
|
||||
|
||||
// StreamRequest contains the resolved parameters for creating a media stream.
|
||||
type StreamRequest struct {
|
||||
ID string
|
||||
Format string
|
||||
BitRate int // kbps
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
// Decider is the core service interface for making transcoding decisions
|
||||
type Decider interface {
|
||||
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error)
|
||||
ResolveStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest
|
||||
CreateTranscodeParams(decision *Decision) (string, error)
|
||||
ParseTranscodeParams(token string) (*Params, error)
|
||||
ValidateTranscodeParams(ctx context.Context, token string, mediaID string) (*Params, *model.MediaFile, error)
|
||||
}
|
||||
|
||||
|
||||
@ -14,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
NewMediaStreamer,
|
||||
GetTranscodingCache,
|
||||
transcode.NewMediaStreamer,
|
||||
transcode.GetTranscodingCache,
|
||||
NewArchiver,
|
||||
NewPlayers,
|
||||
NewShare,
|
||||
|
||||
@ -225,14 +225,14 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool
|
||||
return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil
|
||||
}
|
||||
|
||||
// noopStreamer implements core.MediaStreamer
|
||||
// noopStreamer implements transcode.MediaStreamer
|
||||
type noopStreamer struct{}
|
||||
|
||||
func (n noopStreamer) NewStream(context.Context, core.StreamRequest) (*core.Stream, error) {
|
||||
func (n noopStreamer) NewStream(context.Context, transcode.StreamRequest) (*transcode.Stream, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, core.StreamRequest) (*core.Stream, error) {
|
||||
func (n noopStreamer) DoStream(context.Context, *model.MediaFile, transcode.StreamRequest) (*transcode.Stream, error) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
|
||||
@ -243,12 +243,12 @@ func (n noopDecider) MakeDecision(context.Context, *model.MediaFile, *transcode.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (n noopDecider) CreateTranscodeParams(*transcode.Decision) (string, error) {
|
||||
return "", nil
|
||||
func (n noopDecider) ResolveStream(context.Context, *model.MediaFile, string, int, int) transcode.StreamRequest {
|
||||
return transcode.StreamRequest{Format: "raw"}
|
||||
}
|
||||
|
||||
func (n noopDecider) ParseTranscodeParams(string) (*transcode.Params, error) {
|
||||
return nil, nil
|
||||
func (n noopDecider) CreateTranscodeParams(*transcode.Decision) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (n noopDecider) ValidateTranscodeParams(context.Context, string, string) (*transcode.Params, *model.MediaFile, error) {
|
||||
@ -318,12 +318,12 @@ func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error {
|
||||
|
||||
// Compile-time interface checks
|
||||
var (
|
||||
_ artwork.Artwork = noopArtwork{}
|
||||
_ core.MediaStreamer = noopStreamer{}
|
||||
_ core.Archiver = noopArchiver{}
|
||||
_ external.Provider = noopProvider{}
|
||||
_ scrobbler.PlayTracker = noopPlayTracker{}
|
||||
_ transcode.Decider = noopDecider{}
|
||||
_ artwork.Artwork = noopArtwork{}
|
||||
_ transcode.MediaStreamer = noopStreamer{}
|
||||
_ core.Archiver = noopArchiver{}
|
||||
_ external.Provider = noopProvider{}
|
||||
_ scrobbler.PlayTracker = noopPlayTracker{}
|
||||
_ transcode.Decider = noopDecider{}
|
||||
)
|
||||
|
||||
var _ = BeforeSuite(func() {
|
||||
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
@ -23,7 +23,7 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stream, err := pub.streamer.NewStream(ctx, core.StreamRequest{
|
||||
stream, err := pub.streamer.NewStream(ctx, transcode.StreamRequest{
|
||||
ID: info.id, Format: info.format, BitRate: info.bitrate,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@ -11,6 +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/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
@ -20,14 +21,14 @@ import (
|
||||
type Router struct {
|
||||
http.Handler
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
streamer transcode.MediaStreamer
|
||||
archiver core.Archiver
|
||||
share core.Share
|
||||
assetsHandler http.Handler
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, share core.Share, archiver core.Archiver) *Router {
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.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())))
|
||||
|
||||
@ -39,7 +39,7 @@ type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
streamer transcode.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
@ -54,7 +54,7 @@ type Router struct {
|
||||
transcodeDecision transcode.Decider
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer transcode.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,
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -18,7 +17,7 @@ import (
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *core.Stream, id string) {
|
||||
func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *transcode.Stream, id string) {
|
||||
if stream.Seekable() {
|
||||
http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream)
|
||||
} else {
|
||||
@ -66,7 +65,7 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su
|
||||
return nil, err
|
||||
}
|
||||
|
||||
streamReq := api.resolveStreamRequest(ctx, mf, format, maxBitRate, timeOffset)
|
||||
streamReq := api.transcodeDecision.ResolveStream(ctx, mf, format, maxBitRate, timeOffset)
|
||||
stream, err := api.streamer.DoStream(ctx, mf, streamReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -136,7 +135,7 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
|
||||
|
||||
switch v := entity.(type) {
|
||||
case *model.MediaFile:
|
||||
streamReq := api.resolveStreamRequest(ctx, v, format, maxBitRate, 0)
|
||||
streamReq := api.transcodeDecision.ResolveStream(ctx, v, format, maxBitRate, 0)
|
||||
stream, err := api.streamer.DoStream(ctx, v, streamReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -169,77 +168,3 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
|
||||
// into a transcode.ClientInfo for use with MakeDecision.
|
||||
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
|
||||
// MakeDecision's applyServerOverride.
|
||||
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *transcode.ClientInfo {
|
||||
ci := &transcode.ClientInfo{Name: "legacy"}
|
||||
|
||||
// Determine target format for transcoding
|
||||
var targetFormat string
|
||||
switch {
|
||||
case reqFormat != "":
|
||||
targetFormat = reqFormat
|
||||
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
|
||||
targetFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
if targetFormat != "" {
|
||||
ci.DirectPlayProfiles = []transcode.DirectPlayProfile{
|
||||
{Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{transcode.ProtocolHTTP}},
|
||||
}
|
||||
ci.TranscodingProfiles = []transcode.Profile{
|
||||
{Container: targetFormat, AudioCodec: targetFormat, Protocol: transcode.ProtocolHTTP},
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
ci.MaxAudioBitrate = reqBitRate
|
||||
ci.MaxTranscodingAudioBitrate = reqBitRate
|
||||
}
|
||||
} else {
|
||||
// No transcoding requested — direct play everything
|
||||
ci.DirectPlayProfiles = []transcode.DirectPlayProfile{
|
||||
{Protocols: []string{transcode.ProtocolHTTP}},
|
||||
}
|
||||
}
|
||||
|
||||
return ci
|
||||
}
|
||||
|
||||
// resolveStreamRequest uses MakeDecision to resolve legacy stream parameters
|
||||
// into a fully specified StreamRequest.
|
||||
func (api *Router) resolveStreamRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) core.StreamRequest {
|
||||
req := core.StreamRequest{ID: mf.ID, Offset: offset}
|
||||
|
||||
if reqFormat == "raw" {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
|
||||
decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, transcode.DecisionOptions{SkipProbe: true})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanDirectPlay {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
req.Format = decision.TargetFormat
|
||||
req.BitRate = decision.TargetBitrate
|
||||
req.SampleRate = decision.TargetSampleRate
|
||||
req.BitDepth = decision.TargetBitDepth
|
||||
req.Channels = decision.TargetChannels
|
||||
return req
|
||||
}
|
||||
|
||||
// No compatible profile — fallback to raw
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
@ -360,7 +359,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*
|
||||
}
|
||||
|
||||
// Build streaming parameters from the token
|
||||
streamReq := core.StreamRequest{ID: mediaID, Offset: p.IntOr("offset", 0)}
|
||||
streamReq := transcode.StreamRequest{ID: mediaID, Offset: p.IntOr("offset", 0)}
|
||||
if !params.DirectPlay && params.TargetFormat != "" {
|
||||
streamReq.Format = params.TargetFormat
|
||||
streamReq.BitRate = params.TargetBitrate // Already in kbps, matching the streamer
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
@ -369,8 +368,6 @@ type mockTranscodeDecision struct {
|
||||
decision *transcode.Decision
|
||||
token string
|
||||
tokenErr error
|
||||
params *transcode.Params
|
||||
parseErr error
|
||||
validateParams *transcode.Params
|
||||
validateMF *model.MediaFile
|
||||
validateErr error
|
||||
@ -383,15 +380,12 @@ func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFi
|
||||
return &transcode.Decision{}, nil
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) {
|
||||
return m.token, m.tokenErr
|
||||
func (m *mockTranscodeDecision) ResolveStream(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) transcode.StreamRequest {
|
||||
return transcode.StreamRequest{Format: "raw"}
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) ParseTranscodeParams(_ string) (*transcode.Params, error) {
|
||||
if m.parseErr != nil {
|
||||
return nil, m.parseErr
|
||||
}
|
||||
return m.params, nil
|
||||
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) {
|
||||
return m.token, m.tokenErr
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) ValidateTranscodeParams(_ context.Context, _ string, _ string) (*transcode.Params, *model.MediaFile, error) {
|
||||
@ -406,15 +400,15 @@ func (m *mockTranscodeDecision) ValidateTranscodeParams(_ context.Context, _ str
|
||||
var errStreamCaptured = errors.New("stream request captured")
|
||||
|
||||
type fakeMediaStreamer struct {
|
||||
captured *core.StreamRequest
|
||||
captured *transcode.StreamRequest
|
||||
}
|
||||
|
||||
func (f *fakeMediaStreamer) NewStream(_ context.Context, req core.StreamRequest) (*core.Stream, error) {
|
||||
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 core.StreamRequest) (*core.Stream, error) {
|
||||
func (f *fakeMediaStreamer) DoStream(_ context.Context, _ *model.MediaFile, req transcode.StreamRequest) (*transcode.Stream, error) {
|
||||
f.captured = &req
|
||||
return nil, errStreamCaptured
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user