mirror of
https://github.com/navidrome/navidrome.git
synced 2026-03-04 06:35:52 +00:00
350 lines
12 KiB
Go
350 lines
12 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"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 {
|
|
Name string `json:"name,omitempty"`
|
|
Comparison string `json:"comparison,omitempty"`
|
|
Values []string `json:"values,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
}
|
|
|
|
// toCoreClientInfo converts the API request struct to the core.ClientInfo struct.
|
|
// The OpenSubsonic spec uses bps for bitrate values; core uses kbps.
|
|
func (r *clientInfoRequest) toCoreClientInfo() *core.ClientInfo {
|
|
ci := &core.ClientInfo{
|
|
Name: r.Name,
|
|
Platform: r.Platform,
|
|
MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate),
|
|
MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate),
|
|
}
|
|
|
|
for _, dp := range r.DirectPlayProfiles {
|
|
ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, 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 {
|
|
coreLim := core.Limitation{
|
|
Name: lim.Name,
|
|
Comparison: lim.Comparison,
|
|
Values: lim.Values,
|
|
Required: lim.Required,
|
|
}
|
|
// Convert audioBitrate limitation values from bps to kbps
|
|
if lim.Name == core.LimitationAudioBitrate {
|
|
coreLim.Values = convertBitrateValues(lim.Values)
|
|
}
|
|
coreCP.Limitations = append(coreCP.Limitations, coreLim)
|
|
}
|
|
ci.CodecProfiles = append(ci.CodecProfiles, coreCP)
|
|
}
|
|
|
|
return ci
|
|
}
|
|
|
|
// bpsToKbps converts bits per second to kilobits per second.
|
|
func bpsToKbps(bps int) int {
|
|
return bps / 1000
|
|
}
|
|
|
|
// kbpsToBps converts kilobits per second to bits per second.
|
|
func kbpsToBps(kbps int) int {
|
|
return kbps * 1000
|
|
}
|
|
|
|
// convertBitrateValues converts a slice of bps string values to kbps string values.
|
|
func convertBitrateValues(bpsValues []string) []string {
|
|
result := make([]string, len(bpsValues))
|
|
for i, v := range bpsValues {
|
|
n, err := strconv.Atoi(v)
|
|
if err == nil {
|
|
result[i] = strconv.Itoa(n / 1000)
|
|
} else {
|
|
result[i] = v // preserve unparseable values as-is
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec.
|
|
func (r *clientInfoRequest) validate() error {
|
|
for _, dp := range r.DirectPlayProfiles {
|
|
for _, p := range dp.Protocols {
|
|
if !isValidProtocol(p) {
|
|
return fmt.Errorf("invalid protocol: %s", p)
|
|
}
|
|
}
|
|
}
|
|
for _, tp := range r.TranscodingProfiles {
|
|
if tp.Protocol != "" && !isValidProtocol(tp.Protocol) {
|
|
return fmt.Errorf("invalid protocol: %s", tp.Protocol)
|
|
}
|
|
}
|
|
for _, cp := range r.CodecProfiles {
|
|
if !isValidCodecProfileType(cp.Type) {
|
|
return fmt.Errorf("invalid codec profile type: %s", cp.Type)
|
|
}
|
|
for _, lim := range cp.Limitations {
|
|
if !isValidLimitationName(lim.Name) {
|
|
return fmt.Errorf("invalid limitation name: %s", lim.Name)
|
|
}
|
|
if !isValidComparison(lim.Comparison) {
|
|
return fmt.Errorf("invalid comparison: %s", lim.Comparison)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isValidProtocol(p string) bool {
|
|
return p == core.ProtocolHTTP || p == core.ProtocolHLS
|
|
}
|
|
|
|
func isValidCodecProfileType(t string) bool {
|
|
return t == core.CodecProfileTypeAudio
|
|
}
|
|
|
|
func isValidLimitationName(n string) bool {
|
|
return n == core.LimitationAudioChannels ||
|
|
n == core.LimitationAudioBitrate ||
|
|
n == core.LimitationAudioProfile ||
|
|
n == core.LimitationAudioSamplerate ||
|
|
n == core.LimitationAudioBitdepth
|
|
}
|
|
|
|
func isValidComparison(c string) bool {
|
|
return c == core.ComparisonEquals ||
|
|
c == core.ComparisonNotEquals ||
|
|
c == core.ComparisonLessThanEqual ||
|
|
c == core.ComparisonGreaterThanEqual
|
|
}
|
|
|
|
// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint.
|
|
// It receives client capabilities and returns a decision on whether to direct play or transcode.
|
|
func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
|
return nil, nil
|
|
}
|
|
|
|
ctx := r.Context()
|
|
p := req.Params(r)
|
|
|
|
mediaID, err := p.String("mediaId")
|
|
if err != nil {
|
|
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
|
|
}
|
|
|
|
mediaType, err := p.String("mediaType")
|
|
if err != nil {
|
|
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
|
|
}
|
|
|
|
// Only support songs for now
|
|
if mediaType != "song" {
|
|
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
|
|
}
|
|
|
|
// Parse and validate ClientInfo from request body (required per OpenSubsonic spec)
|
|
var clientInfoReq clientInfoRequest
|
|
if r.Body == nil {
|
|
return nil, newError(responses.ErrorMissingParameter, "missing required JSON request body")
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil {
|
|
return nil, newError(responses.ErrorGeneric, "invalid JSON request body")
|
|
}
|
|
if err := clientInfoReq.validate(); err != nil {
|
|
return nil, newError(responses.ErrorGeneric, "%v", err)
|
|
}
|
|
clientInfo := clientInfoReq.toCoreClientInfo()
|
|
|
|
// Get media file
|
|
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
|
|
if err != nil {
|
|
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 transcode params token
|
|
transcodeParams, err := api.transcodeDecision.CreateTranscodeParams(decision)
|
|
if err != nil {
|
|
return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err)
|
|
}
|
|
|
|
// Build response (convert kbps from core to bps for the API)
|
|
response := newResponse()
|
|
response.TranscodeDecision = &responses.TranscodeDecision{
|
|
CanDirectPlay: decision.CanDirectPlay,
|
|
CanTranscode: decision.CanTranscode,
|
|
TranscodeReasons: decision.TranscodeReasons,
|
|
ErrorReason: decision.ErrorReason,
|
|
TranscodeParams: transcodeParams,
|
|
SourceStream: &responses.StreamDetails{
|
|
Protocol: "http",
|
|
Container: decision.SourceStream.Container,
|
|
Codec: decision.SourceStream.Codec,
|
|
AudioBitrate: int32(kbpsToBps(decision.SourceStream.Bitrate)),
|
|
AudioProfile: decision.SourceStream.Profile,
|
|
AudioSamplerate: int32(decision.SourceStream.SampleRate),
|
|
AudioBitdepth: int32(decision.SourceStream.BitDepth),
|
|
AudioChannels: int32(decision.SourceStream.Channels),
|
|
},
|
|
}
|
|
|
|
if decision.TranscodeStream != nil {
|
|
response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{
|
|
Protocol: "http",
|
|
Container: decision.TranscodeStream.Container,
|
|
Codec: decision.TranscodeStream.Codec,
|
|
AudioBitrate: int32(kbpsToBps(decision.TranscodeStream.Bitrate)),
|
|
AudioProfile: decision.TranscodeStream.Profile,
|
|
AudioSamplerate: int32(decision.TranscodeStream.SampleRate),
|
|
AudioBitdepth: int32(decision.TranscodeStream.BitDepth),
|
|
AudioChannels: int32(decision.TranscodeStream.Channels),
|
|
}
|
|
}
|
|
|
|
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.ParseTranscodeParams(transcodeParams)
|
|
if err != nil {
|
|
log.Warn(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 // Already in kbps, matching the streamer
|
|
}
|
|
|
|
// 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
|
|
}
|