feat(subsonic): implement transcode decision logic and codec handling for media files

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-02-05 15:03:25 -05:00
parent e1b3412999
commit 662e3cf723
14 changed files with 805 additions and 32 deletions

View File

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

View File

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

401
core/transcode_decision.go Normal file
View File

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

View File

@ -22,6 +22,7 @@ var Set = wire.NewSet(
NewLibrary,
NewUser,
NewMaintenance,
NewTranscodeDecision,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),

View File

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

View File

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

View File

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

View File

@ -35,6 +35,7 @@ type AudioProperties struct {
BitDepth int
SampleRate int
Channels int
Codec string
}
type Date string

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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