diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 7b827e880..9b71cb462 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -77,6 +77,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { Channels: int(props.Channels), SampleRate: int(props.SampleRate), BitDepth: int(props.BitsPerSample), + Codec: props.Codec, } // Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index e8df9a386..dd41e6231 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -105,7 +105,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) + transcodeDecision := core.NewTranscodeDecision(dataStore) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecision) return router } diff --git a/core/transcode_decision.go b/core/transcode_decision.go new file mode 100644 index 000000000..140cf0357 --- /dev/null +++ b/core/transcode_decision.go @@ -0,0 +1,401 @@ +package core + +import ( + "context" + "strings" + "time" + + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" +) + +const ( + transcodeTokenTTL = 12 * time.Hour + defaultTranscodeBitrate = 256 // kbps +) + +// TranscodeDecision is the core service interface for making transcoding decisions +type TranscodeDecision interface { + MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) + CreateToken(decision *Decision) (string, error) + ParseToken(token string) (*TranscodeParams, error) +} + +// ClientInfo represents client playback capabilities +type ClientInfo struct { + Name string + Platform string + MaxAudioBitrate int + MaxTranscodingAudioBitrate int + DirectPlayProfiles []DirectPlayProfile + TranscodingProfiles []TranscodingProfile + CodecProfiles []CodecProfile +} + +// DirectPlayProfile describes a format the client can play directly +type DirectPlayProfile struct { + Containers []string + AudioCodecs []string + Protocols []string + MaxAudioChannels int +} + +// TranscodingProfile describes a transcoding target the client supports +type TranscodingProfile 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 { + Property string + Condition string + Value string +} + +// Decision represents the internal decision result +type Decision struct { + MediaID string + CanDirectPlay bool + CanTranscode bool + TranscodeReasons []string + ErrorReason string + TargetFormat string + TargetBitrate int + TargetChannels int + SourceStream StreamDetails + TranscodeStream *StreamDetails +} + +// StreamDetails describes audio stream properties +type StreamDetails struct { + Container string + Codec string + Bitrate int + SampleRate int + BitDepth int + Channels int + Duration float32 + Size int64 + IsLossless bool +} + +// TranscodeParams contains the parameters extracted from a transcode token +type TranscodeParams struct { + MediaID string + DirectPlay bool + TargetFormat string + TargetBitrate int + TargetChannels int +} + +func NewTranscodeDecision(ds model.DataStore) TranscodeDecision { + return &transcodeDecisionService{ + ds: ds, + } +} + +type transcodeDecisionService struct { + ds model.DataStore +} + +func (s *transcodeDecisionService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) { + decision := &Decision{ + MediaID: mf.ID, + } + + // Build source stream details + decision.SourceStream = StreamDetails{ + Container: mf.Suffix, + Codec: mf.AudioCodec(), + Bitrate: mf.BitRate, + SampleRate: mf.SampleRate, + BitDepth: mf.BitDepth, + Channels: mf.Channels, + Duration: mf.Duration, + Size: mf.Size, + IsLossless: mf.IsLossless(), + } + + // Check global bitrate constraint + if clientInfo.MaxAudioBitrate > 0 && mf.BitRate > clientInfo.MaxAudioBitrate { + decision.TranscodeReasons = append(decision.TranscodeReasons, "bitrate exceeds maxAudioBitrate") + } + + // Try direct play profiles + for _, profile := range clientInfo.DirectPlayProfiles { + if s.matchesDirectPlayProfile(mf, &profile, clientInfo) { + decision.CanDirectPlay = true + break + } + } + + // If direct play is possible and no transcode reasons, we're done + if decision.CanDirectPlay && len(decision.TranscodeReasons) == 0 { + return decision, nil + } + + // Try transcoding profiles (in order of preference) + for _, profile := range clientInfo.TranscodingProfiles { + if targetFormat, targetBitrate, ok := s.matchesTranscodingProfile(ctx, mf, &profile, clientInfo); ok { + decision.CanTranscode = true + decision.TargetFormat = targetFormat + decision.TargetBitrate = targetBitrate + decision.TargetChannels = profile.MaxAudioChannels + + // Build transcode stream details + decision.TranscodeStream = &StreamDetails{ + Container: targetFormat, + Codec: targetFormat, + Bitrate: targetBitrate, + SampleRate: mf.SampleRate, + Channels: mf.Channels, + IsLossless: false, + } + if decision.TargetChannels > 0 && decision.TargetChannels < mf.Channels { + decision.TranscodeStream.Channels = decision.TargetChannels + } + break + } + } + + // If neither direct play nor transcode is possible + if !decision.CanDirectPlay && !decision.CanTranscode { + decision.ErrorReason = "no compatible playback profile found" + } + + return decision, nil +} + +func (s *transcodeDecisionService) matchesDirectPlayProfile(mf *model.MediaFile, profile *DirectPlayProfile, clientInfo *ClientInfo) bool { + // Check protocol (only http for now) + if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, "http") { + return false + } + + // Check container + if len(profile.Containers) > 0 && !s.matchesContainer(mf.Suffix, profile.Containers) { + return false + } + + // Check codec + if len(profile.AudioCodecs) > 0 && !s.matchesCodec(mf.AudioCodec(), profile.AudioCodecs) { + return false + } + + // Check channels + if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels { + return false + } + + // Check codec-specific limitations + for _, codecProfile := range clientInfo.CodecProfiles { + if strings.EqualFold(codecProfile.Type, "AudioCodec") && strings.EqualFold(codecProfile.Name, mf.AudioCodec()) { + if !s.meetsLimitations(mf, codecProfile.Limitations) { + return false + } + } + } + + return true +} + +func (s *transcodeDecisionService) matchesTranscodingProfile(ctx context.Context, mf *model.MediaFile, profile *TranscodingProfile, clientInfo *ClientInfo) (string, int, bool) { + // Check protocol (only http for now) + if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, "http") { + return "", 0, false + } + + targetFormat := strings.ToLower(profile.Container) + if targetFormat == "" { + targetFormat = strings.ToLower(profile.AudioCodec) + } + + // Verify we have a transcoding config for this format + tc, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat) + if err != nil || tc == nil { + return "", 0, false + } + + // Reject lossy to lossless conversion + if !mf.IsLossless() && isLosslessFormat(targetFormat) { + return "", 0, false + } + + // Determine target bitrate + targetBitrate := defaultTranscodeBitrate + if mf.IsLossless() { + // Lossless to lossy: use client's max transcoding bitrate or default + if clientInfo.MaxTranscodingAudioBitrate > 0 { + targetBitrate = clientInfo.MaxTranscodingAudioBitrate / 1000 // Convert to kbps + } + } else { + // Lossy to lossy: try to preserve source bitrate if under max + targetBitrate = mf.BitRate / 1000 + if clientInfo.MaxTranscodingAudioBitrate > 0 && targetBitrate > clientInfo.MaxTranscodingAudioBitrate/1000 { + targetBitrate = clientInfo.MaxTranscodingAudioBitrate / 1000 + } + } + + return targetFormat, targetBitrate, true +} + +func (s *transcodeDecisionService) matchesContainer(suffix string, containers []string) bool { + suffix = strings.ToLower(suffix) + for _, c := range containers { + c = strings.ToLower(c) + if c == suffix { + return true + } + // Handle common aliases + if c == "aac" && (suffix == "m4a" || suffix == "m4b" || suffix == "m4p") { + return true + } + if c == "mpeg" && (suffix == "mp3" || suffix == "mp2") { + return true + } + if c == "ogg" && (suffix == "oga" || suffix == "opus") { + return true + } + } + return false +} + +func (s *transcodeDecisionService) matchesCodec(codec string, codecs []string) bool { + codec = strings.ToLower(codec) + for _, c := range codecs { + c = strings.ToLower(c) + if c == codec { + return true + } + // Handle common aliases + if c == "aac" && codec == "alac" { + continue // ALAC is not AAC + } + } + return false +} + +func (s *transcodeDecisionService) meetsLimitations(mf *model.MediaFile, limitations []Limitation) bool { + for _, lim := range limitations { + switch strings.ToLower(lim.Property) { + case "audiochannels": + if !checkIntLimitation(mf.Channels, lim.Condition, lim.Value) { + return false + } + case "audiosamplerate": + if !checkIntLimitation(mf.SampleRate, lim.Condition, lim.Value) { + return false + } + case "audiobitrate": + if !checkIntLimitation(mf.BitRate, lim.Condition, lim.Value) { + return false + } + case "audiobitdepth": + if !checkIntLimitation(mf.BitDepth, lim.Condition, lim.Value) { + return false + } + } + } + return true +} + +func (s *transcodeDecisionService) CreateToken(decision *Decision) (string, error) { + exp := time.Now().Add(transcodeTokenTTL) + claims := map[string]any{ + "mid": decision.MediaID, + "dp": decision.CanDirectPlay, + } + if decision.CanTranscode && decision.TargetFormat != "" { + claims["fmt"] = decision.TargetFormat + claims["br"] = decision.TargetBitrate + if decision.TargetChannels > 0 { + claims["ch"] = decision.TargetChannels + } + } + return auth.CreateExpiringPublicToken(exp, claims) +} + +func (s *transcodeDecisionService) ParseToken(token string) (*TranscodeParams, error) { + claims, err := auth.Validate(token) + if err != nil { + return nil, err + } + + params := &TranscodeParams{} + if mid, ok := claims["mid"].(string); ok { + params.MediaID = mid + } + if dp, ok := claims["dp"].(bool); ok { + params.DirectPlay = dp + } + if fmt, ok := claims["fmt"].(string); ok { + params.TargetFormat = fmt + } + if br, ok := claims["br"].(float64); ok { + params.TargetBitrate = int(br) + } + if ch, ok := claims["ch"].(float64); ok { + params.TargetChannels = int(ch) + } + + return params, nil +} + +func containsIgnoreCase(slice []string, s string) bool { + for _, item := range slice { + if strings.EqualFold(item, s) { + return true + } + } + return false +} + +func checkIntLimitation(value int, condition, limitValue string) bool { + var limit int + if _, err := parseIntFromString(limitValue, &limit); err != nil { + return true // If we can't parse the limit, assume it passes + } + + switch strings.ToLower(condition) { + case "lessthanequal", "lte": + return value <= limit + case "greaterthanequal", "gte": + return value >= limit + case "equals", "eq": + return value == limit + case "notequals", "ne": + return value != limit + default: + return true + } +} + +func parseIntFromString(s string, out *int) (bool, error) { + var v int + for _, c := range s { + if c < '0' || c > '9' { + return false, nil + } + v = v*10 + int(c-'0') + } + *out = v + return true, nil +} + +func isLosslessFormat(format string) bool { + switch strings.ToLower(format) { + case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd": + return true + } + return false +} diff --git a/core/wire_providers.go b/core/wire_providers.go index f9b472015..559b79f62 100644 --- a/core/wire_providers.go +++ b/core/wire_providers.go @@ -22,6 +22,7 @@ var Set = wire.NewSet( NewLibrary, NewUser, NewMaintenance, + NewTranscodeDecision, agents.GetAgents, external.NewProvider, wire.Bind(new(external.Agents), new(*agents.Agents)), diff --git a/db/migrations/20260205120000_add_codec_to_media_file.sql b/db/migrations/20260205120000_add_codec_to_media_file.sql new file mode 100644 index 000000000..e44133e01 --- /dev/null +++ b/db/migrations/20260205120000_add_codec_to_media_file.sql @@ -0,0 +1,7 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL; +CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec); +-- +goose StatementEnd + +-- +goose Down diff --git a/model/mediafile.go b/model/mediafile.go index 103b02639..e92109a06 100644 --- a/model/mediafile.go +++ b/model/mediafile.go @@ -14,6 +14,7 @@ import ( "github.com/gohugoio/hashstructure" "github.com/navidrome/navidrome/conf" + confmime "github.com/navidrome/navidrome/conf/mime" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/slice" @@ -56,6 +57,7 @@ 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"` Genre string `structs:"genre" json:"genre"` Genres Genres `structs:"-" json:"genres,omitempty"` SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"` @@ -168,6 +170,79 @@ 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 "" + } +} + +// IsLossless returns true if this file uses a lossless codec. +func (mf MediaFile) IsLossless() bool { + codec := mf.AudioCodec() + // Primary: codec-based check (most accurate for containers like M4A) + switch codec { + case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd": + return true + } + // Secondary: suffix-based check using configurable list from YAML + if slices.Contains(confmime.LosslessFormats, mf.Suffix) { + return true + } + // Fallback heuristic: if BitDepth is set, it's likely lossless + return mf.BitDepth > 0 +} + type MediaFiles []MediaFile // ToAlbum creates an Album object based on the attributes of this MediaFiles collection. diff --git a/model/metadata/map_mediafile.go b/model/metadata/map_mediafile.go index c64e8c724..824cad7c2 100644 --- a/model/metadata/map_mediafile.go +++ b/model/metadata/map_mediafile.go @@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { mf.SampleRate = md.AudioProperties().SampleRate mf.BitDepth = md.AudioProperties().BitDepth mf.Channels = md.AudioProperties().Channels + mf.Codec = md.AudioProperties().Codec mf.Path = md.FilePath() mf.Suffix = md.Suffix() mf.Size = md.Size() diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go index 954505c98..48928f989 100644 --- a/model/metadata/metadata.go +++ b/model/metadata/metadata.go @@ -35,6 +35,7 @@ type AudioProperties struct { BitDepth int SampleRate int Channels int + Codec string } type Date string diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 8674a2946..87da26a84 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -36,42 +36,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 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 + transcodeDecision core.TranscodeDecision } func New(ds model.DataStore, artwork artwork.Artwork, streamer core.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 core.TranscodeDecision, ) *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 +178,8 @@ func (api *Router) routes() http.Handler { h(r, "getLyricsBySongId", api.GetLyricsBySongId) hr(r, "stream", api.Stream) hr(r, "download", api.Download) + hr(r, "getTranscodeDecision", api.GetTranscodeDecision) + hr(r, "getTranscodeStream", api.GetTranscodeStream) }) r.Group(func(r chi.Router) { // configure request throttling diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 1a638f066..7f64fb47f 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil)) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index a364651c5..353cf1077 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -13,6 +13,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, + {Name: "transcoding", Versions: []int32{1}}, } return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index c02b262b9..3ac294457 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -35,11 +35,12 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(4), + HaveLen(5), ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), )) }) }) diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 0fdbf1be6..7bb7b36b5 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -61,6 +61,7 @@ type Subsonic struct { OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` + TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` } const ( @@ -617,3 +618,28 @@ 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:"transcodeReasons,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 { + Container string `xml:"container,attr,omitempty" json:"container,omitempty"` + Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"` + Bitrate int32 `xml:"bitrate,attr,omitempty" json:"bitrate,omitempty"` + SampleRate int32 `xml:"sampleRate,attr,omitempty" json:"sampleRate,omitempty"` + BitDepth int32 `xml:"bitDepth,attr,omitempty" json:"bitDepth,omitempty"` + Channels int32 `xml:"channels,attr,omitempty" json:"channels,omitempty"` + Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` + Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"` + IsLossless bool `xml:"isLossless,attr,omitempty" json:"isLossless,omitempty"` + IsDirectPlay bool `xml:"isDirectPlay,attr,omitempty" json:"isDirectPlay,omitempty"` +} diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go new file mode 100644 index 000000000..399e37510 --- /dev/null +++ b/server/subsonic/transcode.go @@ -0,0 +1,253 @@ +package subsonic + +import ( + "encoding/json" + "net/http" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "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 []directPlayProfileReq `json:"directPlayProfiles,omitempty"` + TranscodingProfiles []transcodingProfileReq `json:"transcodingProfiles,omitempty"` + CodecProfiles []codecProfileReq `json:"codecProfiles,omitempty"` +} + +// directPlayProfileReq describes a format the client can play directly +type directPlayProfileReq struct { + Containers []string `json:"containers,omitempty"` + AudioCodecs []string `json:"audioCodecs,omitempty"` + Protocols []string `json:"protocols,omitempty"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +// transcodingProfileReq describes a transcoding target the client supports +type transcodingProfileReq struct { + Container string `json:"container,omitempty"` + AudioCodec string `json:"audioCodec,omitempty"` + Protocol string `json:"protocol,omitempty"` + MaxAudioChannels int `json:"maxAudioChannels,omitempty"` +} + +// codecProfileReq describes codec-specific limitations +type codecProfileReq struct { + Type string `json:"type,omitempty"` + Name string `json:"name,omitempty"` + Limitations []limitationReq `json:"limitations,omitempty"` +} + +// limitationReq describes a specific codec limitation +type limitationReq struct { + Property string `json:"property,omitempty"` + Condition string `json:"condition,omitempty"` + Value string `json:"value,omitempty"` +} + +// toCore converts the API request struct to the core ClientInfo struct +func (r *clientInfoRequest) toCore() *core.ClientInfo { + ci := &core.ClientInfo{ + Name: r.Name, + Platform: r.Platform, + MaxAudioBitrate: r.MaxAudioBitrate, + MaxTranscodingAudioBitrate: r.MaxTranscodingAudioBitrate, + } + + for _, dp := range r.DirectPlayProfiles { + ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, core.DirectPlayProfile{ + Containers: dp.Containers, + AudioCodecs: dp.AudioCodecs, + Protocols: dp.Protocols, + MaxAudioChannels: dp.MaxAudioChannels, + }) + } + + for _, tp := range r.TranscodingProfiles { + ci.TranscodingProfiles = append(ci.TranscodingProfiles, core.TranscodingProfile{ + Container: tp.Container, + AudioCodec: tp.AudioCodec, + Protocol: tp.Protocol, + MaxAudioChannels: tp.MaxAudioChannels, + }) + } + + for _, cp := range r.CodecProfiles { + coreCP := core.CodecProfile{ + Type: cp.Type, + Name: cp.Name, + } + for _, lim := range cp.Limitations { + coreCP.Limitations = append(coreCP.Limitations, core.Limitation{ + Property: lim.Property, + Condition: lim.Condition, + Value: lim.Value, + }) + } + ci.CodecProfiles = append(ci.CodecProfiles, coreCP) + } + + return ci +} + +// 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(_ http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + 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") + } + + // Only support songs for now + if mediaType != "song" { + return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType) + } + + // Parse ClientInfo from request body + var clientInfoReq clientInfoRequest + if r.Body != nil { + if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil { + log.Debug(ctx, "Failed to parse client info from body", err) + // Continue with empty client info - will likely result in no compatible profile + } + } + clientInfo := clientInfoReq.toCore() + + // Get media file + mf, err := api.ds.MediaFile(ctx).Get(mediaID) + if err != nil { + return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID) + } + + // Make the decision + decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo) + if err != nil { + return nil, newError(responses.ErrorGeneric, "failed to make transcode decision: %v", err) + } + + // Create token + transcodeParams, err := api.transcodeDecision.CreateToken(decision) + if err != nil { + return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err) + } + + // Build response + response := newResponse() + response.TranscodeDecision = &responses.TranscodeDecision{ + CanDirectPlay: decision.CanDirectPlay, + CanTranscode: decision.CanTranscode, + TranscodeReasons: decision.TranscodeReasons, + ErrorReason: decision.ErrorReason, + TranscodeParams: transcodeParams, + SourceStream: &responses.StreamDetails{ + Container: decision.SourceStream.Container, + Codec: decision.SourceStream.Codec, + Bitrate: int32(decision.SourceStream.Bitrate), + SampleRate: int32(decision.SourceStream.SampleRate), + BitDepth: int32(decision.SourceStream.BitDepth), + Channels: int32(decision.SourceStream.Channels), + Duration: int32(decision.SourceStream.Duration), + Size: decision.SourceStream.Size, + IsLossless: decision.SourceStream.IsLossless, + }, + } + + if decision.TranscodeStream != nil { + response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{ + Container: decision.TranscodeStream.Container, + Codec: decision.TranscodeStream.Codec, + Bitrate: int32(decision.TranscodeStream.Bitrate), + SampleRate: int32(decision.TranscodeStream.SampleRate), + BitDepth: int32(decision.TranscodeStream.BitDepth), + Channels: int32(decision.TranscodeStream.Channels), + IsLossless: decision.TranscodeStream.IsLossless, + } + } + + return response, nil +} + +// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint. +// It streams media using the decision encoded in the transcodeParams JWT token. +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 { + 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") + } + + transcodeParams, err := p.String("transcodeParams") + if err != nil { + return nil, newError(responses.ErrorMissingParameter, "missing required parameter: transcodeParams") + } + + // Only support songs for now + if mediaType != "song" { + return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType) + } + + // Parse and validate the token + params, err := api.transcodeDecision.ParseToken(transcodeParams) + if err != nil { + log.Debug(ctx, "Failed to parse transcode token", err) + return nil, newError(responses.ErrorDataNotFound, "invalid or expired transcodeParams token") + } + + // Verify mediaId matches token + if params.MediaID != mediaID { + return nil, newError(responses.ErrorDataNotFound, "mediaId does not match token") + } + + // Determine streaming parameters + format := "" + maxBitRate := 0 + if !params.DirectPlay && params.TargetFormat != "" { + format = params.TargetFormat + maxBitRate = params.TargetBitrate + } + + // Get offset parameter + offset := p.IntOr("offset", 0) + + // Create stream + stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, offset) + if err != nil { + return nil, err + } + + // 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 +}