mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(subsonic): implement transcode decision logic and codec handling for media files
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
e1b3412999
commit
662e3cf723
@ -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)
|
||||
|
||||
@ -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
401
core/transcode_decision.go
Normal 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
|
||||
}
|
||||
@ -22,6 +22,7 @@ var Set = wire.NewSet(
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
NewTranscodeDecision,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
7
db/migrations/20260205120000_add_codec_to_media_file.sql
Normal file
7
db/migrations/20260205120000_add_codec_to_media_file.sql
Normal 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
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
253
server/subsonic/transcode.go
Normal file
253
server/subsonic/transcode.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user