From ae1e0ddb11a3e18660762f6ec7c096732fa77904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 8 Mar 2026 23:57:49 -0400 Subject: [PATCH] feat(subsonic): implement OpenSubsonic Transcoding extension (#4990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(subsonic): implement transcode decision logic and codec handling for media files Signed-off-by: Deluan * fix(subsonic): update codec limitation structure and decision logic for improved clarity Signed-off-by: Deluan * fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic Signed-off-by: Deluan * refactor(transcoding): simplify container alias handling in matchesContainer function Signed-off-by: Deluan * fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests Signed-off-by: Deluan * feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic Signed-off-by: Deluan * refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability Signed-off-by: Deluan * refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks Signed-off-by: Deluan * refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity Signed-off-by: Deluan * refactor(transcoding): enhance logging for transcode decision process and client info conversion Signed-off-by: Deluan * refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity Signed-off-by: Deluan * refactor(transcoding): enhance transcoding config lookup logic for audio codecs Signed-off-by: Deluan * refactor(transcoding): enhance transcoding options with sample rate support and improve command handling Signed-off-by: Deluan * refactor(transcoding): add bit depth support for audio transcoding and enhance related logic Signed-off-by: Deluan * refactor(transcoding): enhance AAC command handling and support for audio channels in streaming Signed-off-by: Deluan * refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping Signed-off-by: Deluan * refactor(transcoding): update default command handling and add codec support for transcoding Signed-off-by: Deluan * fix: implement noopDecider for transcoding decision handling in tests Signed-off-by: Deluan * fix: address review findings for OpenSubsonic transcoding PR Fix multiple issues identified during code review of the transcoding extension: add missing return after error in shared stream handler preventing nil pointer panic, replace dead r.Body nil check with MaxBytesReader size limit, distinguish not-found from other DB errors, fix bpsToKbps integer truncation with rounding, add "pcm" to isLosslessFormat for consistency with model.IsLossless(), add sampleRate/bitDepth/channels to streaming log, fix outdated test comment, and add tests for conversion functions and GetTranscodeStream parameter passing. * feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters Signed-off-by: Deluan * fix: small issues Updated mock AAC transcoding command to use the new default (ipod with fragmented MP4) matching the migration, ensuring tests exercise the same buildDynamicArgs code path as production. Improved archiver test mock to match on the whole StreamRequest struct instead of decomposing fields, making it resilient to future field additions. Added named constants for JWT claim keys in the transcode token and wrapped ParseTranscodeParams errors with ErrTokenInvalid for consistency. Documented the IsLossless BitDepth fallback heuristic as temporary until Codec column is populated. Signed-off-by: Deluan * fix(transcoding): adapt transcode claims to struct-based auth.Claims Updated transcode token handling to use the struct-based auth.Claims introduced on master, replacing the previous map[string]any approach. Extended auth.Claims with transcoding-specific fields (MediaID, DirectPlay, UpdatedAt, Channels, SampleRate, BitDepth) and added float64 fallback in ClaimsFromToken for numeric claims that lose their Go type during JWT string serialization. Also added the missing lyrics parameter to all subsonic.New() calls in test files. * feat(model): add ProbeData field and UpdateProbeData repository method Add probe_data TEXT column to media_file for caching ffprobe results. Add UpdateProbeData to MediaFileRepository interface and implementations. Use hash:"ignore" tag so probe data doesn't affect MediaFile fingerprints. * feat(ffmpeg): add ProbeAudioStream for authoritative audio metadata Add ProbeAudioStream to FFmpeg interface, using ffprobe to extract codec, profile, bitrate, sample rate, bit depth, and channels. Parse bits_per_raw_sample as fallback for FLAC/ALAC bit depth. Normalize "unknown" profile to empty string. All parseProbeOutput tests use real ffprobe JSON from actual files. * feat(transcoding): integrate ffprobe into transcode decisions Add ensureProbed to probe media files on first transcode decision, caching results in probe_data. Build SourceStream from probe data with fallback to tag-based metadata. Refactor decision logic to pass StreamDetails instead of MediaFile, enabling codec profile limitations (e.g., audioProfile) to use probe data. Add normalizeProbeCodec to map ffprobe codec names (dsd_lsbf_planar, pcm_s16le) to internal names (dsd, pcm). NewDecider now accepts ffmpeg.FFmpeg; wire_gen.go regenerated. * feat(transcoding): add DevEnableMediaFileProbe config flag Add DevEnableMediaFileProbe (default true) to allow disabling ffprobe- based media file probing as a safety fallback. When disabled, the decider uses tag-based metadata from the scanner instead. * test(transcode): add ensureProbed unit tests Test probing when ProbeData is empty, skipping when already set, error propagation from ffprobe, and DevEnableMediaFileProbe flag. * refactor(ffmpeg): use command constant and select_streams for ProbeAudioStream Move ffprobe arguments to a probeAudioStreamCmd constant, following the same pattern as extractImageCmd and probeCmd. Add -select_streams a:0 to only probe the first audio stream, avoiding unnecessary parsing of video and artwork streams. Derive the ffprobe binary path safely using filepath.Dir/Base instead of replacing within the full path string. * refactor(transcode): decouple transcode token claims from auth.Claims Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt, Channels, SampleRate, BitDepth) from auth.Claims, which is shared with session and share tokens. Transcode tokens are signed parameter-passing tokens, not authentication tokens, so coupling them to auth created misleading dependencies. The transcode package now owns its own JWT claim serialization via Decision.toClaimsMap() and paramsFromToken(), using generic auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight tokens remain compatible. Signed-off-by: Deluan * refactor(transcode): simplify code after review Extract getIntClaim helper to eliminate repeated int/int64/float64 JWT claim extraction pattern in paramsFromToken and ClaimsFromToken. Rewrite checkIntLimitation as a one-liner delegating to applyIntLimitation. Return probe result from ensureProbed to avoid redundant JSON round-trip. Extract toResponseStreamDetails helper and mediaTypeSong constant in the API layer, and use transcode.ProtocolHTTP constant instead of hardcoded string. Signed-off-by: Deluan * fix(ffmpeg): enhance bit_rate parsing logic for audio streams Signed-off-by: Deluan * fix(transcode): improve code review findings across transcode implementation - Fix parseProbeData to return nil on JSON unmarshal failure instead of a zero-valued struct, preventing silent degradation of source stream details - Use probe-resolved codec for lossless detection in buildSourceStream instead of the potentially stale scanner data - Remove MediaFile.IsLossless() (dead code) and consolidate lossless detection in isLosslessFormat(), using codec name only — bit depth is not reliable since lossy codecs like ADPCM report non-zero values - Add "wavpack" to lossless codec list (ffprobe codec_name for WavPack) - Guard bpsToKbps against negative input values - Fix misleading comment in buildTemplateArgs about conditional injection - Avoid leaking internal error details in Subsonic API responses - Add missing test for ErrNotFound branch in GetTranscodeDecision - Add TODO for hardcoded protocol in toResponseStreamDetails * refactor(transcode): streamline transcoding command lookup and format resolution Signed-off-by: Deluan * feat(transcode): implement server-side transcoding override for player formats Signed-off-by: Deluan * fix(transcode): honor bit depth and channel constraints in transcoding selection selectTranscodingOptions only checked sample rate when deciding whether same-format transcoding was needed, ignoring requested bit depth and channel reductions. This caused the streamer to return raw audio when the transcode decision requested downmix or bit-depth conversion. * refactor(transcode): unify streaming decision engine via MakeDecision Move transcoding decision-making out of mediaStreamer and into the subsonic Stream/Download handlers, using transcode.Decider.MakeDecision as the single decision engine. This eliminates selectTranscodingOptions and the mismatch between decision and streaming code paths (decision used LookupTranscodeCommand with built-in fallbacks, while streaming used FindByFormat which only checked the DB). - Add DecisionOptions with SkipProbe to MakeDecision so the legacy streaming path never calls ffprobe - Add buildLegacyClientInfo to translate legacy stream params (format, maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo - Add resolveStreamRequest on the subsonic Router to resolve legacy params into a fully specified StreamRequest via MakeDecision - Simplify DoStream to a dumb executor that receives pre-resolved params - Remove selectTranscodingOptions entirely Signed-off-by: Deluan * 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. * refactor(transcode): rename Decider methods and unexport Params type Rename ResolveStream → ResolveRequest and ValidateTranscodeParams → ResolveRequestFromToken for clarity and consistency. The new ResolveRequestFromToken returns a StreamRequest directly (instead of the intermediate Params type), eliminating manual Params→StreamRequest conversion in callers. Unexport Params to params since it is now only used internally for JWT token parsing. * test(transcode): remove redundant tests and use constants Remove tests that duplicate coverage from integration-level tests (toClaimsMap, paramsFromToken round-trips, applyServerOverride direct call, duplicate 410 handler test). Replace raw "http" strings with ProtocolHTTP constant. Consolidate lossy -sample_fmt tests into DescribeTable. * refactor(transcode): split oversized files into focused modules Split transcode.go and transcode_test.go into focused files by concern: - decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe) - token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken) - legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest) - codec_test.go: isLosslessFormat and normalizeProbeCodec tests - token_test.go: token round-trip and ResolveRequestFromToken tests Moved the Decider interface from types.go to decider.go to keep it near its implementation, and cleaned up types.go to contain only pure type definitions and constants. No public API changes. * refactor(transcode): reorder parameters in applyServerOverride function Signed-off-by: Deluan * test(e2e): add NewTestStream function and implement spyStreamer for testing Signed-off-by: Deluan --------- Signed-off-by: Deluan --- adapters/gotaglib/gotaglib.go | 1 + cmd/wire_gen.go | 12 +- conf/configuration.go | 2 + consts/consts.go | 8 +- core/archiver.go | 7 +- core/archiver_test.go | 17 +- core/auth/auth.go | 13 + core/auth/claims.go | 8 +- core/ffmpeg/ffmpeg.go | 276 ++++- core/ffmpeg/ffmpeg_test.go | 498 +++++++- core/media_streamer_Internal_test.go | 162 --- core/transcode/aliases.go | 87 ++ core/transcode/codec.go | 77 ++ core/transcode/codec_test.go | 69 ++ core/transcode/decider.go | 425 +++++++ core/transcode/decider_test.go | 1087 +++++++++++++++++ core/transcode/legacy_client.go | 85 ++ core/transcode/legacy_client_test.go | 84 ++ core/transcode/limitations.go | 171 +++ core/{ => transcode}/media_streamer.go | 144 +-- core/{ => transcode}/media_streamer_test.go | 27 +- core/transcode/token.go | 155 +++ core/transcode/token_test.go | 272 +++++ core/transcode/transcode_suite_test.go | 17 + core/transcode/types.go | 134 ++ core/wire_providers.go | 6 +- ...75815_add_codec_and_update_transcodings.go | 73 ++ model/mediafile.go | 60 + model/mediafile_test.go | 54 +- model/metadata/map_mediafile.go | 1 + model/metadata/metadata.go | 1 + persistence/mediafile_repository.go | 5 + server/e2e/e2e_suite_test.go | 65 +- server/e2e/subsonic_media_retrieval_test.go | 124 ++ server/public/handle_streams.go | 6 +- server/public/public.go | 5 +- server/subsonic/album_lists_test.go | 2 +- server/subsonic/api.go | 65 +- server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval_test.go | 2 +- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 5 +- server/subsonic/playlists_test.go | 4 +- server/subsonic/responses/responses.go | 24 + server/subsonic/searching_test.go | 2 +- server/subsonic/stream.go | 15 +- server/subsonic/transcode.go | 381 ++++++ server/subsonic/transcode_test.go | 406 ++++++ tests/mock_ffmpeg.go | 18 +- tests/mock_mediafile_repo.go | 11 + tests/mock_transcoding_repo.go | 4 + 51 files changed, 4828 insertions(+), 352 deletions(-) delete mode 100644 core/media_streamer_Internal_test.go create mode 100644 core/transcode/aliases.go create mode 100644 core/transcode/codec.go create mode 100644 core/transcode/codec_test.go create mode 100644 core/transcode/decider.go create mode 100644 core/transcode/decider_test.go create mode 100644 core/transcode/legacy_client.go create mode 100644 core/transcode/legacy_client_test.go create mode 100644 core/transcode/limitations.go rename core/{ => transcode}/media_streamer.go (56%) rename core/{ => transcode}/media_streamer_test.go (69%) create mode 100644 core/transcode/token.go create mode 100644 core/transcode/token_test.go create mode 100644 core/transcode/transcode_suite_test.go create mode 100644 core/transcode/types.go create mode 100644 db/migrations/20260307175815_add_codec_and_update_transcodings.go create mode 100644 server/subsonic/transcode.go create mode 100644 server/subsonic/transcode_test.go diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 7b827e880..9b71cb462 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -77,6 +77,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { Channels: int(props.Channels), SampleRate: int(props.SampleRate), BitDepth: int(props.BitsPerSample), + Codec: props.Codec, } // Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index e8df9a386..a7a0769d3 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -21,6 +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/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -94,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) @@ -105,7 +106,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics) + decider := transcode.NewDecider(dataStore, fFmpeg) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, decider) return router } @@ -120,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) diff --git a/conf/configuration.go b/conf/configuration.go index c46879d42..da549ce26 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -139,6 +139,7 @@ type configOptions struct { DevExternalArtistFetchMultiplier float64 DevOptimizeDB bool DevPreserveUnicodeInExternalCalls bool + DevEnableMediaFileProbe bool } type scannerOptions struct { @@ -763,6 +764,7 @@ func setViperDefaults() { viper.SetDefault("devexternalartistfetchmultiplier", 1.5) viper.SetDefault("devoptimizedb", true) viper.SetDefault("devpreserveunicodeinexternalcalls", false) + viper.SetDefault("devenablemediafileprobe", true) } func init() { diff --git a/consts/consts.go b/consts/consts.go index 295abe8a9..2a5fdd94a 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -153,7 +153,13 @@ var ( Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + }, + { + Name: "flac audio", + TargetFormat: "flac", + DefaultBitRate: 0, + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/archiver.go b/core/archiver.go index 63459816e..88b2d5b0e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -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, format, bitrate, 0) + r, err = a.ms.DoStream(ctx, &mf, transcode.StreamRequest{Format: format, BitRate: bitrate}) } else { r, err = os.Open(path) } diff --git a/core/archiver_test.go b/core/archiver_test.go index 37c4ef9ab..bfce641c9 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -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, "mp3", 128, 0).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, "mp3", 128, 0).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, "mp3", 128, 0).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, "mp3", 128, 0).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, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) { - args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset) +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 { diff --git a/core/auth/auth.go b/core/auth/auth.go index f7ab3ac1b..a75111b35 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -120,6 +120,19 @@ func createNewSecret(ctx context.Context, ds model.DataStore) string { return secret } +// EncodeToken creates a signed JWT from an arbitrary claims map. +// It sets the issuer claim automatically. +func EncodeToken(claims map[string]any) (string, error) { + claims[jwt.IssuerKey] = consts.JWTIssuer + _, token, err := TokenAuth.Encode(claims) + return token, err +} + +// DecodeAndVerifyToken verifies a JWT string and returns the parsed token. +func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) { + return jwtauth.VerifyToken(TokenAuth, tokenStr) +} + func getEncKey() []byte { key := cmp.Or( conf.Server.PasswordEncryptionKey, diff --git a/core/auth/claims.go b/core/auth/claims.go index ca496ae9a..c0e4dea7f 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -86,9 +86,11 @@ func ClaimsFromToken(token jwt.Token) Claims { if err := token.Get("f", &f); err == nil { c.Format = f } - var b int - if err := token.Get("b", &b); err == nil { - c.BitRate = b + if err := token.Get("b", &c.BitRate); err != nil { + var bf float64 + if err := token.Get("b", &bf); err == nil { + c.BitRate = int(bf) + } } return c } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index d134077ce..7202d02dd 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -2,23 +2,49 @@ package ffmpeg import ( "context" + "encoding/json" "errors" "fmt" "io" "os" "os/exec" + "path/filepath" "strconv" "strings" "sync" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" ) +// TranscodeOptions contains all parameters for a transcoding operation. +type TranscodeOptions struct { + Command string // DB command template (used to detect custom vs default) + Format string // Target format (mp3, opus, aac, flac) + FilePath string + BitRate int // kbps, 0 = codec default + SampleRate int // 0 = no constraint + Channels int // 0 = no constraint + BitDepth int // 0 = no constraint; valid values: 16, 24, 32 + Offset int // seconds +} + +// AudioProbeResult contains authoritative audio stream properties from ffprobe. +type AudioProbeResult struct { + Codec string `json:"codec"` + Profile string `json:"profile,omitempty"` + BitRate int `json:"bitRate"` + SampleRate int `json:"sampleRate"` + BitDepth int `json:"bitDepth"` + Channels int `json:"channels"` +} + type FFmpeg interface { - Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) + Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) Probe(ctx context.Context, files []string) (string, error) + ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) IsAvailable() bool Version() string @@ -29,21 +55,26 @@ func New() FFmpeg { } const ( - extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" - probeCmd = "ffmpeg %s -f ffmetadata" + extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" + probeCmd = "ffmpeg %s -f ffmetadata" + probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s" ) type ffmpeg struct{} -func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) { +func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err } - // First make sure the file exists - if err := fileExists(path); err != nil { + if err := fileExists(opts.FilePath); err != nil { return nil, err } - args := createFFmpegCommand(command, path, maxBitRate, offset) + var args []string + if isDefaultCommand(opts.Format, opts.Command) { + args = buildDynamicArgs(opts) + } else { + args = buildTemplateArgs(opts) + } return e.start(ctx, args) } @@ -51,7 +82,6 @@ func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, if _, err := ffmpegCmd(); err != nil { return nil, err } - // First make sure the file exists if err := fileExists(path); err != nil { return nil, err } @@ -81,6 +111,91 @@ func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) { return string(output), nil } +func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) { + if _, err := ffmpegCmd(); err != nil { + return nil, err + } + if err := fileExists(filePath); err != nil { + return nil, err + } + args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0) + log.Trace(ctx, "Executing ffprobe command", "args", args) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err) + } + return parseProbeOutput(output) +} + +type probeOutput struct { + Streams []probeStream `json:"streams"` + Format probeFormat `json:"format"` +} + +type probeFormat struct { + BitRate string `json:"bit_rate"` +} + +type probeStream struct { + CodecName string `json:"codec_name"` + CodecType string `json:"codec_type"` + Profile string `json:"profile"` + SampleRate string `json:"sample_rate"` + BitRate string `json:"bit_rate"` + Channels int `json:"channels"` + BitsPerSample int `json:"bits_per_sample"` + BitsPerRawSample string `json:"bits_per_raw_sample"` +} + +func parseProbeOutput(data []byte) (*AudioProbeResult, error) { + var output probeOutput + if err := json.Unmarshal(data, &output); err != nil { + return nil, fmt.Errorf("parsing ffprobe output: %w", err) + } + + for _, s := range output.Streams { + if s.CodecType != "audio" { + continue + } + bitDepth := s.BitsPerSample + if bitDepth == 0 && s.BitsPerRawSample != "" { + bitDepth, _ = strconv.Atoi(s.BitsPerRawSample) + } + result := &AudioProbeResult{ + Codec: s.CodecName, + Channels: s.Channels, + BitDepth: bitDepth, + } + + // Profile: "unknown" → empty + if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") { + result.Profile = s.Profile + } + + // Sample rate: string → int + if s.SampleRate != "" { + result.SampleRate, _ = strconv.Atoi(s.SampleRate) + } + + // Bit rate: bps string → kbps int + if s.BitRate != "" { + bps, _ := strconv.Atoi(s.BitRate) + result.BitRate = bps / 1000 + } + + // Fallback to format-level bit_rate (needed for FLAC, Opus, etc.) + if result.BitRate == 0 && output.Format.BitRate != "" { + bps, _ := strconv.Atoi(output.Format.BitRate) + result.BitRate = bps / 1000 + } + + return result, nil + } + + return nil, fmt.Errorf("no audio stream found in ffprobe output") +} + func (e *ffmpeg) CmdPath() (string, error) { return ffmpegCmd() } @@ -156,6 +271,141 @@ func (j *ffCmd) wait() { _ = j.out.Close() } +// formatCodecMap maps target format to ffmpeg codec flag. +var formatCodecMap = map[string]string{ + "mp3": "libmp3lame", + "opus": "libopus", + "aac": "aac", + "flac": "flac", +} + +// formatOutputMap maps target format to ffmpeg output format flag (-f). +var formatOutputMap = map[string]string{ + "mp3": "mp3", + "opus": "opus", + "aac": "ipod", + "flac": "flac", +} + +// defaultCommands is used to detect whether a user has customized their transcoding command. +var defaultCommands = func() map[string]string { + m := make(map[string]string, len(consts.DefaultTranscodings)) + for _, t := range consts.DefaultTranscodings { + m[t.TargetFormat] = t.Command + } + return m +}() + +// isDefaultCommand returns true if the command matches the known default for this format. +func isDefaultCommand(format, command string) bool { + return defaultCommands[format] == command +} + +// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats, +// including all transcoding parameters (bitrate, sample rate, channels). +func buildDynamicArgs(opts TranscodeOptions) []string { + cmdPath, _ := ffmpegCmd() + args := []string{cmdPath, "-i", opts.FilePath} + + if opts.Offset > 0 { + args = append(args, "-ss", strconv.Itoa(opts.Offset)) + } + + args = append(args, "-map", "0:a:0") + + if codec, ok := formatCodecMap[opts.Format]; ok { + args = append(args, "-c:a", codec) + } + + if opts.BitRate > 0 { + args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k") + } + if opts.SampleRate > 0 { + args = append(args, "-ar", strconv.Itoa(opts.SampleRate)) + } + if opts.Channels > 0 { + args = append(args, "-ac", strconv.Itoa(opts.Channels)) + } + // Only pass -sample_fmt for lossless output formats where bit depth matters. + // Lossy codecs (mp3, aac, opus) handle sample format conversion internally, + // and passing interleaved formats like "s16" causes silent failures. + if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { + args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) + } + + args = append(args, "-v", "0") + + if outputFmt, ok := formatOutputMap[opts.Format]; ok { + args = append(args, "-f", outputFmt) + } + + // For AAC in MP4 container, enable fragmented MP4 for pipe-safe streaming + if opts.Format == "aac" { + args = append(args, "-movflags", "frag_keyframe+empty_moov") + } + + args = append(args, "-") + return args +} + +// buildTemplateArgs handles user-customized command templates, with dynamic injection +// of sample rate, channels, and bit depth when requested by the transcode decision. +// Note: these flags are injected unconditionally when non-zero, even if the template +// already includes them. FFmpeg uses the last occurrence of duplicate flags. +func buildTemplateArgs(opts TranscodeOptions) []string { + args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset) + + // Dynamically inject -ar, -ac, and -sample_fmt before the output target + if opts.SampleRate > 0 { + args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate)) + } + if opts.Channels > 0 { + args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels)) + } + if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) { + args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth)) + } + return args +} + +// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output). +func injectBeforeOutput(args []string, flag, value string) []string { + if len(args) > 0 && args[len(args)-1] == "-" { + result := make([]string, 0, len(args)+2) + result = append(result, args[:len(args)-1]...) + result = append(result, flag, value, "-") + return result + } + return append(args, flag, value) +} + +// 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. +func isLosslessOutputFormat(format string) bool { + switch strings.ToLower(format) { + case "flac", "alac", "wav", "aiff": + return true + } + return false +} + +// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string. +// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format +// (ffmpeg packs 24-bit samples into 32-bit containers). +func bitDepthToSampleFmt(bitDepth int) string { + switch bitDepth { + case 16: + return "s16" + case 32: + return "s32" + default: + // 24-bit and other depths: use s32 (the next valid container size) + return "s32" + } +} + // Path will always be an absolute path func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string @@ -196,10 +446,20 @@ func fixCmd(cmd string) []string { if s == "ffmpeg" || s == "ffmpeg.exe" { split[i] = cmdPath } + if s == "ffprobe" || s == "ffprobe.exe" { + split[i] = ffprobePath(cmdPath) + } } return split } +// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path. +func ffprobePath(ffmpegCmd string) string { + dir := filepath.Dir(ffmpegCmd) + base := filepath.Base(ffmpegCmd) + return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1)) +} + func ffmpegCmd() (string, error) { ffOnce.Do(func() { if conf.Server.FFmpegPath != "" { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index debe0b51e..eebeefe35 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -2,19 +2,27 @@ package ffmpeg import ( "context" + "os" + "path/filepath" "runtime" sync "sync" "testing" "time" + "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestFFmpeg(t *testing.T) { - tests.Init(t, false) + // Inline test init to avoid import cycle with tests package + //nolint:dogsled + _, file, _, _ := runtime.Caller(0) + appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", "..")) + confPath := filepath.Join(appPath, "tests", "navidrome-test.toml") + _ = os.Chdir(appPath) + conf.LoadFromFile(confPath) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) RunSpecs(t, "FFmpeg Suite") @@ -70,6 +78,473 @@ var _ = Describe("ffmpeg", func() { }) }) + Describe("isDefaultCommand", func() { + It("returns true for known default mp3 command", func() { + Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + }) + It("returns true for known default opus command", func() { + Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + }) + It("returns true for known default aac command", func() { + Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -")).To(BeTrue()) + }) + It("returns true for known default flac command", func() { + Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + }) + It("returns false for a custom command", func() { + Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) + }) + It("returns false for unknown format", func() { + Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse()) + }) + }) + + Describe("buildDynamicArgs", func() { + It("builds mp3 args with bitrate, samplerate, and channels", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "mp3", + FilePath: "/music/file.flac", + BitRate: 256, + SampleRate: 48000, + Channels: 2, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "libmp3lame", + "-b:a", "256k", + "-ar", "48000", + "-ac", "2", + "-v", "0", + "-f", "mp3", + "-", + })) + }) + + It("builds flac args without bitrate", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + SampleRate: 48000, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-map", "0:a:0", + "-c:a", "flac", + "-ar", "48000", + "-v", "0", + "-f", "flac", + "-", + })) + }) + + It("builds opus args with bitrate only", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "opus", + FilePath: "/music/file.flac", + BitRate: 128, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "libopus", + "-b:a", "128k", + "-v", "0", + "-f", "opus", + "-", + })) + }) + + It("includes offset when specified", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "mp3", + FilePath: "/music/file.mp3", + BitRate: 192, + Offset: 30, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.mp3", + "-ss", "30", + "-map", "0:a:0", + "-c:a", "libmp3lame", + "-b:a", "192k", + "-v", "0", + "-f", "mp3", + "-", + })) + }) + + It("builds aac args with fragmented MP4 container", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "aac", + FilePath: "/music/file.flac", + BitRate: 256, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-map", "0:a:0", + "-c:a", "aac", + "-b:a", "256k", + "-v", "0", + "-f", "ipod", + "-movflags", "frag_keyframe+empty_moov", + "-", + })) + }) + + It("builds flac args with bit depth", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 24, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-map", "0:a:0", + "-c:a", "flac", + "-sample_fmt", "s32", + "-v", "0", + "-f", "flac", + "-", + })) + }) + + It("omits -sample_fmt when bit depth is 0", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.flac", + BitDepth: 0, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + It("omits -sample_fmt when bit depth is too low (DSD)", func() { + args := buildDynamicArgs(TranscodeOptions{ + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 1, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }) + + DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16", + func(format string, bitRate int) { + args := buildDynamicArgs(TranscodeOptions{ + Format: format, + FilePath: "/music/file.flac", + BitRate: bitRate, + BitDepth: 16, + }) + Expect(args).ToNot(ContainElement("-sample_fmt")) + }, + Entry("mp3", "mp3", 256), + Entry("aac", "aac", 256), + Entry("opus", "opus", 128), + ) + }) + + Describe("bitDepthToSampleFmt", func() { + It("converts 16-bit", func() { + Expect(bitDepthToSampleFmt(16)).To(Equal("s16")) + }) + It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() { + Expect(bitDepthToSampleFmt(24)).To(Equal("s32")) + }) + It("converts 32-bit", func() { + Expect(bitDepthToSampleFmt(32)).To(Equal("s32")) + }) + }) + + Describe("buildTemplateArgs", func() { + It("injects -ar and -ac into custom template", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + SampleRate: 44100, + Channels: 2, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-ar", "44100", "-ac", "2", + "-", + })) + }) + + It("injects only -ar when channels is 0", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + SampleRate: 48000, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-ar", "48000", + "-", + })) + }) + + It("does not inject anything when sample rate and channels are 0", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + FilePath: "/music/file.flac", + BitRate: 192, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-", + })) + }) + + It("injects -sample_fmt for lossless output format with bit depth", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -", + Format: "flac", + FilePath: "/music/file.dsf", + BitDepth: 24, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.dsf", + "-v", "0", "-c:a", "flac", "-f", "flac", + "-sample_fmt", "s32", + "-", + })) + }) + + It("does not inject -sample_fmt for lossy output format even with bit depth", func() { + args := buildTemplateArgs(TranscodeOptions{ + Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -", + Format: "mp3", + FilePath: "/music/file.flac", + BitRate: 192, + BitDepth: 16, + }) + Expect(args).To(Equal([]string{ + "ffmpeg", "-i", "/music/file.flac", + "-b:a", "192k", "-v", "0", "-f", "mp3", + "-", + })) + }) + }) + + Describe("injectBeforeOutput", func() { + It("inserts flag before trailing dash", func() { + args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000") + Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"})) + }) + + It("appends when no trailing dash", func() { + args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000") + Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"})) + }) + }) + + Describe("parseProbeOutput", func() { + It("parses MP3 with embedded artwork (real ffprobe output)", func() { + // Real: MP3 file with mjpeg artwork stream after audio + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` + + `"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` + + `"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("mp3")) + Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps + Expect(result.BitDepth).To(Equal(0)) // lossy codec + }) + + It("parses AAC-LC in m4a container (real ffprobe output)", func() { + // Real: AAC LC file with profile and artwork + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` + + `"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` + + `"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("aac")) + Expect(result.Profile).To(Equal("LC")) + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps + }) + + It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() { + // Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` + + `{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` + + `"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` + + `"sample_rate":"48000","channels":2,"channel_layout":"stereo",` + + `"bits_per_sample":0,"bit_rate":"55999"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("aac")) + Expect(result.Profile).To(Equal("HE-AACv2")) + Expect(result.SampleRate).To(Equal(48000)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps + }) + + It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() { + // Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample. + // Stream-level bit_rate is absent; format-level bit_rate is used as fallback. + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` + + `"format":{"bit_rate":"906900"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("flac")) + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample + Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps + Expect(result.Profile).To(BeEmpty()) // no profile field in real output + }) + + It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() { + // Real: Opus stream-level bit_rate is absent; format-level is used as fallback. + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":0}],` + + `"format":{"bit_rate":"128000"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("opus")) + Expect(result.SampleRate).To(Equal(48000)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps + Expect(result.BitDepth).To(Equal(0)) + }) + + It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() { + // Real: WAV uses bits_per_sample directly + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` + + `"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` + + `"bits_per_sample":16,"bit_rate":"1411200"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("pcm_s16le")) + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitDepth).To(Equal(16)) + Expect(result.BitRate).To(Equal(1411)) + }) + + It("parses ALAC in m4a container (real ffprobe output)", func() { + // Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` + + `"bits_per_raw_sample":"16"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("alac")) + Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample + Expect(result.SampleRate).To(Equal(44100)) + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps + }) + + It("skips video-only streams", func() { + data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + _, err := parseProbeOutput(data) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no audio stream")) + }) + + It("returns error for empty streams array", func() { + data := []byte(`{"streams":[]}`) + _, err := parseProbeOutput(data) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid JSON", func() { + data := []byte(`not json`) + _, err := parseProbeOutput(data) + Expect(err).To(HaveOccurred()) + }) + + It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() { + // Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` + + `"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` + + `"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` + + `"format":{"bit_rate":"18432000"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("flac")) + Expect(result.SampleRate).To(Equal(192000)) + Expect(result.BitDepth).To(Equal(24)) + Expect(result.Channels).To(Equal(8)) + Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps + }) + + It("parses DSD/DSF file (real ffprobe output)", func() { + // Real: Yes - Owner of a Lonely Heart, DSD64 DSF + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"dsd_lsbf_planar",` + + `"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` + + `"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` + + `"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` + + `{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Codec).To(Equal("dsd_lsbf_planar")) + Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample + Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate + Expect(result.Channels).To(Equal(2)) + Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps + }) + + It("prefers stream-level bit_rate over format-level when both are present", func() { + // ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` + + `"sample_rate":"44100","channels":2,"bits_per_sample":0,` + + `"bit_rate":"1011003","bits_per_raw_sample":"16"}],` + + `"format":{"bit_rate":"1050000"}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050) + }) + + It("returns BitRate 0 when neither stream nor format has bit_rate", func() { + data := []byte(`{"streams":[` + + `{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` + + `"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` + + `"format":{}}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.BitRate).To(Equal(0)) + }) + + It("clears 'unknown' profile to empty string", func() { + data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` + + `"codec_type":"audio","profile":"unknown","sample_rate":"44100",` + + `"channels":2,"bits_per_sample":0}]}`) + result, err := parseProbeOutput(data) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Profile).To(BeEmpty()) + }) + }) + Describe("FFmpeg", func() { Context("when FFmpeg is available", func() { var ff FFmpeg @@ -93,7 +568,12 @@ var _ = Describe("ffmpeg", func() { command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -" // The input file is not used here, but we need to provide a valid path to the Transcode function - stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0) + stream, err := ff.Transcode(ctx, TranscodeOptions{ + Command: command, + Format: "mp3", + FilePath: "tests/fixtures/test.mp3", + BitRate: 128, + }) Expect(err).ToNot(HaveOccurred()) defer stream.Close() @@ -115,7 +595,12 @@ var _ = Describe("ffmpeg", func() { cancel() // Cancel immediately // This should fail immediately - _, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0) + _, err := ff.Transcode(ctx, TranscodeOptions{ + Command: "ffmpeg -i %s -f mp3 -", + Format: "mp3", + FilePath: "tests/fixtures/test.mp3", + BitRate: 128, + }) Expect(err).To(MatchError(context.Canceled)) }) }) @@ -142,7 +627,10 @@ var _ = Describe("ffmpeg", func() { defer cancel() // Start a process that will run for a while - stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0) + stream, err := ff.Transcode(ctx, TranscodeOptions{ + Command: longRunningCmd, + FilePath: "tests/fixtures/test.mp3", + }) Expect(err).ToNot(HaveOccurred()) defer stream.Close() diff --git a/core/media_streamer_Internal_test.go b/core/media_streamer_Internal_test.go deleted file mode 100644 index 44fbf701c..000000000 --- a/core/media_streamer_Internal_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package core - -import ( - "context" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/tests" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("MediaStreamer", func() { - var ds model.DataStore - ctx := log.NewContext(context.Background()) - - BeforeEach(func() { - ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} - }) - - Context("selectTranscodingOptions", func() { - mf := &model.MediaFile{} - Context("player is not configured", func() { - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) - Expect(format).To(Equal("raw")) - }) - It("returns raw if a transcoder does not exists", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "m4a", 0) - Expect(format).To(Equal("raw")) - }) - It("returns the requested format if a transcoder exists", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns raw if requested format is the same as the original and it is not necessary to downsample", func() { - mf.Suffix = "mp3" - mf.BitRate = 112 - format, _ := selectTranscodingOptions(ctx, ds, mf, "mp3", 128) - Expect(format).To(Equal("raw")) - }) - It("returns the requested format if requested BitRate is lower than original", func() { - mf.Suffix = "mp3" - mf.BitRate = 320 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(192)) - }) - It("returns raw if requested format is the same as the original, but requested BitRate is 0", func() { - mf.Suffix = "mp3" - mf.BitRate = 320 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(320)) - }) - Context("Downsampling", func() { - BeforeEach(func() { - conf.Server.DefaultDownsamplingFormat = "opus" - mf.Suffix = "FLAC" - mf.BitRate = 960 - }) - It("returns the DefaultDownsamplingFormat if a maxBitrate is requested but not the format", func() { - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 128) - Expect(format).To(Equal("opus")) - Expect(bitRate).To(Equal(128)) - }) - It("returns raw if maxBitrate is equal or greater than original", func() { - // This happens with DSub (and maybe other clients?). See https://github.com/navidrome/navidrome/issues/2066 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 960) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(0)) - }) - }) - }) - - Context("player has format configured", func() { - BeforeEach(func() { - t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} - ctx = request.WithTranscoding(ctx, t) - }) - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) - Expect(format).To(Equal("raw")) - }) - It("returns configured format/bitrate as default", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(96)) - }) - It("returns requested format", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns requested bitrate", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 80) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(80)) - }) - It("returns raw if selected bitrate and format is the same as original", func() { - mf.Suffix = "mp3" - mf.BitRate = 192 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 192) - Expect(format).To(Equal("raw")) - Expect(bitRate).To(Equal(0)) - }) - }) - - Context("player has maxBitRate configured", func() { - BeforeEach(func() { - t := model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 96} - p := model.Player{ID: "player1", TranscodingId: t.ID, MaxBitRate: 192} - ctx = request.WithTranscoding(ctx, t) - ctx = request.WithPlayer(ctx, p) - }) - It("returns raw if raw is requested", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, _ := selectTranscodingOptions(ctx, ds, mf, "raw", 0) - Expect(format).To(Equal("raw")) - }) - It("returns configured format/bitrate as default", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 0) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(192)) - }) - It("returns requested format", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "mp3", 0) - Expect(format).To(Equal("mp3")) - Expect(bitRate).To(Equal(160)) // Default Bit Rate - }) - It("returns requested bitrate", func() { - mf.Suffix = "flac" - mf.BitRate = 1000 - format, bitRate := selectTranscodingOptions(ctx, ds, mf, "", 160) - Expect(format).To(Equal("oga")) - Expect(bitRate).To(Equal(160)) - }) - }) - }) -}) diff --git a/core/transcode/aliases.go b/core/transcode/aliases.go new file mode 100644 index 000000000..67a641511 --- /dev/null +++ b/core/transcode/aliases.go @@ -0,0 +1,87 @@ +package transcode + +import ( + "slices" + "strings" +) + +// containerAliasGroups maps each container alias to a canonical group name. +var containerAliasGroups = func() map[string]string { + groups := [][]string{ + {"aac", "adts", "m4a", "mp4", "m4b", "m4p"}, + {"mpeg", "mp3", "mp2"}, + {"ogg", "oga"}, + {"aif", "aiff"}, + {"asf", "wma"}, + {"mpc", "mpp"}, + {"wv"}, + } + m := make(map[string]string) + for _, g := range groups { + canonical := g[0] + for _, name := range g { + m[name] = canonical + } + } + return m +}() + +// codecAliasGroups maps each codec alias to a canonical group name. +// Codecs within the same group are considered equivalent. +var codecAliasGroups = func() map[string]string { + groups := [][]string{ + {"aac", "adts"}, + {"ac3", "ac-3"}, + {"eac3", "e-ac3", "e-ac-3", "eac-3"}, + {"mpc7", "musepack7"}, + {"mpc8", "musepack8"}, + {"wma1", "wmav1"}, + {"wma2", "wmav2"}, + {"wmalossless", "wma9lossless"}, + {"wmapro", "wma9pro"}, + {"shn", "shorten"}, + {"mp4als", "als"}, + } + m := make(map[string]string) + for _, g := range groups { + for _, name := range g { + m[name] = g[0] // canonical = first entry + } + } + return m +}() + +// matchesWithAliases checks if a value matches any entry in candidates, +// consulting the alias map for equivalent names. +func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool { + value = strings.ToLower(value) + canonical := aliases[value] + for _, c := range candidates { + c = strings.ToLower(c) + if c == value { + return true + } + if canonical != "" && aliases[c] == canonical { + return true + } + } + return false +} + +// matchesContainer checks if a file suffix matches any of the container names, +// including common aliases. +func matchesContainer(suffix string, containers []string) bool { + return matchesWithAliases(suffix, containers, containerAliasGroups) +} + +// matchesCodec checks if a codec matches any of the codec names, +// including common aliases. +func matchesCodec(codec string, codecs []string) bool { + return matchesWithAliases(codec, codecs, codecAliasGroups) +} + +func containsIgnoreCase(slice []string, s string) bool { + return slices.ContainsFunc(slice, func(item string) bool { + return strings.EqualFold(item, s) + }) +} diff --git a/core/transcode/codec.go b/core/transcode/codec.go new file mode 100644 index 000000000..aa276d43f --- /dev/null +++ b/core/transcode/codec.go @@ -0,0 +1,77 @@ +package transcode + +import "strings" + +// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal +// codec names used throughout Navidrome (matching inferCodecFromSuffix output). +// Most ffprobe names match directly; this handles the exceptions. +func normalizeProbeCodec(codec string) string { + c := strings.ToLower(codec) + // DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf + if strings.HasPrefix(c, "dsd") { + return "dsd" + } + // PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc. + if strings.HasPrefix(c, "pcm_") { + return "pcm" + } + return c +} + +// isLosslessFormat returns true if the format is a known lossless audio codec/format. +// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM) +// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal. +// +// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats +// ffmpeg can produce as output (a smaller set). +func isLosslessFormat(format string) bool { + switch strings.ToLower(format) { + case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm": + return true + } + return false +} + +// normalizeSourceSampleRate adjusts the source sample rate for codecs that store +// it differently than PCM. Currently handles DSD (÷8): +// DSD64=2822400→352800, DSD128=5644800→705600, etc. +// For other codecs, returns the rate unchanged. +func normalizeSourceSampleRate(sampleRate int, codec string) int { + if strings.EqualFold(codec, "dsd") && sampleRate > 0 { + return sampleRate / 8 + } + return sampleRate +} + +// normalizeSourceBitDepth adjusts the source bit depth for codecs that use +// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is +// what ffmpeg produces). For other codecs, returns the depth unchanged. +func normalizeSourceBitDepth(bitDepth int, codec string) int { + if strings.EqualFold(codec, "dsd") && bitDepth == 1 { + return 24 + } + return bitDepth +} + +// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs +// that always resample regardless of input (e.g., Opus always outputs 48000Hz). +// Returns 0 if the codec has no fixed output rate. +func codecFixedOutputSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "opus": + return 48000 + } + return 0 +} + +// codecMaxSampleRate returns the hard maximum output sample rate for a codec. +// Returns 0 if the codec has no hard limit. +func codecMaxSampleRate(codec string) int { + switch strings.ToLower(codec) { + case "mp3": + return 48000 + case "aac": + return 96000 + } + return 0 +} diff --git a/core/transcode/codec_test.go b/core/transcode/codec_test.go new file mode 100644 index 000000000..6d3fbd78c --- /dev/null +++ b/core/transcode/codec_test.go @@ -0,0 +1,69 @@ +package transcode + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Codec", func() { + Describe("isLosslessFormat", func() { + It("returns true for known lossless codecs", func() { + Expect(isLosslessFormat("flac")).To(BeTrue()) + Expect(isLosslessFormat("alac")).To(BeTrue()) + Expect(isLosslessFormat("pcm")).To(BeTrue()) + Expect(isLosslessFormat("wav")).To(BeTrue()) + Expect(isLosslessFormat("dsd")).To(BeTrue()) + Expect(isLosslessFormat("ape")).To(BeTrue()) + Expect(isLosslessFormat("wv")).To(BeTrue()) + Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack + }) + + It("returns false for lossy codecs", func() { + Expect(isLosslessFormat("mp3")).To(BeFalse()) + Expect(isLosslessFormat("aac")).To(BeFalse()) + Expect(isLosslessFormat("opus")).To(BeFalse()) + Expect(isLosslessFormat("vorbis")).To(BeFalse()) + }) + + It("returns false for unknown codecs", func() { + Expect(isLosslessFormat("unknown_codec")).To(BeFalse()) + }) + + It("is case-insensitive", func() { + Expect(isLosslessFormat("FLAC")).To(BeTrue()) + Expect(isLosslessFormat("Alac")).To(BeTrue()) + }) + }) + + Describe("normalizeProbeCodec", func() { + It("passes through common codec names unchanged", func() { + Expect(normalizeProbeCodec("mp3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("aac")).To(Equal("aac")) + Expect(normalizeProbeCodec("flac")).To(Equal("flac")) + Expect(normalizeProbeCodec("opus")).To(Equal("opus")) + Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis")) + Expect(normalizeProbeCodec("alac")).To(Equal("alac")) + Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2")) + }) + + It("normalizes DSD variants to dsd", func() { + Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd")) + Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd")) + }) + + It("normalizes PCM variants to pcm", func() { + Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm")) + Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm")) + }) + + It("lowercases input", func() { + Expect(normalizeProbeCodec("MP3")).To(Equal("mp3")) + Expect(normalizeProbeCodec("AAC")).To(Equal("aac")) + Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd")) + }) + }) +}) diff --git a/core/transcode/decider.go b/core/transcode/decider.go new file mode 100644 index 000000000..55b451fd6 --- /dev/null +++ b/core/transcode/decider.go @@ -0,0 +1,425 @@ +package transcode + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +const defaultBitrate = 256 // kbps + +// Decider is the core service interface for making transcoding decisions +type Decider interface { + MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) + CreateTranscodeParams(decision *Decision) (string, error) + ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) + ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest +} + +func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider { + return &deciderService{ + ds: ds, + ff: ff, + } +} + +type deciderService struct { + ds model.DataStore + ff ffmpeg.FFmpeg +} + +func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error) { + decision := &Decision{ + MediaID: mf.ID, + SourceUpdatedAt: mf.UpdatedAt, + } + + var probe *ffmpeg.AudioProbeResult + if !opts.SkipProbe { + var err error + probe, err = s.ensureProbed(ctx, mf) + if err != nil { + return nil, err + } + } + + // Build source stream details (uses probe data if available) + decision.SourceStream = buildSourceStream(mf, probe) + src := &decision.SourceStream + + // Check for server-side player transcoding override + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } + + log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, + "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, + "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) + + // Check global bitrate constraint first. + if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { + log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play", + "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) + decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported") + // Skip direct play profiles entirely — global constraint fails + } else { + // Try direct play profiles, collecting reasons for each failure + for _, profile := range clientInfo.DirectPlayProfiles { + if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" { + decision.CanDirectPlay = true + decision.TranscodeReasons = nil // Clear any previously collected reasons + break + } else { + decision.TranscodeReasons = append(decision.TranscodeReasons, reason) + } + } + } + + // If direct play is possible, we're done + if decision.CanDirectPlay { + log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec) + return decision, nil + } + + // Try transcoding profiles (in order of preference) + for _, profile := range clientInfo.TranscodingProfiles { + if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil { + decision.CanTranscode = true + decision.TargetFormat = transcodeFormat + decision.TargetBitrate = ts.Bitrate + decision.TargetChannels = ts.Channels + decision.TargetSampleRate = ts.SampleRate + decision.TargetBitDepth = ts.BitDepth + decision.TranscodeStream = ts + break + } + } + + if decision.CanTranscode { + log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID, + "targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate, + "targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons) + } + + // If neither direct play nor transcode is possible + if !decision.CanDirectPlay && !decision.CanTranscode { + decision.ErrorReason = "no compatible playback profile found" + log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID, + "container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons) + } + + return decision, nil +} + +func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) StreamDetails { + sd := StreamDetails{ + Container: mf.Suffix, + Duration: mf.Duration, + Size: mf.Size, + } + + // Use pre-parsed probe result, or fall back to parsing stored probe data + if probe == nil { + probe, _ = parseProbeData(mf.ProbeData) + } + + // Use probe data if available for authoritative values + if probe != nil { + sd.Codec = normalizeProbeCodec(probe.Codec) + sd.Profile = probe.Profile + sd.Bitrate = probe.BitRate + sd.SampleRate = probe.SampleRate + sd.BitDepth = probe.BitDepth + sd.Channels = probe.Channels + } else { + sd.Codec = mf.AudioCodec() + sd.Bitrate = mf.BitRate + sd.SampleRate = mf.SampleRate + sd.BitDepth = mf.BitDepth + sd.Channels = mf.Channels + } + sd.IsLossless = isLosslessFormat(sd.Codec) + + return sd +} + +// applyServerOverride replaces the client-provided profiles with synthetic ones +// matching the server-forced transcoding format and bitrate. +func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo { + maxBitRate := trc.DefaultBitRate + if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { + maxBitRate = player.MaxBitRate + } + + log.Debug(ctx, "Applying server-side transcoding override", + "targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate, + "client", original.Name) + + return &ClientInfo{ + Name: original.Name, + Platform: original.Platform, + MaxAudioBitrate: maxBitRate, + MaxTranscodingAudioBitrate: maxBitRate, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP}, + }, + } +} + +func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) { + if data == "" { + return nil, nil + } + var result ffmpeg.AudioProbeResult + if err := json.Unmarshal([]byte(data), &result); err != nil { + return nil, err + } + return &result, nil +} + +// 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 { + // Check protocol (only http for now) + if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) { + return "protocol not supported" + } + + // Check container + if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) { + return "container not supported" + } + + // Check codec + if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) { + return "audio codec not supported" + } + + // Check channels + if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { + return "audio channels not supported" + } + + // Check codec-specific limitations + for _, codecProfile := range clientInfo.CodecProfiles { + if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) { + if reason := checkLimitations(src, codecProfile.Limitations); reason != "" { + return reason + } + } + } + + return "" +} + +// computeTranscodedStream attempts to build a valid transcoded stream for the given profile. +// 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) { + // 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) + return nil, "" + } + + responseContainer, targetFormat := resolveTargetFormat(profile) + if targetFormat == "" { + return nil, "" + } + + // Verify we have a transcoding command available (DB custom or built-in default) + if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" { + log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat) + return nil, "" + } + + targetIsLossless := isLosslessFormat(targetFormat) + + // Reject lossy to lossless conversion + if !src.IsLossless && targetIsLossless { + log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat) + return nil, "" + } + + ts := &StreamDetails{ + Container: responseContainer, + Codec: strings.ToLower(profile.AudioCodec), + SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec), + Channels: src.Channels, + BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec), + IsLossless: targetIsLossless, + } + if ts.Codec == "" { + ts.Codec = targetFormat + } + + // Apply codec-intrinsic sample rate adjustments before codec profile limitations + if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 { + ts.SampleRate = fixedRate + } + if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate { + ts.SampleRate = maxRate + } + + // Determine target bitrate (all in kbps) + if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok { + return nil, "" + } + + // Apply MaxAudioChannels from the transcoding profile + if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels { + ts.Channels = profile.MaxAudioChannels + } + + // Apply codec profile limitations to the TARGET codec + if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok { + return nil, "" + } + + return ts, targetFormat +} + +// LookupTranscodeCommand returns the ffmpeg command for the given format. +// It checks the DB first (for user-customized commands), then falls back to +// the built-in default command. Returns "" if the format is unknown. +func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string { + t, err := ds.Transcoding(ctx).FindByFormat(format) + if err == nil && t.Command != "" { + return t.Command + } + // Fall back to built-in defaults + for _, dt := range consts.DefaultTranscodings { + if dt.TargetFormat == format { + return dt.Command + } + } + return "" +} + +// resolveTargetFormat determines the response container and internal target format +// from the profile's Container and AudioCodec fields. When an AudioCodec is specified +// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac"). +func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) { + responseContainer = strings.ToLower(profile.Container) + targetFormat = responseContainer + + // Prefer the audioCodec as targetFormat when provided (handles container-to-codec + // mapping like "mp4" → "aac", "ogg" → "opus"). + if profile.AudioCodec != "" { + targetFormat = strings.ToLower(profile.AudioCodec) + } + + // If neither container nor audioCodec is set, we can't resolve a format. + if targetFormat == "" { + return "", "" + } + + // When no container was specified, use the targetFormat as container too. + if responseContainer == "" { + responseContainer = targetFormat + } + + return responseContainer, targetFormat +} + +// 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 { + if src.IsLossless { + if !targetIsLossless { + if clientInfo.MaxTranscodingAudioBitrate > 0 { + ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate + } else { + ts.Bitrate = defaultBitrate + } + } else { + if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate { + log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit", + "targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate) + return false + } + } + } else { + ts.Bitrate = src.Bitrate + } + + // Apply maxAudioBitrate as final cap + if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate { + ts.Bitrate = clientInfo.MaxAudioBitrate + } + return true +} + +// 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 { + targetCodec := ts.Codec + for _, codecProfile := range clientInfo.CodecProfiles { + if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) { + continue + } + if !matchesCodec(targetCodec, []string{codecProfile.Name}) { + continue + } + for _, lim := range codecProfile.Limitations { + result := applyLimitation(sourceBitrate, &lim, ts) + if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted { + log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target", + "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name) + return false + } + if result == adjustCannotFit { + log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied", + "targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name, + "comparison", lim.Comparison, "values", lim.Values) + return false + } + } + } + return true +} + +// ensureProbed runs ffprobe if probe data is missing, persists it, and returns +// the parsed result. Returns (nil, nil) when probing is skipped or data already exists +// (in which case the caller should parse mf.ProbeData). +func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) { + if mf.ProbeData != "" { + return nil, nil + } + if !conf.Server.DevEnableMediaFileProbe { + return nil, nil + } + + result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath()) + if err != nil { + return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err) + } + + data, err := json.Marshal(result) + if err != nil { + return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err) + } + mf.ProbeData = string(data) + + if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil { + log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err) + // Don't fail the decision — we have the data in memory + } + + log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec, + "profile", result.Profile, "bitRate", result.BitRate, + "sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels) + return result, nil +} diff --git a/core/transcode/decider_test.go b/core/transcode/decider_test.go new file mode 100644 index 000000000..e5ad2f621 --- /dev/null +++ b/core/transcode/decider_test.go @@ -0,0 +1,1087 @@ +package transcode + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// withProbe pre-populates ProbeData on a MediaFile from its own fields, +// so ensureProbed short-circuits and tests don't need mock ffprobe results. +func withProbe(mf *model.MediaFile) *model.MediaFile { + probe := ffmpeg.AudioProbeResult{ + Codec: mf.AudioCodec(), + BitRate: mf.BitRate, + SampleRate: mf.SampleRate, + BitDepth: mf.BitDepth, + Channels: mf.Channels, + } + data, _ := json.Marshal(probe) + mf.ProbeData = string(data) + return mf +} + +var _ = Describe("Decider", func() { + var ( + ds *tests.MockDataStore + ff *tests.MockFFmpeg + svc Decider + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + ff = tests.NewMockFFmpeg("") + auth.Init(ds) + svc = NewDecider(ds, ff) + }) + + Describe("MakeDecision", func() { + Context("Direct Play", func() { + It("allows direct play when profile matches", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + Expect(decision.CanTranscode).To(BeFalse()) + Expect(decision.TranscodeReasons).To(BeEmpty()) + }) + + It("rejects direct play when container doesn't match", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + }) + + It("rejects direct play when codec doesn't match", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported")) + }) + + It("rejects direct play when channels exceed limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported")) + }) + + It("handles container aliases (aac -> m4a)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles container aliases (mp4 -> m4a)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles codec aliases (adts -> aac)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("allows when protocol list is empty (any protocol)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, AudioCodecs: []string{"flac"}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("allows when both container and codec lists are empty (wildcard)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{}, AudioCodecs: []string{}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + }) + + Context("MaxAudioBitrate constraint", func() { + It("revokes direct play when bitrate exceeds maxAudioBitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2}) + ci := &ClientInfo{ + MaxAudioBitrate: 500, // kbps + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) + }) + }) + + Context("Transcoding", func() { + It("selects transcoding when direct play isn't possible", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, // kbps + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(256)) // kbps + Expect(decision.TranscodeReasons).To(ContainElement("container not supported")) + }) + + It("rejects lossy to lossless transcoding", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("uses default bitrate when client doesn't specify", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) // 256 kbps + }) + + It("preserves lossy bitrate when under max", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, // kbps + TranscodingProfiles: []Profile{ + {Container: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps + }) + + It("rejects format with no transcoding command available", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "wav", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("applies maxAudioBitrate as final cap on transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}) + ci := &ClientInfo{ + MaxAudioBitrate: 96, // kbps + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate + }) + + It("selects first valid transcoding profile in order", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP, MaxAudioChannels: 2}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("opus")) + }) + }) + + Context("Lossless to lossless transcoding", func() { + It("allows lossless to lossless when samplerate needs downsampling", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}) + ci := &ClientInfo{ + MaxAudioBitrate: 1000, + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + }) + + It("sets IsLossless=true on transcoded stream when target is lossless", func() { + // Transcoding to mp3 (lossy) should result in IsLossless=false. + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy + }) + }) + + Context("No compatible profile", 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{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeFalse()) + Expect(decision.ErrorReason).To(Equal("no compatible playback profile found")) + }) + }) + + Context("Codec limitations on direct play", func() { + It("rejects direct play when codec limitation fails (required)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported")) + }) + + It("allows direct play when optional limitation fails", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("handles Equals comparison with multiple values", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("rejects when Equals comparison doesn't match any value", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + }) + + It("rejects direct play when audioProfile limitation fails (required)", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "aac", + Limitations: []Limitation{ + {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true}, + }, + }, + }, + } + // Source profile is empty (not yet populated from scanner), so Equals("LC") fails + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported")) + }) + + It("allows direct play when audioProfile limitation is optional", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "aac", + Limitations: []Limitation{ + {Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + It("rejects direct play due to samplerate limitation", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported")) + }) + }) + + Context("Codec limitations on transcoded output", func() { + It("applies bitrate limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + MaxAudioBitrate: 96, // force transcode + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Bitrate).To(Equal(96)) + }) + + It("applies channel limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Channels).To(Equal(2)) + }) + + It("applies samplerate limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + + It("applies bitdepth limitation to transcoded stream", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + Expect(decision.TargetBitDepth).To(Equal(16)) + }) + + It("preserves source bit depth when no limitation applies", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 24}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "mp3", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + }) + + Context("DSD sample rate conversion", func() { + It("converts DSD sample rate to PCM-equivalent in decision", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + // DSD64 2822400 / 8 = 352800, capped by MP3 max of 48000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + // DSD 1-bit → 24-bit PCM + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("converts DSD sample rate for FLAC target without codec limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("flac")) + // DSD64 2822400 / 8 = 352800, FLAC has no hard max + Expect(decision.TranscodeStream.SampleRate).To(Equal(352800)) + Expect(decision.TargetSampleRate).To(Equal(352800)) + // DSD 1-bit → 24-bit PCM + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("applies codec profile limit to DSD-converted FLAC sample rate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD64 2822400 / 8 = 352800, capped by codec profile limit of 48000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + // DSD 1-bit → 24-bit PCM + Expect(decision.TranscodeStream.BitDepth).To(Equal(24)) + Expect(decision.TargetBitDepth).To(Equal(24)) + }) + + It("applies audioBitdepth limitation to DSD-converted bit depth", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "flac", AudioCodec: "flac", Protocol: ProtocolHTTP}, + }, + CodecProfiles: []CodecProfile{ + { + Type: CodecProfileTypeAudio, + Name: "flac", + Limitations: []Limitation{ + {Name: LimitationAudioBitdepth, Comparison: ComparisonLessThanEqual, Values: []string{"16"}, Required: true}, + }, + }, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD 1-bit → 24-bit PCM, then capped by codec profile limit to 16-bit + Expect(decision.TranscodeStream.BitDepth).To(Equal(16)) + Expect(decision.TargetBitDepth).To(Equal(16)) + }) + }) + + Context("Probe-based lossless detection", func() { + It("uses probe codec name for lossless detection", func() { + // WavPack files: ffprobe reports codec as "wavpack", suffix is ".wv" + mf := &model.MediaFile{ID: "1", Suffix: "wv", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16} + probe := ffmpeg.AudioProbeResult{ + Codec: "wavpack", BitRate: 1000, SampleRate: 44100, BitDepth: 16, Channels: 2, + } + data, _ := json.Marshal(probe) + mf.ProbeData = string(data) + + ci := &ClientInfo{ + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + MaxTranscodingAudioBitrate: 256, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.SourceStream.IsLossless).To(BeTrue()) + Expect(decision.SourceStream.Codec).To(Equal("wavpack")) + // Lossless source transcoding to MP3 should use MaxTranscodingAudioBitrate + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.Bitrate).To(Equal(256)) + }) + + It("detects lossy from probe codec name", func() { + mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2, SampleRate: 48000} + probe := ffmpeg.AudioProbeResult{ + Codec: "vorbis", BitRate: 192, SampleRate: 48000, BitDepth: 0, Channels: 2, + } + data, _ := json.Marshal(probe) + mf.ProbeData = string(data) + + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"ogg"}, AudioCodecs: []string{"vorbis"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.SourceStream.IsLossless).To(BeFalse()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + }) + + Context("Opus fixed sample rate", func() { + It("sets Opus output to 48000Hz regardless of input", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 128, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("opus")) + // Opus always outputs 48000Hz + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + Expect(decision.TargetSampleRate).To(Equal(48000)) + }) + + It("sets Opus output to 48000Hz even for 96kHz input", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 128, + TranscodingProfiles: []Profile{ + {Container: "opus", AudioCodec: "opus", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + }) + + Context("Container vs format separation", func() { + It("preserves mp4 container when falling back to aac format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, + TranscodingProfiles: []Profile{ + {Container: "mp4", AudioCodec: "aac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // TargetFormat is the internal format used for transcoding ("aac") + Expect(decision.TargetFormat).To(Equal("aac")) + // Container in the response preserves what the client asked ("mp4") + Expect(decision.TranscodeStream.Container).To(Equal("mp4")) + Expect(decision.TranscodeStream.Codec).To(Equal("aac")) + }) + + It("uses container as format when container matches transcoding config", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 256, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TranscodeStream.Container).To(Equal("mp3")) + }) + }) + + Context("MP3 max sample rate", func() { + It("caps sample rate at 48000 for MP3", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2, SampleRate: 96000, BitDepth: 24}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(48000)) + }) + + It("preserves sample rate at 44100 for MP3", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TranscodeStream.SampleRate).To(Equal(44100)) + }) + }) + + Context("AAC max sample rate", func() { + It("caps sample rate at 96000 for AAC", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 2822400, BitDepth: 1}) + ci := &ClientInfo{ + MaxTranscodingAudioBitrate: 320, + TranscodingProfiles: []Profile{ + {Container: "aac", AudioCodec: "aac", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + // DSD64 2822400 / 8 = 352800, capped by AAC max of 96000 + Expect(decision.TranscodeStream.SampleRate).To(Equal(96000)) + }) + }) + + Context("Typed transcode reasons from multiple profiles", func() { + It("collects reasons from each failed direct play profile", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, + {Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{ProtocolHTTP}}, + }, + TranscodingProfiles: []Profile{ + {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.TranscodeReasons).To(HaveLen(3)) + Expect(decision.TranscodeReasons[0]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[1]).To(Equal("container not supported")) + Expect(decision.TranscodeReasons[2]).To(Equal("container not supported")) + }) + }) + + Context("Source stream details", func() { + It("populates source stream correctly with kbps bitrate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000}) + ci := &ClientInfo{ + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.SourceStream.Container).To(Equal("flac")) + Expect(decision.SourceStream.Codec).To(Equal("flac")) + Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps + Expect(decision.SourceStream.SampleRate).To(Equal(96000)) + Expect(decision.SourceStream.BitDepth).To(Equal(24)) + Expect(decision.SourceStream.Channels).To(Equal(2)) + }) + }) + + Context("Server-side player transcoding override", func() { + It("forces transcoding when override targets a different format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + // Set server override in context + 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{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("allows direct play when source matches forced format and bitrate is within cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + Expect(decision.CanTranscode).To(BeFalse()) + }) + + It("transcodes when source bitrate exceeds the forced cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + + decision, err := svc.MakeDecision(overrideCtx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeFalse()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(192)) + }) + + It("uses player MaxBitRate over transcoding DefaultBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + 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{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + Expect(decision.TargetBitrate).To(Equal(320)) + }) + + It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + } + 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{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanTranscode).To(BeTrue()) + Expect(decision.TargetFormat).To(Equal("mp3")) + // With no cap, lossless→lossy uses defaultBitrate (256) + Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) + }) + + It("does not apply override when no transcoding is in context", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + ci := &ClientInfo{ + Name: "TestClient", + DirectPlayProfiles: []DirectPlayProfile{ + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, + }, + } + // No override in context — client profiles used as-is + decision, err := svc.MakeDecision(ctx, mf, ci, DecisionOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(decision.CanDirectPlay).To(BeTrue()) + }) + + }) + }) + + Describe("ensureProbed", func() { + var mockMFRepo *tests.MockMediaFileRepo + + BeforeEach(func() { + mockMFRepo = tests.CreateMockMediaFileRepo() + ds.MockedMediaFile = mockMFRepo + }) + + It("calls ffprobe and populates ProbeData when empty", func() { + mf := &model.MediaFile{ID: "probe-1", Suffix: "mp3", BitRate: 320, Channels: 2} + mockMFRepo.SetData(model.MediaFiles{*mf}) + + ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{ + Codec: "mp3", BitRate: 320, SampleRate: 44100, Channels: 2, + } + + svc := NewDecider(ds, ff).(*deciderService) + probe, err := svc.ensureProbed(ctx, mf) + Expect(err).ToNot(HaveOccurred()) + Expect(mf.ProbeData).ToNot(BeEmpty()) + Expect(probe).ToNot(BeNil()) + Expect(probe.Codec).To(Equal("mp3")) + Expect(probe.BitRate).To(Equal(320)) + Expect(probe.SampleRate).To(Equal(44100)) + Expect(probe.Channels).To(Equal(2)) + + // Verify persisted to DB + stored := mockMFRepo.Data["probe-1"] + Expect(stored.ProbeData).To(Equal(mf.ProbeData)) + }) + + It("skips ffprobe when ProbeData is already set", func() { + mf := withProbe(&model.MediaFile{ID: "probe-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}) + + // Set error on mock — if ffprobe were called, this would fail + ff.Error = fmt.Errorf("should not be called") + + svc := NewDecider(ds, ff).(*deciderService) + probe, err := svc.ensureProbed(ctx, mf) + Expect(err).ToNot(HaveOccurred()) + Expect(probe).To(BeNil()) + }) + + It("returns error when ffprobe fails", func() { + mf := &model.MediaFile{ID: "probe-3", Suffix: "mp3"} + ff.Error = fmt.Errorf("ffprobe not found") + + svc := NewDecider(ds, ff).(*deciderService) + _, err := svc.ensureProbed(ctx, mf) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("probing media file")) + Expect(mf.ProbeData).To(BeEmpty()) + }) + + It("skips ffprobe when DevEnableMediaFileProbe is false", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevEnableMediaFileProbe = false + + mf := &model.MediaFile{ID: "probe-4", Suffix: "mp3"} + // Set a result — if ffprobe were called, ProbeData would be populated + ff.ProbeAudioResult = &ffmpeg.AudioProbeResult{Codec: "mp3"} + + svc := NewDecider(ds, ff).(*deciderService) + probe, err := svc.ensureProbed(ctx, mf) + Expect(err).ToNot(HaveOccurred()) + Expect(probe).To(BeNil()) + Expect(mf.ProbeData).To(BeEmpty()) + }) + }) + +}) diff --git a/core/transcode/legacy_client.go b/core/transcode/legacy_client.go new file mode 100644 index 000000000..83190ec92 --- /dev/null +++ b/core/transcode/legacy_client.go @@ -0,0 +1,85 @@ +package transcode + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// buildLegacyClientInfo translates legacy Subsonic stream/download parameters +// into a ClientInfo for use with MakeDecision. +// It does NOT read request.TranscodingFrom(ctx) — that is handled by +// MakeDecision's applyServerOverride. +func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { + ci := &ClientInfo{Name: "legacy"} + + // Determine target format for transcoding + var targetFormat string + switch { + case reqFormat != "": + targetFormat = reqFormat + case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "": + targetFormat = conf.Server.DefaultDownsamplingFormat + } + + if targetFormat != "" { + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}}, + } + ci.TranscodingProfiles = []Profile{ + {Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP}, + } + if reqBitRate > 0 { + ci.MaxAudioBitrate = reqBitRate + ci.MaxTranscodingAudioBitrate = reqBitRate + } + } else { + // No transcoding requested — direct play everything + ci.DirectPlayProfiles = []DirectPlayProfile{ + {Protocols: []string{ProtocolHTTP}}, + } + } + + return ci +} + +// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters +// into a fully specified StreamRequest. +func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest { + var req StreamRequest + req.ID = mf.ID + req.Offset = offset + + if reqFormat == "raw" { + req.Format = "raw" + return req + } + + clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{SkipProbe: true}) + if err != nil { + log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) + req.Format = "raw" + return req + } + + if decision.CanDirectPlay { + req.Format = "raw" + return req + } + + if decision.CanTranscode { + req.Format = decision.TargetFormat + req.BitRate = decision.TargetBitrate + req.SampleRate = decision.TargetSampleRate + req.BitDepth = decision.TargetBitDepth + req.Channels = decision.TargetChannels + return req + } + + // No compatible profile — fallback to raw + req.Format = "raw" + return req +} diff --git a/core/transcode/legacy_client_test.go b/core/transcode/legacy_client_test.go new file mode 100644 index 000000000..9628764f4 --- /dev/null +++ b/core/transcode/legacy_client_test.go @@ -0,0 +1,84 @@ +package transcode + +import ( + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("buildLegacyClientInfo", func() { + var mf *model.MediaFile + + BeforeEach(func() { + mf = &model.MediaFile{Suffix: "flac", BitRate: 960} + }) + + It("sets transcoding profile for explicit format without bitrate", func() { + ci := buildLegacyClientInfo(mf, "mp3", 0) + + Expect(ci.Name).To(Equal("legacy")) + 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(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{ProtocolHTTP})) + }) + + It("sets transcoding profile and bitrate for explicit format with bitrate", func() { + ci := buildLegacyClientInfo(mf, "mp3", 192) + + Expect(ci.TranscodingProfiles).To(HaveLen(1)) + Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3")) + Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3")) + Expect(ci.MaxAudioBitrate).To(Equal(192)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192)) + Expect(ci.DirectPlayProfiles).To(HaveLen(1)) + Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"})) + }) + + It("returns direct play profile when no format and no bitrate", func() { + ci := buildLegacyClientInfo(mf, "", 0) + + 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{ProtocolHTTP})) + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.MaxAudioBitrate).To(BeZero()) + }) + + It("uses default downsampling format for bitrate-only downsampling", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + ci := buildLegacyClientInfo(mf, "", 128) + + 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(ProtocolHTTP)) + Expect(ci.MaxAudioBitrate).To(Equal(128)) + Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128)) + 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()})) + }) + + It("returns direct play when bitrate >= source bitrate", func() { + ci := buildLegacyClientInfo(mf, "", 960) + + 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{ProtocolHTTP})) + Expect(ci.TranscodingProfiles).To(BeEmpty()) + Expect(ci.MaxAudioBitrate).To(BeZero()) + }) +}) diff --git a/core/transcode/limitations.go b/core/transcode/limitations.go new file mode 100644 index 000000000..aefc87d97 --- /dev/null +++ b/core/transcode/limitations.go @@ -0,0 +1,171 @@ +package transcode + +import ( + "strconv" + "strings" +) + +// adjustResult represents the outcome of applying a limitation to a transcoded stream value +type adjustResult int + +const ( + adjustNone adjustResult = iota // Value already satisfies the limitation + adjustAdjusted // Value was changed to fit the limitation + adjustCannotFit // Cannot satisfy the limitation (reject this profile) +) + +// 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 { + for _, lim := range limitations { + var ok bool + var reason string + + switch lim.Name { + case LimitationAudioChannels: + ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values) + reason = "audio channels not supported" + case LimitationAudioSamplerate: + ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values) + reason = "audio samplerate not supported" + case LimitationAudioBitrate: + ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values) + reason = "audio bitrate not supported" + case LimitationAudioBitdepth: + ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values) + reason = "audio bitdepth not supported" + case LimitationAudioProfile: + ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values) + reason = "audio profile not supported" + default: + continue + } + + if !ok && lim.Required { + return reason + } + } + return "" +} + +// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation. +// Returns the adjustment result. +func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult { + switch lim.Name { + case LimitationAudioChannels: + return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v }) + case LimitationAudioBitrate: + current := ts.Bitrate + if current == 0 { + current = sourceBitrate + } + return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v }) + case LimitationAudioSamplerate: + return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v }) + case LimitationAudioBitdepth: + if ts.BitDepth > 0 { + return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v }) + } + case LimitationAudioProfile: + // TODO: implement when audio profile data is available + } + return adjustNone +} + +// applyIntLimitation applies a limitation comparison to a value. +// If the value needs adjusting, calls the setter and returns the result. +func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult { + if len(values) == 0 { + return adjustNone + } + + switch comparison { + case ComparisonLessThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current <= limit { + return adjustNone + } + setter(limit) + return adjustAdjusted + case ComparisonGreaterThanEqual: + limit, ok := parseInt(values[0]) + if !ok { + return adjustNone + } + if current >= limit { + return adjustNone + } + // Cannot upscale + return adjustCannotFit + case ComparisonEquals: + // Check if current value matches any allowed value + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustNone + } + } + // Find the closest allowed value below current (don't upscale) + var closest int + found := false + for _, v := range values { + if limit, ok := parseInt(v); ok && limit < current { + if !found || limit > closest { + closest = limit + found = true + } + } + } + if found { + setter(closest) + return adjustAdjusted + } + return adjustCannotFit + case ComparisonNotEquals: + for _, v := range values { + if limit, ok := parseInt(v); ok && current == limit { + return adjustCannotFit + } + } + return adjustNone + } + + return adjustNone +} + +func checkIntLimitation(value int, comparison string, values []string) bool { + return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone +} + +// checkStringLimitation checks a string value against a limitation. +// Only Equals and NotEquals comparisons are meaningful for strings. +// LessThanEqual/GreaterThanEqual are not applicable and always pass. +func checkStringLimitation(value string, comparison string, values []string) bool { + switch comparison { + case ComparisonEquals: + for _, v := range values { + if strings.EqualFold(value, v) { + return true + } + } + return false + case ComparisonNotEquals: + for _, v := range values { + if strings.EqualFold(value, v) { + return false + } + } + return true + } + return true +} + +func parseInt(s string) (int, bool) { + v, err := strconv.Atoi(s) + if err != nil || v < 0 { + return 0, false + } + return v, true +} diff --git a/core/media_streamer.go b/core/transcode/media_streamer.go similarity index 56% rename from core/media_streamer.go rename to core/transcode/media_streamer.go index c741ed476..88fb61e2d 100644 --- a/core/media_streamer.go +++ b/core/transcode/media_streamer.go @@ -1,4 +1,4 @@ -package core +package transcode import ( "context" @@ -6,6 +6,7 @@ import ( "io" "mime" "os" + "strings" "sync" "time" @@ -19,8 +20,8 @@ import ( ) type MediaStreamer interface { - NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, offset int) (*Stream, error) - DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) + NewStream(ctx context.Context, req StreamRequest) (*Stream, error) + DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) } type TranscodingCache cache.FileCache @@ -36,44 +37,53 @@ type mediaStreamer struct { } type streamJob struct { - ms *mediaStreamer - mf *model.MediaFile - filePath string - format string - bitRate int - offset int + ms *mediaStreamer + mf *model.MediaFile + filePath string + format string + bitRate int + sampleRate int + bitDepth int + channels int + offset int } func (j *streamJob) Key() string { - return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset) + 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, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { - mf, err := ms.ds.MediaFile(ctx).Get(id) +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, reqFormat, reqBitRate, reqOffset) + return ms.DoStream(ctx, mf, req) } -func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { +func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*Stream, error) { var format string var bitRate int var cached bool defer func() { log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached, - "bitRate", bitRate, "user", userName(ctx), "transcoding", format != "raw", + "bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels, + "user", userName(ctx), "transcoding", format != "raw", "originalFormat", mf.Suffix, "originalBitRate", mf.BitRate) }() - format, bitRate = selectTranscodingOptions(ctx, ms.ds, mf, reqFormat, reqBitRate) + format = req.Format + bitRate = req.BitRate + if format == "" || format == "raw" { + format = "raw" + bitRate = 0 + } s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate} filePath := mf.AbsolutePath() if format == "raw" { log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath, - "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "selectedBitrate", bitRate, "selectedFormat", format) f, err := os.Open(filePath) @@ -87,12 +97,15 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF } job := &streamJob{ - ms: ms, - mf: mf, - filePath: filePath, - format: format, - bitRate: bitRate, - offset: reqOffset, + ms: ms, + mf: mf, + filePath: filePath, + format: format, + bitRate: bitRate, + sampleRate: req.SampleRate, + bitDepth: req.BitDepth, + channels: req.Channels, + offset: req.Offset, } r, err := ms.cache.Get(ctx, job) if err != nil { @@ -105,7 +118,7 @@ func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqF s.Seeker = r.Seeker log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath, - "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset, "originalBitrate", mf.BitRate, "originalFormat", mf.Suffix, "selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable()) @@ -130,56 +143,15 @@ func (s *Stream) EstimatedContentLength() int { return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) } -// TODO This function deserves some love (refactoring) -func selectTranscodingOptions(ctx context.Context, ds model.DataStore, mf *model.MediaFile, reqFormat string, reqBitRate int) (format string, bitRate int) { - format = "raw" - if reqFormat == "raw" { - return format, 0 +// NewTestStream creates a Stream for testing purposes. +func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream { + return &Stream{ + ctx: context.Background(), + mf: mf, + format: format, + bitRate: bitRate, + ReadCloser: io.NopCloser(strings.NewReader("")), } - if reqFormat == mf.Suffix && reqBitRate == 0 { - bitRate = mf.BitRate - return format, bitRate - } - trc, hasDefault := request.TranscodingFrom(ctx) - var cFormat string - var cBitRate int - if reqFormat != "" { - cFormat = reqFormat - } else { - if hasDefault { - cFormat = trc.TargetFormat - cBitRate = trc.DefaultBitRate - if p, ok := request.PlayerFrom(ctx); ok { - cBitRate = p.MaxBitRate - } - } else if reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "" { - // If no format is specified and no transcoding associated to the player, but a bitrate is specified, - // and there is no transcoding set for the player, we use the default downsampling format. - // But only if the requested bitRate is lower than the original bitRate. - log.Debug("Default Downsampling", "Using default downsampling format", conf.Server.DefaultDownsamplingFormat) - cFormat = conf.Server.DefaultDownsamplingFormat - } - } - if reqBitRate > 0 { - cBitRate = reqBitRate - } - if cBitRate == 0 && cFormat == "" { - return format, bitRate - } - t, err := ds.Transcoding(ctx).FindByFormat(cFormat) - if err == nil { - format = t.TargetFormat - if cBitRate != 0 { - bitRate = cBitRate - } else { - bitRate = t.DefaultBitRate - } - } - if format == mf.Suffix && bitRate >= mf.BitRate { - format = "raw" - bitRate = 0 - } - return format, bitRate } var ( @@ -199,9 +171,9 @@ func NewTranscodingCache() TranscodingCache { consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems, func(ctx context.Context, arg cache.Item) (io.Reader, error) { job := arg.(*streamJob) - t, err := job.ms.ds.Transcoding(ctx).FindByFormat(job.format) - if err != nil { - log.Error(ctx, "Error loading transcoding command", "format", job.format, err) + command := LookupTranscodeCommand(ctx, job.ms.ds, job.format) + if command == "" { + log.Error(ctx, "No transcoding command available", "format", job.format) return nil, os.ErrInvalid } @@ -217,7 +189,16 @@ func NewTranscodingCache() TranscodingCache { transcodingCtx = request.AddValues(context.Background(), ctx) } - out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset) + out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{ + Command: command, + Format: job.format, + FilePath: job.filePath, + BitRate: job.bitRate, + SampleRate: job.sampleRate, + BitDepth: job.bitDepth, + Channels: job.channels, + Offset: job.offset, + }) if err != nil { log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid @@ -225,3 +206,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 + } +} diff --git a/core/media_streamer_test.go b/core/transcode/media_streamer_test.go similarity index 69% rename from core/media_streamer_test.go rename to core/transcode/media_streamer_test.go index f5175495b..f49dcb8d8 100644 --- a/core/media_streamer_test.go +++ b/core/transcode/media_streamer_test.go @@ -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,34 +39,29 @@ var _ = Describe("MediaStreamer", func() { Context("NewStream", func() { It("returns a seekable stream if format is 'raw'", func() { - s, err := streamer.NewStream(ctx, "123", "raw", 0, 0) + 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 maxBitRate is 0", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 0, 0) - Expect(err).ToNot(HaveOccurred()) - Expect(s.Seekable()).To(BeTrue()) - }) - It("returns a seekable stream if maxBitRate is higher than file bitRate", func() { - s, err := streamer.NewStream(ctx, "123", "mp3", 320, 0) + It("returns a seekable stream if no format is specified (direct play)", func() { + 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, "123", "mp3", 64, 0) + 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, "123", "mp3", 32, 0) + 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, "123", "mp3", 32, 0) + s, err = streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) Expect(s.Seekable()).To(BeTrue()) }) diff --git a/core/transcode/token.go b/core/transcode/token.go new file mode 100644 index 000000000..e110320d0 --- /dev/null +++ b/core/transcode/token.go @@ -0,0 +1,155 @@ +package transcode + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/lestrrat-go/jwx/v3/jwt" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const tokenTTL = 12 * time.Hour + +// params contains the parameters extracted from a transcode token. +// TargetBitrate is in kilobits per second (kbps). +type params struct { + MediaID string + DirectPlay bool + TargetFormat string + TargetBitrate int + TargetChannels int + TargetSampleRate int + TargetBitDepth int + SourceUpdatedAt time.Time +} + +// toClaimsMap converts a Decision into a JWT claims map for token encoding. +// Only non-zero transcode fields are included. +func (d *Decision) toClaimsMap() map[string]any { + m := map[string]any{ + "mid": d.MediaID, + "ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(), + jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(), + } + if d.CanDirectPlay { + m["dp"] = true + } + if d.CanTranscode && d.TargetFormat != "" { + m["f"] = d.TargetFormat + if d.TargetBitrate != 0 { + m["b"] = d.TargetBitrate + } + if d.TargetChannels != 0 { + m["ch"] = d.TargetChannels + } + if d.TargetSampleRate != 0 { + m["sr"] = d.TargetSampleRate + } + if d.TargetBitDepth != 0 { + m["bd"] = d.TargetBitDepth + } + } + return m +} + +// paramsFromToken extracts and validates Params from a parsed JWT token. +// Returns an error if required claims (media ID, source timestamp) are missing. +func paramsFromToken(token jwt.Token) (*params, error) { + var p params + var mid string + if err := token.Get("mid", &mid); err == nil { + p.MediaID = mid + } + if p.MediaID == "" { + return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid) + } + + var dp bool + if err := token.Get("dp", &dp); err == nil { + p.DirectPlay = dp + } + + ua := getIntClaim(token, "ua") + if ua != 0 { + p.SourceUpdatedAt = time.Unix(int64(ua), 0) + } + if p.SourceUpdatedAt.IsZero() { + return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid) + } + + var f string + if err := token.Get("f", &f); err == nil { + p.TargetFormat = f + } + p.TargetBitrate = getIntClaim(token, "b") + p.TargetChannels = getIntClaim(token, "ch") + p.TargetSampleRate = getIntClaim(token, "sr") + p.TargetBitDepth = getIntClaim(token, "bd") + return &p, nil +} + +// getIntClaim extracts an int claim from a JWT token, handling the case where +// the value may be stored as int64 or float64 (common in JSON-based JWT libraries). +func getIntClaim(token jwt.Token, key string) int { + var v int + if err := token.Get(key, &v); err == nil { + return v + } + var v64 int64 + if err := token.Get(key, &v64); err == nil { + return int(v64) + } + var f float64 + if err := token.Get(key, &f); err == nil { + return int(f) + } + return 0 +} + +func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) { + return auth.EncodeToken(decision.toClaimsMap()) +} + +func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) { + token, err := auth.DecodeAndVerifyToken(tokenStr) + if err != nil { + return nil, err + } + return paramsFromToken(token) +} + +func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) { + p, err := s.parseTranscodeParams(token) + if err != nil { + return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err) + } + if p.MediaID != mediaID { + return StreamRequest{}, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mediaID) + } + mf, err := s.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return StreamRequest{}, nil, ErrMediaNotFound + } + return StreamRequest{}, nil, err + } + if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) { + log.Info(ctx, "Transcode token is stale", "mediaID", mediaID, + "tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt) + return StreamRequest{}, nil, ErrTokenStale + } + + req := StreamRequest{ID: mediaID, Offset: offset} + if !p.DirectPlay && p.TargetFormat != "" { + req.Format = p.TargetFormat + req.BitRate = p.TargetBitrate + req.SampleRate = p.TargetSampleRate + req.BitDepth = p.TargetBitDepth + req.Channels = p.TargetChannels + } + return req, mf, nil +} diff --git a/core/transcode/token_test.go b/core/transcode/token_test.go new file mode 100644 index 000000000..b9b74c8fc --- /dev/null +++ b/core/transcode/token_test.go @@ -0,0 +1,272 @@ +package transcode + +import ( + "context" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Token", func() { + var ( + ds *tests.MockDataStore + ff *tests.MockFFmpeg + svc Decider + ctx context.Context + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedTranscoding: &tests.MockTranscodingRepo{}, + } + ff = tests.NewMockFFmpeg("") + auth.Init(ds) + svc = NewDecider(ds, ff) + }) + + Describe("Token round-trip", func() { + var ( + sourceTime time.Time + impl *deciderService + ) + + BeforeEach(func() { + sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + impl = svc.(*deciderService) + }) + + It("creates and parses a direct play token", func() { + decision := &Decision{ + MediaID: "media-123", + CanDirectPlay: true, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + Expect(token).ToNot(BeEmpty()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-123")) + Expect(params.DirectPlay).To(BeTrue()) + Expect(params.TargetFormat).To(BeEmpty()) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) + }) + + It("creates and parses a transcode token with kbps bitrate", func() { + decision := &Decision{ + MediaID: "media-456", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, // kbps + TargetChannels: 2, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-456")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("mp3")) + Expect(params.TargetBitrate).To(Equal(256)) // kbps + Expect(params.TargetChannels).To(Equal(2)) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix())) + }) + + It("creates and parses a transcode token with sample rate", func() { + decision := &Decision{ + MediaID: "media-789", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetSampleRate: 48000, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-789")) + Expect(params.DirectPlay).To(BeFalse()) + Expect(params.TargetFormat).To(Equal("flac")) + Expect(params.TargetSampleRate).To(Equal(48000)) + Expect(params.TargetChannels).To(Equal(2)) + }) + + It("creates and parses a transcode token with bit depth", func() { + decision := &Decision{ + MediaID: "media-bd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "flac", + TargetBitrate: 0, + TargetChannels: 2, + TargetBitDepth: 24, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.MediaID).To(Equal("media-bd")) + Expect(params.TargetBitDepth).To(Equal(24)) + }) + + It("omits bit depth from token when 0", func() { + decision := &Decision{ + MediaID: "media-nobd", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetBitDepth: 0, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetBitDepth).To(Equal(0)) + }) + + It("omits sample rate from token when 0", func() { + decision := &Decision{ + MediaID: "media-100", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TargetSampleRate: 0, + SourceUpdatedAt: sourceTime, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.TargetSampleRate).To(Equal(0)) + }) + + It("truncates SourceUpdatedAt to seconds", func() { + timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC) + decision := &Decision{ + MediaID: "media-trunc", + CanDirectPlay: true, + SourceUpdatedAt: timeWithNanos, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + + params, err := impl.parseTranscodeParams(token) + Expect(err).ToNot(HaveOccurred()) + Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix())) + }) + + It("rejects an invalid token", func() { + _, err := impl.parseTranscodeParams("invalid-token") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ResolveRequestFromToken", func() { + var ( + mockMFRepo *tests.MockMediaFileRepo + sourceTime time.Time + ) + + BeforeEach(func() { + sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC) + mockMFRepo = &tests.MockMediaFileRepo{} + ds.MockedMediaFile = mockMFRepo + }) + + createTokenForMedia := func(mediaID string, updatedAt time.Time) string { + decision := &Decision{ + MediaID: mediaID, + CanDirectPlay: true, + SourceUpdatedAt: updatedAt, + } + token, err := svc.CreateTranscodeParams(decision) + Expect(err).ToNot(HaveOccurred()) + return token + } + + It("returns stream request and media file for valid token", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", UpdatedAt: sourceTime}, + }) + token := createTokenForMedia("song-1", sourceTime) + + req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(req.ID).To(Equal("song-1")) + Expect(req.Format).To(BeEmpty()) // direct play has no target format + Expect(mf.ID).To(Equal("song-1")) + }) + + It("returns ErrTokenInvalid for invalid token", func() { + _, _, err := svc.ResolveRequestFromToken(ctx, "bad-token", "song-1", 0) + Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) + }) + + It("returns ErrTokenInvalid when mediaID does not match token", func() { + token := createTokenForMedia("song-1", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0) + Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error()))) + }) + + It("returns ErrMediaNotFound when media file does not exist", func() { + token := createTokenForMedia("gone-id", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0) + Expect(err).To(MatchError(ErrMediaNotFound)) + }) + + It("returns ErrTokenStale when media file has changed", func() { + newTime := sourceTime.Add(1 * time.Hour) + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", UpdatedAt: newTime}, + }) + token := createTokenForMedia("song-1", sourceTime) + + _, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0) + Expect(err).To(MatchError(ErrTokenStale)) + }) + }) + + Describe("paramsFromToken", func() { + It("returns error when media ID is missing", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)}) + Expect(err).NotTo(HaveOccurred()) + + _, err = paramsFromToken(token) + Expect(err).To(MatchError(ContainSubstring("missing media ID"))) + }) + + It("returns error when source timestamp is missing", func() { + tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil) + token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"}) + Expect(err).NotTo(HaveOccurred()) + + _, err = paramsFromToken(token) + Expect(err).To(MatchError(ContainSubstring("missing source timestamp"))) + }) + }) +}) diff --git a/core/transcode/transcode_suite_test.go b/core/transcode/transcode_suite_test.go new file mode 100644 index 000000000..e35471b05 --- /dev/null +++ b/core/transcode/transcode_suite_test.go @@ -0,0 +1,17 @@ +package transcode + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTranscode(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Transcode Suite") +} diff --git a/core/transcode/types.go b/core/transcode/types.go new file mode 100644 index 000000000..d7a63fbc4 --- /dev/null +++ b/core/transcode/types.go @@ -0,0 +1,134 @@ +package transcode + +import ( + "errors" + "time" +) + +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") +) + +// DecisionOptions controls optional behavior of MakeDecision. +type DecisionOptions struct { + // SkipProbe prevents MakeDecision 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 + Format string + BitRate int // kbps + SampleRate int + BitDepth int + Channels int + Offset int // seconds +} + +// ClientInfo represents client playback capabilities. +// All bitrate values are in kilobits per second (kbps) +type ClientInfo struct { + Name string + Platform string + MaxAudioBitrate int + MaxTranscodingAudioBitrate int + DirectPlayProfiles []DirectPlayProfile + TranscodingProfiles []Profile + CodecProfiles []CodecProfile +} + +// DirectPlayProfile describes a format the client can play directly +type DirectPlayProfile struct { + Containers []string + AudioCodecs []string + Protocols []string + MaxAudioChannels int +} + +// Profile describes a transcoding target the client supports +type Profile struct { + Container string + AudioCodec string + Protocol string + MaxAudioChannels int +} + +// CodecProfile describes codec-specific limitations +type CodecProfile struct { + Type string + Name string + Limitations []Limitation +} + +// Limitation describes a specific codec limitation +type Limitation struct { + Name string + Comparison string + Values []string + Required bool +} + +// Protocol values (OpenSubsonic spec enum) +const ( + ProtocolHTTP = "http" + ProtocolHLS = "hls" +) + +// Comparison operators (OpenSubsonic spec enum) +const ( + ComparisonEquals = "Equals" + ComparisonNotEquals = "NotEquals" + ComparisonLessThanEqual = "LessThanEqual" + ComparisonGreaterThanEqual = "GreaterThanEqual" +) + +// Limitation names (OpenSubsonic spec enum) +const ( + LimitationAudioChannels = "audioChannels" + LimitationAudioBitrate = "audioBitrate" + LimitationAudioProfile = "audioProfile" + LimitationAudioSamplerate = "audioSamplerate" + LimitationAudioBitdepth = "audioBitdepth" +) + +// Codec profile types (OpenSubsonic spec enum) +const ( + CodecProfileTypeAudio = "AudioCodec" +) + +// Decision represents the internal decision result. +// All bitrate values are in kilobits per second (kbps). +type Decision struct { + MediaID string + CanDirectPlay bool + CanTranscode bool + TranscodeReasons []string + ErrorReason string + TargetFormat string + TargetBitrate int + TargetChannels int + TargetSampleRate int + TargetBitDepth int + SourceStream StreamDetails + SourceUpdatedAt time.Time + TranscodeStream *StreamDetails +} + +// StreamDetails describes audio stream properties. +// Bitrate is in kilobits per second (kbps). +type StreamDetails struct { + Container string + Codec string + Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data. + Bitrate int + SampleRate int + BitDepth int + Channels int + Duration float32 + Size int64 + IsLossless bool +} diff --git a/core/wire_providers.go b/core/wire_providers.go index f9b472015..20b5eb9a5 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -10,11 +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" ) var Set = wire.NewSet( - NewMediaStreamer, - GetTranscodingCache, + transcode.NewMediaStreamer, + transcode.GetTranscodingCache, NewArchiver, NewPlayers, NewShare, @@ -22,6 +23,7 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, + transcode.NewDecider, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/db/migrations/20260307175815_add_codec_and_update_transcodings.go b/db/migrations/20260307175815_add_codec_and_update_transcodings.go new file mode 100644 index 000000000..4e8b1b7f5 --- /dev/null +++ b/db/migrations/20260307175815_add_codec_and_update_transcodings.go @@ -0,0 +1,73 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/navidrome/navidrome/model/id" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddCodecAndUpdateTranscodings, downAddCodecAndUpdateTranscodings) +} + +func upAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { + // Add codec column to media_file. + _, err := tx.Exec(`ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL`) + if err != nil { + return err + } + _, err = tx.Exec(`CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec)`) + if err != nil { + return err + } + + // Update old AAC default (adts) to new default (ipod with fragmented MP4). + // Only affects users who still have the unmodified old default command. + _, err = tx.Exec( + `UPDATE transcoding SET command = ? WHERE target_format = 'aac' AND command = ?`, + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + ) + if err != nil { + return err + } + + // Add FLAC transcoding for existing installations that were seeded before FLAC was added. + var count int + err = tx.QueryRow("SELECT COUNT(*) FROM transcoding WHERE target_format = 'flac'").Scan(&count) + if err != nil { + return err + } + if count == 0 { + _, err = tx.Exec( + "INSERT INTO transcoding (id, name, target_format, default_bit_rate, command) VALUES (?, ?, ?, ?, ?)", + id.NewRandom(), "flac audio", "flac", 0, + "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + ) + if err != nil { + return err + } + } + + // Add probe_data column for caching ffprobe results. + _, err = tx.Exec(`ALTER TABLE media_file ADD COLUMN probe_data TEXT DEFAULT NULL`) + if err != nil { + return err + } + return nil +} + +func downAddCodecAndUpdateTranscodings(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE media_file DROP COLUMN probe_data`) + if err != nil { + return err + } + _, err = tx.Exec(`DROP INDEX IF EXISTS media_file_codec`) + if err != nil { + return err + } + _, err = tx.Exec(`ALTER TABLE media_file DROP COLUMN codec`) + return err +} diff --git a/model/mediafile.go b/model/mediafile.go index 103b02639..20532bfb9 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -56,6 +56,8 @@ type MediaFile struct { SampleRate int `structs:"sample_rate" json:"sampleRate"` BitDepth int `structs:"bit_depth" json:"bitDepth"` Channels int `structs:"channels" json:"channels"` + Codec string `structs:"codec" json:"codec"` + ProbeData string `structs:"probe_data" json:"-" hash:"ignore"` Genre string `structs:"genre" json:"genre"` Genres Genres `structs:"-" json:"genres,omitempty"` SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` @@ -168,6 +170,63 @@ func (mf MediaFile) AbsolutePath() string { return filepath.Join(mf.LibraryPath, mf.Path) } +// AudioCodec returns the audio codec for this file. +// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties. +func (mf MediaFile) AudioCodec() string { + // If we have a stored codec from scanning, normalize and return it + if mf.Codec != "" { + return strings.ToLower(mf.Codec) + } + // Fallback: infer from Suffix + BitDepth + return mf.inferCodecFromSuffix() +} + +// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty. +func (mf MediaFile) inferCodecFromSuffix() string { + switch strings.ToLower(mf.Suffix) { + case "mp3", "mpga": + return "mp3" + case "mp2": + return "mp2" + case "ogg", "oga": + return "vorbis" + case "opus": + return "opus" + case "mpc": + return "mpc" + case "wma": + return "wma" + case "flac": + return "flac" + case "wav": + return "pcm" + case "aif", "aiff", "aifc": + return "pcm" + case "ape": + return "ape" + case "wv", "wvp": + return "wv" + case "tta": + return "tta" + case "tak": + return "tak" + case "shn": + return "shn" + case "dsf", "dff": + return "dsd" + case "m4a": + // AAC if BitDepth==0, ALAC if BitDepth>0 + if mf.BitDepth > 0 { + return "alac" + } + return "aac" + case "m4b", "m4p", "m4r": + return "aac" + default: + return "" + } +} + type MediaFiles []MediaFile // ToAlbum creates an Album object based on the attributes of this MediaFiles collection. @@ -363,6 +422,7 @@ type MediaFileRepository interface { CountBySuffix(options ...QueryOptions) (map[string]int64, error) Exists(id string) (bool, error) Put(m *MediaFile) error + UpdateProbeData(id string, data string) error Get(id string) (*MediaFile, error) GetWithParticipants(id string) (*MediaFile, error) GetAll(options ...QueryOptions) (MediaFiles, error) diff --git a/model/mediafile_test.go b/model/mediafile_test.go index 0b9191fe5..207d3c155 100644 --- a/model/mediafile_test.go +++ b/model/mediafile_test.go @@ -497,7 +497,7 @@ var _ = Describe("MediaFile", func() { Entry("returns just album name when tag is absent", true, Tags{}, "Album"), Entry("returns just album name when tag is an empty slice", true, Tags{TagAlbumVersion: []string{}}, "Album"), ) - Describe("CoverArtId()", func() { + Describe("CoverArtId", func() { It("returns its own id if it HasCoverArt", func() { mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} id := mf.CoverArtID() @@ -518,6 +518,58 @@ var _ = Describe("MediaFile", func() { Expect(id.ID).To(Equal(mf.AlbumID)) }) }) + + Describe("AudioCodec", func() { + It("returns normalized stored codec when available", func() { + mf := MediaFile{Codec: "AAC", Suffix: "m4a"} + Expect(mf.AudioCodec()).To(Equal("aac")) + }) + + It("returns stored codec lowercased", func() { + mf := MediaFile{Codec: "ALAC", Suffix: "m4a"} + Expect(mf.AudioCodec()).To(Equal("alac")) + }) + + DescribeTable("infers codec from suffix when Codec field is empty", + func(suffix string, bitDepth int, expected string) { + mf := MediaFile{Suffix: suffix, BitDepth: bitDepth} + Expect(mf.AudioCodec()).To(Equal(expected)) + }, + Entry("mp3", "mp3", 0, "mp3"), + Entry("mpga", "mpga", 0, "mp3"), + Entry("mp2", "mp2", 0, "mp2"), + Entry("ogg", "ogg", 0, "vorbis"), + Entry("oga", "oga", 0, "vorbis"), + Entry("opus", "opus", 0, "opus"), + Entry("mpc", "mpc", 0, "mpc"), + Entry("wma", "wma", 0, "wma"), + Entry("flac", "flac", 0, "flac"), + Entry("wav", "wav", 0, "pcm"), + Entry("aif", "aif", 0, "pcm"), + Entry("aiff", "aiff", 0, "pcm"), + Entry("aifc", "aifc", 0, "pcm"), + Entry("ape", "ape", 0, "ape"), + Entry("wv", "wv", 0, "wv"), + Entry("wvp", "wvp", 0, "wv"), + Entry("tta", "tta", 0, "tta"), + Entry("tak", "tak", 0, "tak"), + Entry("shn", "shn", 0, "shn"), + Entry("dsf", "dsf", 0, "dsd"), + Entry("dff", "dff", 0, "dsd"), + Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"), + Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"), + Entry("m4b", "m4b", 0, "aac"), + Entry("m4p", "m4p", 0, "aac"), + Entry("m4r", "m4r", 0, "aac"), + Entry("unknown suffix", "xyz", 0, ""), + ) + + It("prefers stored codec over suffix inference", func() { + mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0} + Expect(mf.AudioCodec()).To(Equal("alac")) + }) + }) + }) func t(v string) time.Time { diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index c64e8c724..824cad7c2 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.SampleRate = md.AudioProperties().SampleRate mf.BitDepth = md.AudioProperties().BitDepth mf.Channels = md.AudioProperties().Channels + mf.Codec = md.AudioProperties().Codec mf.Path = md.FilePath() mf.Suffix = md.Suffix() mf.Size = md.Size() diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 954505c98..48928f989 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -35,6 +35,7 @@ type AudioProperties struct { BitDepth int SampleRate int Channels int + Codec string } type Date string diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 43736e317..264778ea0 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -163,6 +163,11 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error { return r.updateParticipants(m.ID, m.Participants) } +func (r *mediaFileRepository) UpdateProbeData(id string, data string) error { + _, err := r.executeSQL(Update(r.tableName).Set("probe_data", data).Where(Eq{"id": id})) + return err +} + func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). LeftJoin("library on media_file.library_id = library.id") diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 484a91cc7..02b66f4b7 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -3,6 +3,7 @@ package e2e import ( "context" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -19,12 +20,14 @@ import ( "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "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/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -69,6 +72,7 @@ var ( ctx context.Context ds *tests.MockDataStore router *subsonic.Router + spy *spyStreamer lib model.Library // Snapshot paths for fast DB restore @@ -224,17 +228,50 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool return io.NopCloser(io.LimitReader(nil, 0)), time.Time{}, nil } -// noopStreamer implements core.MediaStreamer -type noopStreamer struct{} +// spyStreamer captures the StreamRequest passed to DoStream for test assertions, +// then returns a minimal fake Stream so the handler completes without error. +type spyStreamer struct { + LastRequest transcode.StreamRequest + LastMediaFile *model.MediaFile +} -func (n noopStreamer) NewStream(context.Context, string, string, int, int) (*core.Stream, error) { +func (s *spyStreamer) NewStream(ctx context.Context, req transcode.StreamRequest) (*transcode.Stream, error) { return nil, model.ErrNotFound } -func (n noopStreamer) DoStream(context.Context, *model.MediaFile, string, int, int) (*core.Stream, error) { - return nil, model.ErrNotFound +func (s *spyStreamer) DoStream(_ context.Context, mf *model.MediaFile, req transcode.StreamRequest) (*transcode.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 } +// noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. +type noopFFmpeg struct{} + +func (n noopFFmpeg) Transcode(context.Context, ffmpeg.TranscodeOptions) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: transcode not supported") +} + +func (n noopFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + return nil, errors.New("noop ffmpeg: extract image not supported") +} + +func (n noopFFmpeg) Probe(context.Context, []string) (string, error) { + return "", nil +} + +func (n noopFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + return nil, errors.New("noop ffmpeg: probe not supported") +} + +func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } +func (n noopFFmpeg) IsAvailable() bool { return false } +func (n noopFFmpeg) Version() string { return "noop" } + // noopArchiver implements core.Archiver type noopArchiver struct{} @@ -298,11 +335,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{} + _ artwork.Artwork = noopArtwork{} + _ transcode.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ scrobbler.PlayTracker = noopPlayTracker{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -380,13 +418,15 @@ func setupTestDB() { ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} auth.Init(ds) - // Create the Subsonic Router with real DS + noop stubs + // Create the Subsonic Router with real DS, spy streamer, and real Decider + spy = &spyStreamer{} + decider := transcode.NewDecider(ds, noopFFmpeg{}) s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), playlists.NewPlaylists(ds), metrics.NewNoopInstance()) router = subsonic.New( ds, noopArtwork{}, - noopStreamer{}, + spy, noopArchiver{}, core.NewPlayers(ds), noopProvider{}, @@ -398,6 +438,7 @@ func setupTestDB() { playback.PlaybackServer(nil), metrics.NewNoopInstance(), lyrics.NewLyrics(nil), + decider, ) } diff --git a/server/e2e/subsonic_media_retrieval_test.go b/server/e2e/subsonic_media_retrieval_test.go index c36713dbb..465082acb 100644 --- a/server/e2e/subsonic_media_retrieval_test.go +++ b/server/e2e/subsonic_media_retrieval_test.go @@ -3,6 +3,9 @@ package e2e import ( "net/http" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -14,21 +17,142 @@ var _ = Describe("Media Retrieval Endpoints", Ordered, func() { }) Describe("Stream", func() { + var trackID string + + BeforeAll(func() { + // All test tracks are mp3 at 320kbps + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + trackID = songs[0].ID + }) + It("returns error when id parameter is missing", func() { resp := doReq("stream") Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) }) + + It("streams raw when no format or bitrate specified", func() { + w := doRawReq("stream", "id", trackID) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("streams raw when format=raw", func() { + w := doRawReq("stream", "id", trackID, "format", "raw") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("transcodes to different format with bitrate", func() { + w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("downsamples when only maxBitRate is specified (lower than source)", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DefaultDownsamplingFormat = "opus" + + w := doRawReq("stream", "id", trackID, "maxBitRate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("streams raw when maxBitRate is higher than source", func() { + w := doRawReq("stream", "id", trackID, "maxBitRate", "999") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("streams raw when format matches source and no bitrate reduction", func() { + w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "320") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("transcodes when same format but lower bitrate", func() { + w := doRawReq("stream", "id", trackID, "format", "mp3", "maxBitRate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("mp3")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("falls back to raw for unknown format", func() { + w := doRawReq("stream", "id", trackID, "format", "xyz") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("passes timeOffset through", func() { + w := doRawReq("stream", "id", trackID, "format", "opus", "maxBitRate", "128", "timeOffset", "30") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.Offset).To(Equal(30)) + }) }) Describe("Download", func() { + var trackID string + + BeforeAll(func() { + // All test tracks are mp3 at 320kbps + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + trackID = songs[0].ID + }) + It("returns error when id parameter is missing", func() { resp := doReq("download") Expect(resp.Status).To(Equal(responses.StatusFailed)) Expect(resp.Error).ToNot(BeNil()) }) + + It("downloads raw when no format specified and AutoTranscodeDownload is false", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableDownloads = true + conf.Server.AutoTranscodeDownload = false + + w := doRawReq("download", "id", trackID) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("raw")) + }) + + It("downloads with explicit format and bitrate", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableDownloads = true + + w := doRawReq("download", "id", trackID, "format", "opus", "bitrate", "128") + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(spy.LastRequest.Format).To(Equal("opus")) + Expect(spy.LastRequest.BitRate).To(Equal(128)) + }) + + It("returns error when downloads are disabled", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableDownloads = false + + resp := doReq("download", "id", trackID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) }) Describe("GetCoverArt", func() { diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index d6819974b..a147a2ac8 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/transcode" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/utils/req" ) @@ -22,10 +23,13 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0) + stream, err := pub.streamer.NewStream(ctx, transcode.StreamRequest{ + ID: info.id, Format: info.format, BitRate: info.bitrate, + }) if err != nil { log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) + return } // Make sure the stream will be closed at the end, to avoid leakage diff --git a/server/public/public.go b/server/public/public.go index ebccb01d2..7d8a4e007 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -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()))) diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index aac2d63da..ae4ef9bb9 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 8674a2946..6f355d161 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,6 +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/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" @@ -36,42 +37,44 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, type Router struct { http.Handler - ds model.DataStore - artwork artwork.Artwork - streamer core.MediaStreamer - archiver core.Archiver - players core.Players - provider external.Provider - playlists playlistsvc.Playlists - scanner model.Scanner - broker events.Broker - scrobbler scrobbler.PlayTracker - share core.Share - playback playback.PlaybackServer - metrics metrics.Metrics - lyrics lyricssvc.Lyrics + ds model.DataStore + artwork artwork.Artwork + streamer transcode.MediaStreamer + archiver core.Archiver + players core.Players + provider external.Provider + playlists playlistsvc.Playlists + scanner model.Scanner + broker events.Broker + scrobbler scrobbler.PlayTracker + share core.Share + playback playback.PlaybackServer + metrics metrics.Metrics + lyrics lyricssvc.Lyrics + 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, + metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision transcode.Decider, ) *Router { r := &Router{ - ds: ds, - artwork: artwork, - streamer: streamer, - archiver: archiver, - players: players, - provider: provider, - playlists: playlists, - scanner: scanner, - broker: broker, - scrobbler: scrobbler, - share: share, - playback: playback, - metrics: metrics, - lyrics: lyrics, + ds: ds, + artwork: artwork, + streamer: streamer, + archiver: archiver, + players: players, + provider: provider, + playlists: playlists, + scanner: scanner, + broker: broker, + scrobbler: scrobbler, + share: share, + playback: playback, + metrics: metrics, + lyrics: lyrics, + transcodeDecision: transcodeDecision, } r.Handler = r.routes() return r @@ -176,6 +179,8 @@ func (api *Router) routes() http.Handler { h(r, "getLyricsBySongId", api.GetLyricsBySongId) hr(r, "stream", api.Stream) hr(r, "download", api.Download) + hr(r, "getTranscodeDecision", api.GetTranscodeDecision) + hr(r, "getTranscodeStream", api.GetTranscodeStream) }) r.Group(func(r chi.Router) { // configure request throttling diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 57809fbb6..fc767b0ff 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 1a638f066..7f64fb47f 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil)) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index a364651c5..353cf1077 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -13,6 +13,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, + {Name: "transcoding", Versions: []int32{1}}, } return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index c02b262b9..92d1c3e84 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ) BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) @@ -35,11 +35,12 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(4), + HaveLen(5), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 86c17b39c..41701b4de 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -224,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 0fdbf1be6..be59e5851 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -61,6 +61,7 @@ type Subsonic struct { OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` + TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` } const ( @@ -617,3 +618,26 @@ func marshalJSONArray[T any](v []T) ([]byte, error) { } return json.Marshal(v) } + +// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension) +type TranscodeDecision struct { + CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"` + CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"` + TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"` + ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"` + TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"` + SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"` + TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"` +} + +// StreamDetails describes audio stream properties for transcoding decisions +type StreamDetails struct { + Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"` + Container string `xml:"container,attr,omitempty" json:"container,omitempty"` + Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"` + AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"` + AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"` + AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"` + AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"` + AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"` +} diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index d4b7e9702..ab40a726f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index d0cbe2086..753e408c1 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -9,7 +9,7 @@ import ( "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/transcode" "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 *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 { @@ -60,7 +60,13 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su format, _ := p.String("format") timeOffset := p.IntOr("timeOffset", 0) - stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, timeOffset) + mf, err := api.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err + } + + streamReq := api.transcodeDecision.ResolveRequest(ctx, mf, format, maxBitRate, timeOffset) + stream, err := api.streamer.DoStream(ctx, mf, streamReq) if err != nil { return nil, err } @@ -129,7 +135,8 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. switch v := entity.(type) { case *model.MediaFile: - stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0) + streamReq := api.transcodeDecision.ResolveRequest(ctx, v, format, maxBitRate, 0) + stream, err := api.streamer.DoStream(ctx, v, streamReq) if err != nil { return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go new file mode 100644 index 000000000..ffc4cfcd7 --- /dev/null +++ b/server/subsonic/transcode.go @@ -0,0 +1,381 @@ +package subsonic + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + + "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +// API-layer request structs for JSON unmarshaling (decoupled from core structs) + +// clientInfoRequest represents client playback capabilities from the request body +type clientInfoRequest struct { + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"` + MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"` + DirectPlayProfiles []directPlayProfileRequest `json:"directPlayProfiles,omitempty"` + TranscodingProfiles []transcodingProfileRequest `json:"transcodingProfiles,omitempty"` + CodecProfiles []codecProfileRequest `json:"codecProfiles,omitempty"` +} + +// directPlayProfileRequest describes a format the client can play directly +type directPlayProfileRequest struct { + Containers []string `json:"containers,omitempty"` + AudioCodecs []string `json:"audioCodecs,omitempty"` + Protocols []string `json:"protocols,omitempty"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +// transcodingProfileRequest describes a transcoding target the client supports +type transcodingProfileRequest struct { + Container string `json:"container,omitempty"` + AudioCodec string `json:"audioCodec,omitempty"` + Protocol string `json:"protocol,omitempty"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +// codecProfileRequest describes codec-specific limitations +type codecProfileRequest struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Limitations []limitationRequest `json:"limitations,omitempty"` +} + +// limitationRequest describes a specific codec limitation +type limitationRequest struct { + Name string `json:"name,omitempty"` + Comparison string `json:"comparison,omitempty"` + Values []string `json:"values,omitempty"` + Required bool `json:"required,omitempty"` +} + +// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct. +// The OpenSubsonic spec uses bps for bitrate values; core uses kbps. +func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo { + ci := &transcode.ClientInfo{ + Name: r.Name, + Platform: r.Platform, + MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate), + MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate), + } + + for _, dp := range r.DirectPlayProfiles { + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{ + Containers: dp.Containers, + AudioCodecs: dp.AudioCodecs, + Protocols: dp.Protocols, + MaxAudioChannels: dp.MaxAudioChannels, + }) + } + + for _, tp := range r.TranscodingProfiles { + ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{ + Container: tp.Container, + AudioCodec: tp.AudioCodec, + Protocol: tp.Protocol, + MaxAudioChannels: tp.MaxAudioChannels, + }) + } + + for _, cp := range r.CodecProfiles { + coreCP := transcode.CodecProfile{ + Type: cp.Type, + Name: cp.Name, + } + for _, lim := range cp.Limitations { + coreLim := transcode.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 { + coreLim.Values = convertBitrateValues(lim.Values) + } + coreCP.Limitations = append(coreCP.Limitations, coreLim) + } + ci.CodecProfiles = append(ci.CodecProfiles, coreCP) + } + + return ci +} + +// bpsToKbps converts bits per second to kilobits per second (rounded). +func bpsToKbps(bps int) int { + if bps < 0 { + return 0 + } + return (bps + 500) / 1000 +} + +// kbpsToBps converts kilobits per second to bits per second. +func kbpsToBps(kbps int) int { + return kbps * 1000 +} + +// convertBitrateValues converts a slice of bps string values to kbps string values. +func convertBitrateValues(bpsValues []string) []string { + result := make([]string, len(bpsValues)) + for i, v := range bpsValues { + n, err := strconv.Atoi(v) + if err == nil { + result[i] = strconv.Itoa(bpsToKbps(n)) + } else { + result[i] = v // preserve unparseable values as-is + } + } + return result +} + +// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec. +func (r *clientInfoRequest) validate() error { + for _, dp := range r.DirectPlayProfiles { + for _, p := range dp.Protocols { + if !isValidProtocol(p) { + return fmt.Errorf("invalid protocol: %s", p) + } + } + } + for _, tp := range r.TranscodingProfiles { + if tp.Protocol != "" && !isValidProtocol(tp.Protocol) { + return fmt.Errorf("invalid protocol: %s", tp.Protocol) + } + } + for _, cp := range r.CodecProfiles { + if !isValidCodecProfileType(cp.Type) { + return fmt.Errorf("invalid codec profile type: %s", cp.Type) + } + for _, lim := range cp.Limitations { + if !isValidLimitationName(lim.Name) { + return fmt.Errorf("invalid limitation name: %s", lim.Name) + } + if !isValidComparison(lim.Comparison) { + return fmt.Errorf("invalid comparison: %s", lim.Comparison) + } + } + } + return nil +} + +// Only support songs for now +var validMediaTypes = []string{ + "song", +} + +func isValidMediaType(mediaType string) bool { + return slices.Contains(validMediaTypes, mediaType) +} + +var validProtocols = []string{ + transcode.ProtocolHTTP, + transcode.ProtocolHLS, +} + +func isValidProtocol(p string) bool { + return slices.Contains(validProtocols, p) +} + +var validCodecProfileTypes = []string{ + transcode.CodecProfileTypeAudio, +} + +func isValidCodecProfileType(t string) bool { + return slices.Contains(validCodecProfileTypes, t) +} + +var validLimitationNames = []string{ + transcode.LimitationAudioChannels, + transcode.LimitationAudioBitrate, + transcode.LimitationAudioProfile, + transcode.LimitationAudioSamplerate, + transcode.LimitationAudioBitdepth, +} + +func isValidLimitationName(n string) bool { + return slices.Contains(validLimitationNames, n) +} + +var validComparisons = []string{ + transcode.ComparisonEquals, + transcode.ComparisonNotEquals, + transcode.ComparisonLessThanEqual, + transcode.ComparisonGreaterThanEqual, +} + +func isValidComparison(c string) bool { + return slices.Contains(validComparisons, c) +} + +// toResponseStreamDetails converts a core StreamDetails to the API response type. +func toResponseStreamDetails(sd *transcode.StreamDetails) *responses.StreamDetails { + return &responses.StreamDetails{ + Protocol: transcode.ProtocolHTTP, // TODO: derive from decision when HLS support is added + Container: sd.Container, + Codec: sd.Codec, + AudioBitrate: int32(kbpsToBps(sd.Bitrate)), + AudioProfile: sd.Profile, + AudioSamplerate: int32(sd.SampleRate), + AudioBitdepth: int32(sd.BitDepth), + AudioChannels: int32(sd.Channels), + } +} + +// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint. +// It receives client capabilities and returns a decision on whether to direct play or transcode. +func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return nil, nil + } + + ctx := r.Context() + p := req.Params(r) + + mediaID, err := p.String("mediaId") + if err != nil { + return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId") + } + + mediaType, err := p.String("mediaType") + if err != nil { + return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType") + } + + if !isValidMediaType(mediaType) { + return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType) + } + + // Parse and validate ClientInfo from request body (required per OpenSubsonic spec) + var clientInfoReq clientInfoRequest + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB limit + if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil { + return nil, newError(responses.ErrorGeneric, "invalid JSON request body") + } + if err := clientInfoReq.validate(); err != nil { + return nil, newError(responses.ErrorGeneric, "%v", err) + } + clientInfo := clientInfoReq.toCoreClientInfo() + + // Get media file + mf, err := api.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID) + } + log.Error(ctx, "Error retrieving media file", "mediaID", mediaID, err) + return nil, newError(responses.ErrorGeneric, "error retrieving media file") + } + + // Make the decision + decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo, transcode.DecisionOptions{}) + if err != nil { + log.Error(ctx, "Failed to make transcode decision", "mediaID", mediaID, err) + return nil, newError(responses.ErrorGeneric, "failed to make transcode decision") + } + + // Only create a token when there is a valid playback path + var transcodeParams string + if decision.CanDirectPlay || decision.CanTranscode { + transcodeParams, err = api.transcodeDecision.CreateTranscodeParams(decision) + if err != nil { + log.Error(ctx, "Failed to create transcode token", "mediaID", mediaID, err) + return nil, newError(responses.ErrorGeneric, "failed to create transcode token") + } + } + + // Build response (convert kbps from core to bps for the API) + response := newResponse() + response.TranscodeDecision = &responses.TranscodeDecision{ + CanDirectPlay: decision.CanDirectPlay, + CanTranscode: decision.CanTranscode, + TranscodeReasons: decision.TranscodeReasons, + ErrorReason: decision.ErrorReason, + TranscodeParams: transcodeParams, + SourceStream: toResponseStreamDetails(&decision.SourceStream), + } + + if decision.TranscodeStream != nil { + response.TranscodeDecision.TranscodeStream = toResponseStreamDetails(decision.TranscodeStream) + } + + return response, nil +} + +// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. +// It streams media using the decision encoded in the transcodeParams JWT token. +// All errors are returned as proper HTTP status codes (not Subsonic error responses). +func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + p := req.Params(r) + + mediaID, err := p.String("mediaId") + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + mediaType, err := p.String("mediaType") + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + transcodeParamsToken, err := p.String("transcodeParams") + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + if !isValidMediaType(mediaType) { + http.Error(w, "Bad Request", http.StatusBadRequest) + return nil, nil + } + + // Validate the token and resolve streaming parameters + streamReq, mf, err := api.transcodeDecision.ResolveRequestFromToken(ctx, transcodeParamsToken, mediaID, p.IntOr("offset", 0)) + if err != nil { + switch { + case errors.Is(err, transcode.ErrMediaNotFound): + http.Error(w, "Not Found", http.StatusNotFound) + case errors.Is(err, transcode.ErrTokenInvalid), errors.Is(err, transcode.ErrTokenStale): + http.Error(w, "Gone", http.StatusGone) + default: + log.Error(ctx, "Error validating transcode params", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return nil, nil + } + + // Create stream (use DoStream to avoid duplicate DB fetch) + stream, err := api.streamer.DoStream(ctx, mf, streamReq) + if err != nil { + log.Error(ctx, "Error creating stream", "mediaID", mediaID, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return nil, nil + } + + // Make sure the stream will be closed at the end + defer func() { + if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err) + } + }() + + w.Header().Set("X-Content-Type-Options", "nosniff") + + api.serveStream(ctx, w, r, stream, mediaID) + + return nil, nil +} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go new file mode 100644 index 000000000..717eeb1f5 --- /dev/null +++ b/server/subsonic/transcode_test.go @@ -0,0 +1,406 @@ +package subsonic + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/core/transcode" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Transcode endpoints", func() { + var ( + router *Router + ds *tests.MockDataStore + mockTD *mockTranscodeDecision + w *httptest.ResponseRecorder + mockMFRepo *tests.MockMediaFileRepo + ) + + BeforeEach(func() { + mockMFRepo = &tests.MockMediaFileRepo{} + ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} + mockTD = &mockTranscodeDecision{} + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + w = httptest.NewRecorder() + }) + + Describe("GetTranscodeDecision", func() { + It("returns 405 for non-POST requests", func() { + r := newGetRequest("mediaId=123", "mediaType=song") + resp, err := router.GetTranscodeDecision(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusMethodNotAllowed)) + Expect(w.Header().Get("Allow")).To(Equal("POST")) + }) + + It("returns error when mediaId is missing", func() { + r := newJSONPostRequest("mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newJSONPostRequest("mediaId=123", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for unsupported mediaType", func() { + r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not yet supported")) + }) + + It("returns ErrorDataNotFound when media file does not exist", func() { + // mockMFRepo has no data set, so Get() returns model.ErrNotFound + r := newJSONPostRequest("mediaId=nonexistent&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("media file not found")) + }) + + It("returns error when media file retrieval fails", func() { + mockMFRepo.SetError(true) + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "{}") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("error retrieving media file")) + }) + + It("returns error when body is empty", func() { + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when body contains invalid JSON", func() { + r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{") + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid protocol in direct play profile", func() { + body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid protocol")) + }) + + It("returns error for invalid comparison operator", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid comparison")) + }) + + It("returns error for invalid limitation name", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid limitation name")) + }) + + It("returns error for invalid codec profile type", func() { + body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid codec profile type")) + }) + + It("rejects wrong-case protocol", func() { + body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid protocol")) + }) + + It("rejects wrong-case codec profile type", func() { + body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid codec profile type")) + }) + + It("rejects wrong-case comparison operator", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid comparison")) + }) + + It("rejects wrong-case limitation name", func() { + body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + _, err := router.GetTranscodeDecision(w, r) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid limitation name")) + }) + + It("returns a valid decision response", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}, + }) + mockTD.decision = &transcode.Decision{ + MediaID: "song-1", + CanDirectPlay: true, + SourceStream: transcode.StreamDetails{ + Container: "mp3", Codec: "mp3", Bitrate: 320, + SampleRate: 44100, Channels: 2, + }, + } + mockTD.token = "test-jwt-token" + + body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}` + r := newJSONPostRequest("mediaId=song-1&mediaType=song", body) + resp, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.TranscodeDecision).ToNot(BeNil()) + Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token")) + Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http")) + Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3")) + Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000))) + }) + + It("includes transcode stream when transcoding", func() { + mockMFRepo.SetData(model.MediaFiles{ + {ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}, + }) + mockTD.decision = &transcode.Decision{ + MediaID: "song-2", + CanDirectPlay: false, + CanTranscode: true, + TargetFormat: "mp3", + TargetBitrate: 256, + TranscodeReasons: []string{"container not supported"}, + SourceStream: transcode.StreamDetails{ + Container: "flac", Codec: "flac", Bitrate: 1000, + SampleRate: 96000, BitDepth: 24, Channels: 2, + }, + TranscodeStream: &transcode.StreamDetails{ + Container: "mp3", Codec: "mp3", Bitrate: 256, + SampleRate: 96000, Channels: 2, + }, + } + mockTD.token = "transcode-token" + + r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}") + resp, err := router.GetTranscodeDecision(w, r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) + Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported")) + Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) + Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) + }) + }) + + Describe("GetTranscodeStream", func() { + It("returns 400 when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "transcodeParams=abc") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when transcodeParams is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 410 for invalid or mismatched token", func() { + mockTD.resolveErr = transcode.ErrTokenInvalid + r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 404 when media file not found", func() { + mockTD.resolveErr = transcode.ErrMediaNotFound + r := newGetRequest("mediaId=gone-id", "mediaType=song", "transcodeParams=valid-token") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 410 when media file has changed (stale token)", func() { + mockTD.resolveErr = transcode.ErrTokenStale + r := newGetRequest("mediaId=song-1", "mediaType=song", "transcodeParams=stale-token") + resp, err := router.GetTranscodeStream(w, r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp).To(BeNil()) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + 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"} + + 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()) + Expect(fakeStreamer.captured.BitDepth).To(BeZero()) + Expect(fakeStreamer.captured.Channels).To(BeZero()) + }) + + 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", + 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)) + Expect(fakeStreamer.captured.BitDepth).To(Equal(16)) + Expect(fakeStreamer.captured.Channels).To(Equal(2)) + Expect(fakeStreamer.captured.Offset).To(Equal(10)) + }) + }) + + Describe("bpsToKbps", func() { + It("converts standard bitrates", func() { + Expect(bpsToKbps(128000)).To(Equal(128)) + Expect(bpsToKbps(320000)).To(Equal(320)) + Expect(bpsToKbps(256000)).To(Equal(256)) + }) + It("returns 0 for 0", func() { + Expect(bpsToKbps(0)).To(Equal(0)) + }) + It("rounds instead of truncating", func() { + Expect(bpsToKbps(999)).To(Equal(1)) + Expect(bpsToKbps(500)).To(Equal(1)) + Expect(bpsToKbps(499)).To(Equal(0)) + }) + It("returns 0 for negative values", func() { + Expect(bpsToKbps(-1)).To(Equal(0)) + Expect(bpsToKbps(-1000)).To(Equal(0)) + Expect(bpsToKbps(-1000000)).To(Equal(0)) + }) + }) + + Describe("kbpsToBps", func() { + It("converts standard bitrates", func() { + Expect(kbpsToBps(128)).To(Equal(128000)) + Expect(kbpsToBps(320)).To(Equal(320000)) + }) + It("returns 0 for 0", func() { + Expect(kbpsToBps(0)).To(Equal(0)) + }) + }) + + Describe("convertBitrateValues", func() { + It("converts valid bps strings to kbps", func() { + Expect(convertBitrateValues([]string{"128000", "320000"})).To(Equal([]string{"128", "320"})) + }) + It("preserves unparseable values", func() { + Expect(convertBitrateValues([]string{"128000", "bad", "320000"})).To(Equal([]string{"128", "bad", "320"})) + }) + It("handles empty slice", func() { + Expect(convertBitrateValues([]string{})).To(Equal([]string{})) + }) + }) +}) + +// newJSONPostRequest creates an HTTP POST request with JSON body and query params +func newJSONPostRequest(queryParams string, jsonBody string) *http.Request { + r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody)) + r.Header.Set("Content-Type", "application/json") + return r +} + +// mockTranscodeDecision is a test double for transcode.Decider +type mockTranscodeDecision struct { + decision *transcode.Decision + token string + tokenErr error + resolvedReq transcode.StreamRequest + resolvedMF *model.MediaFile + resolveErr error +} + +func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo, _ transcode.DecisionOptions) (*transcode.Decision, error) { + if m.decision != nil { + return m.decision, nil + } + return &transcode.Decision{}, nil +} + +func (m *mockTranscodeDecision) ResolveRequest(_ context.Context, _ *model.MediaFile, _ string, _ int, _ int) transcode.StreamRequest { + return transcode.StreamRequest{Format: "raw"} +} + +func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) { + return m.token, m.tokenErr +} + +func (m *mockTranscodeDecision) ResolveRequestFromToken(_ context.Context, _ string, _ string, offset int) (transcode.StreamRequest, *model.MediaFile, error) { + if m.resolveErr != nil { + return transcode.StreamRequest{}, nil, m.resolveErr + } + req := m.resolvedReq + req.Offset = offset + return req, m.resolvedMF, nil +} + +// fakeMediaStreamer captures the StreamRequest and returns a sentinel error, +// allowing tests to verify parameter passing without constructing a real Stream. +var errStreamCaptured = errors.New("stream request captured") + +type fakeMediaStreamer struct { + captured *transcode.StreamRequest +} + +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) { + f.captured = &req + return nil, errStreamCaptured +} diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index a792ae9d3..a35defeae 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -6,6 +6,8 @@ import ( "strings" "sync" "sync/atomic" + + "github.com/navidrome/navidrome/core/ffmpeg" ) func NewMockFFmpeg(data string) *MockFFmpeg { @@ -14,16 +16,17 @@ func NewMockFFmpeg(data string) *MockFFmpeg { type MockFFmpeg struct { io.Reader - lock sync.Mutex - closed atomic.Bool - Error error + lock sync.Mutex + closed atomic.Bool + Error error + ProbeAudioResult *ffmpeg.AudioProbeResult } func (ff *MockFFmpeg) IsAvailable() bool { return true } -func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) { +func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) { if ff.Error != nil { return nil, ff.Error } @@ -43,6 +46,13 @@ func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) { } return "", nil } +func (ff *MockFFmpeg) ProbeAudioStream(context.Context, string) (*ffmpeg.AudioProbeResult, error) { + if ff.Error != nil { + return nil, ff.Error + } + return ff.ProbeAudioResult, nil +} + func (ff *MockFFmpeg) CmdPath() (string, error) { if ff.Error != nil { return "", ff.Error diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go index 1d4527c88..01eacae30 100644 --- a/tests/mock_mediafile_repo.go +++ b/tests/mock_mediafile_repo.go @@ -109,6 +109,17 @@ func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { return nil } +func (m *MockMediaFileRepo) UpdateProbeData(id string, data string) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[id]; ok { + d.ProbeData = data + return nil + } + return model.ErrNotFound +} + func (m *MockMediaFileRepo) Delete(id string) error { if m.Err { return errors.New("error") diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 12db0d7be..796e84111 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -18,6 +18,10 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil + case "flac": + return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + case "aac": + return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil default: return nil, model.ErrNotFound }