feat(subsonic): implement OpenSubsonic Transcoding extension (#4990)
* feat(subsonic): implement transcode decision logic and codec handling for media files Signed-off-by: Deluan <deluan@navidrome.org> * fix(subsonic): update codec limitation structure and decision logic for improved clarity Signed-off-by: Deluan <deluan@navidrome.org> * fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): simplify container alias handling in matchesContainer function Signed-off-by: Deluan <deluan@navidrome.org> * fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests Signed-off-by: Deluan <deluan@navidrome.org> * feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance logging for transcode decision process and client info conversion Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance transcoding config lookup logic for audio codecs Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance transcoding options with sample rate support and improve command handling Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): add bit depth support for audio transcoding and enhance related logic Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): enhance AAC command handling and support for audio channels in streaming Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping Signed-off-by: Deluan <deluan@navidrome.org> * refactor(transcoding): update default command handling and add codec support for transcoding Signed-off-by: Deluan <deluan@navidrome.org> * fix: implement noopDecider for transcoding decision handling in tests Signed-off-by: Deluan <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * fix(ffmpeg): enhance bit_rate parsing logic for audio streams Signed-off-by: Deluan <deluan@navidrome.org> * 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 <deluan@navidrome.org> * feat(transcode): implement server-side transcoding override for player formats Signed-off-by: Deluan <deluan@navidrome.org> * 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 <deluan@navidrome.org> * 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 <deluan@navidrome.org> * test(e2e): add NewTestStream function and implement spyStreamer for testing Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -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)
|
||||
|
||||
+7
-5
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+7
-1
@@ -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 -",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
+4
-3
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-3
@@ -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
|
||||
}
|
||||
|
||||
+268
-8
@@ -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 != "" {
|
||||
|
||||
+493
-5
@@ -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()
|
||||
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+53
-1
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -35,6 +35,7 @@ type AudioProperties struct {
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
Codec string
|
||||
}
|
||||
|
||||
type Date string
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())))
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
+35
-30
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+14
-4
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user