refactor: rename core/transcode to core/stream, simplify MediaStreamer (#5166)
* refactor: rename core/transcode directory to core/stream * refactor: update all imports from core/transcode to core/stream * refactor: rename exported symbols to fit core/stream package name * refactor: simplify MediaStreamer interface to single NewStream method Remove the two-method interface (NewStream + DoStream) in favor of a single NewStream(ctx, mf, req) method. Callers are now responsible for fetching the MediaFile before calling NewStream. This removes the implicit DB lookup from the streamer, making it a pure streaming concern. * refactor: update all callers from DoStream to NewStream * chore: update wire_gen.go and stale comment for core/stream rename * refactor: update wire command to handle GO_BUILD_TAGS correctly Signed-off-by: Deluan <deluan@navidrome.org> * fix: distinguish not-found from internal errors in public stream handler * refactor: remove unused ID field from stream.Request * refactor: simplify ResolveRequestFromToken to receive *model.MediaFile Move MediaFile fetching responsibility to callers, making the method focused on token validation and request resolution. Remove ErrMediaNotFound (no longer produced). Update GetTranscodeStream handler to fetch the media file before calling ResolveRequestFromToken. * refactor: extend tokenTTL from 12 to 48 hours Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// containerAliasGroups maps each container alias to a canonical group name.
|
||||
var containerAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
|
||||
{"mpeg", "mp3", "mp2"},
|
||||
{"ogg", "oga", "opus"},
|
||||
{"aif", "aiff"},
|
||||
{"asf", "wma"},
|
||||
{"mpc", "mpp"},
|
||||
{"wv"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
canonical := g[0]
|
||||
for _, name := range g {
|
||||
m[name] = canonical
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// codecAliasGroups maps each codec alias to a canonical group name.
|
||||
// Codecs within the same group are considered equivalent.
|
||||
var codecAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts"},
|
||||
{"ac3", "ac-3"},
|
||||
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
|
||||
{"mpc7", "musepack7"},
|
||||
{"mpc8", "musepack8"},
|
||||
{"wma1", "wmav1"},
|
||||
{"wma2", "wmav2"},
|
||||
{"wmalossless", "wma9lossless"},
|
||||
{"wmapro", "wma9pro"},
|
||||
{"shn", "shorten"},
|
||||
{"mp4als", "als"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
for _, name := range g {
|
||||
m[name] = g[0] // canonical = first entry
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// matchesWithAliases checks if a value matches any entry in candidates,
|
||||
// consulting the alias map for equivalent names.
|
||||
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
|
||||
value = strings.ToLower(value)
|
||||
canonical := aliases[value]
|
||||
for _, c := range candidates {
|
||||
c = strings.ToLower(c)
|
||||
if c == value {
|
||||
return true
|
||||
}
|
||||
if canonical != "" && aliases[c] == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer checks if a file suffix matches any of the container names,
|
||||
// including common aliases.
|
||||
func matchesContainer(suffix string, containers []string) bool {
|
||||
return matchesWithAliases(suffix, containers, containerAliasGroups)
|
||||
}
|
||||
|
||||
// matchesCodec checks if a codec matches any of the codec names,
|
||||
// including common aliases.
|
||||
func matchesCodec(codec string, codecs []string) bool {
|
||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||
}
|
||||
|
||||
func containsIgnoreCase(slice []string, s string) bool {
|
||||
return slices.ContainsFunc(slice, func(item string) bool {
|
||||
return strings.EqualFold(item, s)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package stream
|
||||
|
||||
import "strings"
|
||||
|
||||
// normalizeProbeCodec maps ffprobe codec_name values to the simplified internal
|
||||
// codec names used throughout Navidrome (matching inferCodecFromSuffix output).
|
||||
// Most ffprobe names match directly; this handles the exceptions.
|
||||
func normalizeProbeCodec(codec string) string {
|
||||
c := strings.ToLower(codec)
|
||||
// DSD variants: dsd_lsbf_planar, dsd_msbf_planar, dsd_lsbf, dsd_msbf
|
||||
if strings.HasPrefix(c, "dsd") {
|
||||
return "dsd"
|
||||
}
|
||||
// PCM variants: pcm_s16le, pcm_s24le, pcm_s32be, pcm_f32le, etc.
|
||||
if strings.HasPrefix(c, "pcm_") {
|
||||
return "pcm"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// isLosslessFormat returns true if the format is a known lossless audio codec/format.
|
||||
// Detection is based on codec name only, not bit depth — some lossy codecs (e.g. ADPCM)
|
||||
// report non-zero bits_per_sample in ffprobe, so bit depth alone is not a reliable signal.
|
||||
//
|
||||
// Note: core/ffmpeg has a separate isLosslessOutputFormat that covers only formats
|
||||
// ffmpeg can produce as output (a smaller set).
|
||||
func isLosslessFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff", "ape", "wv", "wavpack", "tta", "tak", "shn", "dsd", "pcm":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeSourceSampleRate adjusts the source sample rate for codecs that store
|
||||
// it differently than PCM. Currently handles DSD (÷8):
|
||||
// DSD64=2822400→352800, DSD128=5644800→705600, etc.
|
||||
// For other codecs, returns the rate unchanged.
|
||||
func normalizeSourceSampleRate(sampleRate int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && sampleRate > 0 {
|
||||
return sampleRate / 8
|
||||
}
|
||||
return sampleRate
|
||||
}
|
||||
|
||||
// normalizeSourceBitDepth adjusts the source bit depth for codecs that use
|
||||
// non-standard bit depths. Currently handles DSD (1-bit → 24-bit PCM, which is
|
||||
// what ffmpeg produces). For other codecs, returns the depth unchanged.
|
||||
func normalizeSourceBitDepth(bitDepth int, codec string) int {
|
||||
if strings.EqualFold(codec, "dsd") && bitDepth == 1 {
|
||||
return 24
|
||||
}
|
||||
return bitDepth
|
||||
}
|
||||
|
||||
// codecFixedOutputSampleRate returns the mandatory output sample rate for codecs
|
||||
// that always resample regardless of input (e.g., Opus always outputs 48000Hz).
|
||||
// Returns 0 if the codec has no fixed output rate.
|
||||
func codecFixedOutputSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "opus":
|
||||
return 48000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// codecMaxSampleRate returns the hard maximum output sample rate for a codec.
|
||||
// Returns 0 if the codec has no hard limit.
|
||||
func codecMaxSampleRate(codec string) int {
|
||||
switch strings.ToLower(codec) {
|
||||
case "mp3":
|
||||
return 48000
|
||||
case "aac":
|
||||
return 96000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Codec", func() {
|
||||
Describe("isLosslessFormat", func() {
|
||||
It("returns true for known lossless codecs", func() {
|
||||
Expect(isLosslessFormat("flac")).To(BeTrue())
|
||||
Expect(isLosslessFormat("alac")).To(BeTrue())
|
||||
Expect(isLosslessFormat("pcm")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wav")).To(BeTrue())
|
||||
Expect(isLosslessFormat("dsd")).To(BeTrue())
|
||||
Expect(isLosslessFormat("ape")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wv")).To(BeTrue())
|
||||
Expect(isLosslessFormat("wavpack")).To(BeTrue()) // ffprobe codec_name for WavPack
|
||||
})
|
||||
|
||||
It("returns false for lossy codecs", func() {
|
||||
Expect(isLosslessFormat("mp3")).To(BeFalse())
|
||||
Expect(isLosslessFormat("aac")).To(BeFalse())
|
||||
Expect(isLosslessFormat("opus")).To(BeFalse())
|
||||
Expect(isLosslessFormat("vorbis")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns false for unknown codecs", func() {
|
||||
Expect(isLosslessFormat("unknown_codec")).To(BeFalse())
|
||||
})
|
||||
|
||||
It("is case-insensitive", func() {
|
||||
Expect(isLosslessFormat("FLAC")).To(BeTrue())
|
||||
Expect(isLosslessFormat("Alac")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("normalizeProbeCodec", func() {
|
||||
It("passes through common codec names unchanged", func() {
|
||||
Expect(normalizeProbeCodec("mp3")).To(Equal("mp3"))
|
||||
Expect(normalizeProbeCodec("aac")).To(Equal("aac"))
|
||||
Expect(normalizeProbeCodec("flac")).To(Equal("flac"))
|
||||
Expect(normalizeProbeCodec("opus")).To(Equal("opus"))
|
||||
Expect(normalizeProbeCodec("vorbis")).To(Equal("vorbis"))
|
||||
Expect(normalizeProbeCodec("alac")).To(Equal("alac"))
|
||||
Expect(normalizeProbeCodec("wmav2")).To(Equal("wmav2"))
|
||||
})
|
||||
|
||||
It("normalizes DSD variants to dsd", func() {
|
||||
Expect(normalizeProbeCodec("dsd_lsbf_planar")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_msbf_planar")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_lsbf")).To(Equal("dsd"))
|
||||
Expect(normalizeProbeCodec("dsd_msbf")).To(Equal("dsd"))
|
||||
})
|
||||
|
||||
It("normalizes PCM variants to pcm", func() {
|
||||
Expect(normalizeProbeCodec("pcm_s16le")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_s24le")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_s32be")).To(Equal("pcm"))
|
||||
Expect(normalizeProbeCodec("pcm_f32le")).To(Equal("pcm"))
|
||||
})
|
||||
|
||||
It("lowercases input", func() {
|
||||
Expect(normalizeProbeCodec("MP3")).To(Equal("mp3"))
|
||||
Expect(normalizeProbeCodec("AAC")).To(Equal("aac"))
|
||||
Expect(normalizeProbeCodec("DSD_LSBF_PLANAR")).To(Equal("dsd"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,449 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
const fallbackBitrate = 256 // kbps
|
||||
|
||||
// TranscodeDecider is the core service interface for making transcoding decisions
|
||||
type TranscodeDecider interface {
|
||||
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error)
|
||||
CreateTranscodeParams(decision *TranscodeDecision) (string, error)
|
||||
ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error)
|
||||
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request
|
||||
}
|
||||
|
||||
func NewTranscodeDecider(ds model.DataStore, ff ffmpeg.FFmpeg) TranscodeDecider {
|
||||
return &deciderService{
|
||||
ds: ds,
|
||||
ff: ff,
|
||||
}
|
||||
}
|
||||
|
||||
type deciderService struct {
|
||||
ds model.DataStore
|
||||
ff ffmpeg.FFmpeg
|
||||
}
|
||||
|
||||
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts TranscodeOptions) (*TranscodeDecision, error) {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: mf.ID,
|
||||
SourceUpdatedAt: mf.UpdatedAt,
|
||||
}
|
||||
|
||||
var probe *ffmpeg.AudioProbeResult
|
||||
if !opts.SkipProbe {
|
||||
var err error
|
||||
probe, err = s.ensureProbed(ctx, mf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Build source stream details (uses probe data if available)
|
||||
decision.SourceStream = buildSourceStream(mf, probe)
|
||||
src := &decision.SourceStream
|
||||
|
||||
// Check for server-side player transcoding override
|
||||
if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" {
|
||||
clientInfo = applyServerOverride(ctx, clientInfo, &trc)
|
||||
} else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||
if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate {
|
||||
modified := *clientInfo
|
||||
modified.MaxAudioBitrate = player.MaxBitRate
|
||||
clientInfo = &modified
|
||||
log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name)
|
||||
}
|
||||
}
|
||||
|
||||
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container,
|
||||
"codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels,
|
||||
"sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name)
|
||||
|
||||
// Check global bitrate constraint first.
|
||||
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
||||
"sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, "audio bitrate not supported")
|
||||
// Skip direct play profiles entirely — global constraint fails
|
||||
} else {
|
||||
// Try direct play profiles, collecting reasons for each failure
|
||||
for _, profile := range clientInfo.DirectPlayProfiles {
|
||||
if reason := s.checkDirectPlayProfile(src, &profile, clientInfo); reason == "" {
|
||||
decision.CanDirectPlay = true
|
||||
decision.TranscodeReasons = nil // Clear any previously collected reasons
|
||||
break
|
||||
} else {
|
||||
decision.TranscodeReasons = append(decision.TranscodeReasons, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If direct play is possible, we're done
|
||||
if decision.CanDirectPlay {
|
||||
log.Debug(ctx, "Transcode decision: direct play", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec)
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// Try transcoding profiles (in order of preference)
|
||||
for _, profile := range clientInfo.TranscodingProfiles {
|
||||
if ts, transcodeFormat := s.computeTranscodedStream(ctx, src, &profile, clientInfo); ts != nil {
|
||||
decision.CanTranscode = true
|
||||
decision.TargetFormat = transcodeFormat
|
||||
decision.TargetBitrate = ts.Bitrate
|
||||
decision.TargetChannels = ts.Channels
|
||||
decision.TargetSampleRate = ts.SampleRate
|
||||
decision.TargetBitDepth = ts.BitDepth
|
||||
decision.TranscodeStream = ts
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
log.Debug(ctx, "Transcode decision: transcode", "mediaID", mf.ID,
|
||||
"targetFormat", decision.TargetFormat, "targetBitrate", decision.TargetBitrate,
|
||||
"targetChannels", decision.TargetChannels, "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
// If neither direct play nor transcode is possible
|
||||
if !decision.CanDirectPlay && !decision.CanTranscode {
|
||||
decision.ErrorReason = "no compatible playback profile found"
|
||||
log.Warn(ctx, "Transcode decision: no compatible profile", "mediaID", mf.ID,
|
||||
"container", src.Container, "codec", src.Codec, "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
func buildSourceStream(mf *model.MediaFile, probe *ffmpeg.AudioProbeResult) Details {
|
||||
sd := Details{
|
||||
Container: mf.Suffix,
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
}
|
||||
|
||||
// Use pre-parsed probe result, or fall back to parsing stored probe data
|
||||
if probe == nil {
|
||||
probe, _ = parseProbeData(mf.ProbeData)
|
||||
}
|
||||
|
||||
// Use probe data if available for authoritative values
|
||||
if probe != nil {
|
||||
sd.Codec = normalizeProbeCodec(probe.Codec)
|
||||
sd.Profile = probe.Profile
|
||||
sd.Bitrate = probe.BitRate
|
||||
sd.SampleRate = probe.SampleRate
|
||||
sd.BitDepth = probe.BitDepth
|
||||
sd.Channels = probe.Channels
|
||||
} else {
|
||||
sd.Codec = mf.AudioCodec()
|
||||
sd.Bitrate = mf.BitRate
|
||||
sd.SampleRate = mf.SampleRate
|
||||
sd.BitDepth = mf.BitDepth
|
||||
sd.Channels = mf.Channels
|
||||
}
|
||||
sd.IsLossless = isLosslessFormat(sd.Codec)
|
||||
|
||||
return sd
|
||||
}
|
||||
|
||||
// applyServerOverride replaces the client-provided profiles with synthetic ones
|
||||
// matching the server-forced transcoding format and bitrate.
|
||||
func applyServerOverride(ctx context.Context, original *ClientInfo, trc *model.Transcoding) *ClientInfo {
|
||||
maxBitRate := trc.DefaultBitRate
|
||||
if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 {
|
||||
maxBitRate = player.MaxBitRate
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Applying server-side transcoding override",
|
||||
"targetFormat", trc.TargetFormat, "maxBitRate", maxBitRate,
|
||||
"client", original.Name)
|
||||
|
||||
return &ClientInfo{
|
||||
Name: original.Name,
|
||||
Platform: original.Platform,
|
||||
MaxAudioBitrate: maxBitRate,
|
||||
MaxTranscodingAudioBitrate: maxBitRate,
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{trc.TargetFormat}, AudioCodecs: []string{trc.TargetFormat}, Protocols: []string{ProtocolHTTP}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: trc.TargetFormat, AudioCodec: trc.TargetFormat, Protocol: ProtocolHTTP},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func parseProbeData(data string) (*ffmpeg.AudioProbeResult, error) {
|
||||
if data == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var result ffmpeg.AudioProbeResult
|
||||
if err := json.Unmarshal([]byte(data), &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||
// or a typed reason string if it doesn't match.
|
||||
func (s *deciderService) checkDirectPlayProfile(src *Details, profile *DirectPlayProfile, clientInfo *ClientInfo) string {
|
||||
// Check protocol (only http for now)
|
||||
if len(profile.Protocols) > 0 && !containsIgnoreCase(profile.Protocols, ProtocolHTTP) {
|
||||
return "protocol not supported"
|
||||
}
|
||||
|
||||
// Check container
|
||||
if len(profile.Containers) > 0 && !matchesContainer(src.Container, profile.Containers) {
|
||||
return "container not supported"
|
||||
}
|
||||
|
||||
// Check codec
|
||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) {
|
||||
return "audio codec not supported"
|
||||
}
|
||||
|
||||
// Check channels
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
return "audio channels not supported"
|
||||
}
|
||||
|
||||
// Check codec-specific limitations
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(src.Codec, []string{codecProfile.Name}) {
|
||||
if reason := checkLimitations(src, codecProfile.Limitations); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
|
||||
// Returns the stream details and the internal transcoding format (which may differ from the
|
||||
// response container when a codec fallback occurs, e.g., "mp4"→"aac").
|
||||
// Returns nil, "" if the profile cannot produce a valid output.
|
||||
func (s *deciderService) computeTranscodedStream(ctx context.Context, src *Details, profile *Profile, clientInfo *ClientInfo) (*Details, string) {
|
||||
// Check protocol (only http for now)
|
||||
if profile.Protocol != "" && !strings.EqualFold(profile.Protocol, ProtocolHTTP) {
|
||||
log.Trace(ctx, "Skipping transcoding profile: unsupported protocol", "protocol", profile.Protocol)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
responseContainer, targetFormat := resolveTargetFormat(profile)
|
||||
if targetFormat == "" {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Verify we have a transcoding command available (DB custom or built-in default)
|
||||
if LookupTranscodeCommand(ctx, s.ds, targetFormat) == "" {
|
||||
log.Trace(ctx, "Skipping transcoding profile: no transcoding command available", "targetFormat", targetFormat)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
targetIsLossless := isLosslessFormat(targetFormat)
|
||||
|
||||
// Reject lossy to lossless conversion
|
||||
if !src.IsLossless && targetIsLossless {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
ts := &Details{
|
||||
Container: responseContainer,
|
||||
Codec: strings.ToLower(profile.AudioCodec),
|
||||
SampleRate: normalizeSourceSampleRate(src.SampleRate, src.Codec),
|
||||
Channels: src.Channels,
|
||||
BitDepth: normalizeSourceBitDepth(src.BitDepth, src.Codec),
|
||||
IsLossless: targetIsLossless,
|
||||
}
|
||||
if ts.Codec == "" {
|
||||
ts.Codec = targetFormat
|
||||
}
|
||||
|
||||
// Apply codec-intrinsic sample rate adjustments before codec profile limitations
|
||||
if fixedRate := codecFixedOutputSampleRate(ts.Codec); fixedRate > 0 {
|
||||
ts.SampleRate = fixedRate
|
||||
}
|
||||
if maxRate := codecMaxSampleRate(ts.Codec); maxRate > 0 && ts.SampleRate > maxRate {
|
||||
ts.SampleRate = maxRate
|
||||
}
|
||||
|
||||
// Determine target bitrate (all in kbps)
|
||||
if ok := s.computeBitrate(ctx, src, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Apply MaxAudioChannels from the transcoding profile
|
||||
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
|
||||
ts.Channels = profile.MaxAudioChannels
|
||||
}
|
||||
|
||||
// Apply codec profile limitations to the TARGET codec
|
||||
if ok := s.applyCodecLimitations(ctx, src.Bitrate, targetFormat, targetIsLossless, clientInfo, ts); !ok {
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
return ts, targetFormat
|
||||
}
|
||||
|
||||
// lookupDefaultBitrate returns the default bitrate for the given format.
|
||||
// It checks the DB first (for user-customized values), then falls back to
|
||||
// the built-in defaults, and finally to fallbackBitrate.
|
||||
func lookupDefaultBitrate(ctx context.Context, ds model.DataStore, format string) int {
|
||||
if t, err := ds.Transcoding(ctx).FindByFormat(format); err == nil && t.DefaultBitRate > 0 {
|
||||
return t.DefaultBitRate
|
||||
}
|
||||
for _, dt := range consts.DefaultTranscodings {
|
||||
if dt.TargetFormat == format && dt.DefaultBitRate > 0 {
|
||||
return dt.DefaultBitRate
|
||||
}
|
||||
}
|
||||
return fallbackBitrate
|
||||
}
|
||||
|
||||
// LookupTranscodeCommand returns the ffmpeg command for the given format.
|
||||
// It checks the DB first (for user-customized commands), then falls back to
|
||||
// the built-in default command. Returns "" if the format is unknown.
|
||||
func LookupTranscodeCommand(ctx context.Context, ds model.DataStore, format string) string {
|
||||
t, err := ds.Transcoding(ctx).FindByFormat(format)
|
||||
if err == nil && t.Command != "" {
|
||||
return t.Command
|
||||
}
|
||||
// Fall back to built-in defaults
|
||||
for _, dt := range consts.DefaultTranscodings {
|
||||
if dt.TargetFormat == format {
|
||||
return dt.Command
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveTargetFormat determines the response container and internal target format
|
||||
// from the profile's Container and AudioCodec fields. When an AudioCodec is specified
|
||||
// it is preferred as targetFormat (e.g. container "mp4" with audioCodec "aac" → targetFormat "aac").
|
||||
func resolveTargetFormat(profile *Profile) (responseContainer, targetFormat string) {
|
||||
responseContainer = strings.ToLower(profile.Container)
|
||||
targetFormat = responseContainer
|
||||
|
||||
// Prefer the audioCodec as targetFormat when provided (handles container-to-codec
|
||||
// mapping like "mp4" → "aac", "ogg" → "opus").
|
||||
if profile.AudioCodec != "" {
|
||||
targetFormat = strings.ToLower(profile.AudioCodec)
|
||||
}
|
||||
|
||||
// If neither container nor audioCodec is set, we can't resolve a format.
|
||||
if targetFormat == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// When no container was specified, use the targetFormat as container too.
|
||||
if responseContainer == "" {
|
||||
responseContainer = targetFormat
|
||||
}
|
||||
|
||||
return responseContainer, targetFormat
|
||||
}
|
||||
|
||||
// computeBitrate determines the target bitrate for the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) computeBitrate(ctx context.Context, src *Details, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
|
||||
if src.IsLossless {
|
||||
if !targetIsLossless {
|
||||
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
||||
} else if clientInfo.MaxAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
} else {
|
||||
ts.Bitrate = lookupDefaultBitrate(ctx, s.ds, targetFormat)
|
||||
}
|
||||
} else {
|
||||
if clientInfo.MaxAudioBitrate > 0 && src.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
|
||||
"targetFormat", targetFormat, "sourceBitrate", src.Bitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ts.Bitrate = src.Bitrate
|
||||
}
|
||||
|
||||
// Apply maxAudioBitrate as final cap
|
||||
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// applyCodecLimitations applies codec profile limitations to the transcoded stream.
|
||||
// Returns false if the profile should be rejected.
|
||||
func (s *deciderService) applyCodecLimitations(ctx context.Context, sourceBitrate int, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *Details) bool {
|
||||
targetCodec := ts.Codec
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if !strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) {
|
||||
continue
|
||||
}
|
||||
if !matchesCodec(targetCodec, []string{codecProfile.Name}) {
|
||||
continue
|
||||
}
|
||||
for _, lim := range codecProfile.Limitations {
|
||||
result := applyLimitation(sourceBitrate, &lim, ts)
|
||||
if strings.EqualFold(lim.Name, LimitationAudioBitrate) && targetIsLossless && result == adjustAdjusted {
|
||||
log.Trace(ctx, "Skipping transcoding profile: cannot adjust bitrate for lossless target",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name)
|
||||
return false
|
||||
}
|
||||
if result == adjustCannotFit {
|
||||
log.Trace(ctx, "Skipping transcoding profile: codec limitation cannot be satisfied",
|
||||
"targetFormat", targetFormat, "codec", targetCodec, "limitation", lim.Name,
|
||||
"comparison", lim.Comparison, "values", lim.Values)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ensureProbed runs ffprobe if probe data is missing, persists it, and returns
|
||||
// the parsed result. Returns (nil, nil) when probing is skipped or data already exists
|
||||
// (in which case the caller should parse mf.ProbeData).
|
||||
func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile) (*ffmpeg.AudioProbeResult, error) {
|
||||
if mf.ProbeData != "" {
|
||||
return nil, nil
|
||||
}
|
||||
if !conf.Server.DevEnableMediaFileProbe {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, err := s.ff.ProbeAudioStream(ctx, mf.AbsolutePath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probing media file %s: %w", mf.ID, err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshaling probe result for %s: %w", mf.ID, err)
|
||||
}
|
||||
mf.ProbeData = string(data)
|
||||
|
||||
if err := s.ds.MediaFile(ctx).UpdateProbeData(mf.ID, mf.ProbeData); err != nil {
|
||||
log.Error(ctx, "Failed to persist probe data", "mediaID", mf.ID, err)
|
||||
// Don't fail the decision — we have the data in memory
|
||||
}
|
||||
|
||||
log.Debug(ctx, "Probed media file", "mediaID", mf.ID, "codec", result.Codec,
|
||||
"profile", result.Profile, "bitRate", result.BitRate,
|
||||
"sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
|
||||
return result, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// buildLegacyClientInfo translates legacy Subsonic stream/download parameters
|
||||
// into a ClientInfo for use with MakeDecision.
|
||||
// It does NOT read request.TranscodingFrom(ctx) — that is handled by
|
||||
// MakeDecision's applyServerOverride.
|
||||
func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo {
|
||||
ci := &ClientInfo{Name: "legacy"}
|
||||
|
||||
// Determine target format for transcoding
|
||||
var targetFormat string
|
||||
switch {
|
||||
case reqFormat != "":
|
||||
targetFormat = reqFormat
|
||||
case reqBitRate > 0 && reqBitRate < mf.BitRate && conf.Server.DefaultDownsamplingFormat != "":
|
||||
targetFormat = conf.Server.DefaultDownsamplingFormat
|
||||
}
|
||||
|
||||
if targetFormat != "" {
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Containers: []string{mf.Suffix}, AudioCodecs: []string{mf.AudioCodec()}, Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
ci.TranscodingProfiles = []Profile{
|
||||
{Container: targetFormat, AudioCodec: targetFormat, Protocol: ProtocolHTTP},
|
||||
}
|
||||
if reqBitRate > 0 {
|
||||
ci.MaxAudioBitrate = reqBitRate
|
||||
ci.MaxTranscodingAudioBitrate = reqBitRate
|
||||
}
|
||||
} else {
|
||||
// No transcoding requested — direct play everything
|
||||
ci.DirectPlayProfiles = []DirectPlayProfile{
|
||||
{Protocols: []string{ProtocolHTTP}},
|
||||
}
|
||||
}
|
||||
|
||||
return ci
|
||||
}
|
||||
|
||||
// ResolveRequest uses MakeDecision to resolve legacy Subsonic stream parameters
|
||||
// into a fully specified Request.
|
||||
func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) Request {
|
||||
var req Request
|
||||
req.Offset = offset
|
||||
|
||||
if reqFormat == "raw" {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
|
||||
decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err)
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanDirectPlay {
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
|
||||
if decision.CanTranscode {
|
||||
req.Format = decision.TargetFormat
|
||||
req.BitRate = decision.TargetBitrate
|
||||
req.SampleRate = decision.TargetSampleRate
|
||||
req.BitDepth = decision.TargetBitDepth
|
||||
req.Channels = decision.TargetChannels
|
||||
return req
|
||||
}
|
||||
|
||||
// No compatible profile — fallback to raw
|
||||
req.Format = "raw"
|
||||
return req
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("buildLegacyClientInfo", func() {
|
||||
var mf *model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
mf = &model.MediaFile{Suffix: "flac", BitRate: 960}
|
||||
})
|
||||
|
||||
It("sets transcoding profile for explicit format without bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 0)
|
||||
|
||||
Expect(ci.Name).To(Equal("legacy"))
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
|
||||
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(BeZero())
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
})
|
||||
|
||||
It("sets transcoding profile and bitrate for explicit format with bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "mp3", 192)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("mp3"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("mp3"))
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(192))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(192))
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
|
||||
})
|
||||
|
||||
It("returns direct play profile when no format and no bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "", 0)
|
||||
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
|
||||
It("uses default downsampling format for bitrate-only downsampling", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultDownsamplingFormat = "opus"
|
||||
|
||||
ci := buildLegacyClientInfo(mf, "", 128)
|
||||
|
||||
Expect(ci.TranscodingProfiles).To(HaveLen(1))
|
||||
Expect(ci.TranscodingProfiles[0].Container).To(Equal("opus"))
|
||||
Expect(ci.TranscodingProfiles[0].AudioCodec).To(Equal("opus"))
|
||||
Expect(ci.TranscodingProfiles[0].Protocol).To(Equal(ProtocolHTTP))
|
||||
Expect(ci.MaxAudioBitrate).To(Equal(128))
|
||||
Expect(ci.MaxTranscodingAudioBitrate).To(Equal(128))
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(Equal([]string{"flac"}))
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(Equal([]string{mf.AudioCodec()}))
|
||||
})
|
||||
|
||||
It("returns direct play when bitrate >= source bitrate", func() {
|
||||
ci := buildLegacyClientInfo(mf, "", 960)
|
||||
|
||||
Expect(ci.DirectPlayProfiles).To(HaveLen(1))
|
||||
Expect(ci.DirectPlayProfiles[0].Containers).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].AudioCodecs).To(BeEmpty())
|
||||
Expect(ci.DirectPlayProfiles[0].Protocols).To(Equal([]string{ProtocolHTTP}))
|
||||
Expect(ci.TranscodingProfiles).To(BeEmpty())
|
||||
Expect(ci.MaxAudioBitrate).To(BeZero())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
|
||||
type adjustResult int
|
||||
|
||||
const (
|
||||
adjustNone adjustResult = iota // Value already satisfies the limitation
|
||||
adjustAdjusted // Value was changed to fit the limitation
|
||||
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
|
||||
)
|
||||
|
||||
// checkLimitations checks codec profile limitations against source stream details.
|
||||
// Returns "" if all limitations pass, or a typed reason string for the first failure.
|
||||
func checkLimitations(src *Details, limitations []Limitation) string {
|
||||
for _, lim := range limitations {
|
||||
var ok bool
|
||||
var reason string
|
||||
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
ok = checkIntLimitation(src.Channels, lim.Comparison, lim.Values)
|
||||
reason = "audio channels not supported"
|
||||
case LimitationAudioSamplerate:
|
||||
ok = checkIntLimitation(src.SampleRate, lim.Comparison, lim.Values)
|
||||
reason = "audio samplerate not supported"
|
||||
case LimitationAudioBitrate:
|
||||
ok = checkIntLimitation(src.Bitrate, lim.Comparison, lim.Values)
|
||||
reason = "audio bitrate not supported"
|
||||
case LimitationAudioBitdepth:
|
||||
ok = checkIntLimitation(src.BitDepth, lim.Comparison, lim.Values)
|
||||
reason = "audio bitdepth not supported"
|
||||
case LimitationAudioProfile:
|
||||
ok = checkStringLimitation(src.Profile, lim.Comparison, lim.Values)
|
||||
reason = "audio profile not supported"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if !ok && lim.Required {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
|
||||
// Returns the adjustment result.
|
||||
func applyLimitation(sourceBitrate int, lim *Limitation, ts *Details) adjustResult {
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
|
||||
case LimitationAudioBitrate:
|
||||
current := ts.Bitrate
|
||||
if current == 0 {
|
||||
current = sourceBitrate
|
||||
}
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
|
||||
case LimitationAudioSamplerate:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
|
||||
case LimitationAudioBitdepth:
|
||||
if ts.BitDepth > 0 {
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
|
||||
}
|
||||
case LimitationAudioProfile:
|
||||
// TODO: implement when audio profile data is available
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
// applyIntLimitation applies a limitation comparison to a value.
|
||||
// If the value needs adjusting, calls the setter and returns the result.
|
||||
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
|
||||
if len(values) == 0 {
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current <= limit {
|
||||
return adjustNone
|
||||
}
|
||||
setter(limit)
|
||||
return adjustAdjusted
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current >= limit {
|
||||
return adjustNone
|
||||
}
|
||||
// Cannot upscale
|
||||
return adjustCannotFit
|
||||
case ComparisonEquals:
|
||||
// Check if current value matches any allowed value
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustNone
|
||||
}
|
||||
}
|
||||
// Find the closest allowed value below current (don't upscale)
|
||||
var closest int
|
||||
found := false
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && limit < current {
|
||||
if !found || limit > closest {
|
||||
closest = limit
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
setter(closest)
|
||||
return adjustAdjusted
|
||||
}
|
||||
return adjustCannotFit
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustCannotFit
|
||||
}
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
func checkIntLimitation(value int, comparison string, values []string) bool {
|
||||
return applyIntLimitation(comparison, values, value, func(int) {}) == adjustNone
|
||||
}
|
||||
|
||||
// checkStringLimitation checks a string value against a limitation.
|
||||
// Only Equals and NotEquals comparisons are meaningful for strings.
|
||||
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
|
||||
func checkStringLimitation(value string, comparison string, values []string) bool {
|
||||
switch comparison {
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, bool) {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
)
|
||||
|
||||
type MediaStreamer interface {
|
||||
NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error)
|
||||
}
|
||||
|
||||
type TranscodingCache cache.FileCache
|
||||
|
||||
func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer {
|
||||
return &mediaStreamer{ds: ds, transcoder: t, cache: cache}
|
||||
}
|
||||
|
||||
type mediaStreamer struct {
|
||||
ds model.DataStore
|
||||
transcoder ffmpeg.FFmpeg
|
||||
cache cache.FileCache
|
||||
}
|
||||
|
||||
type streamJob struct {
|
||||
ms *mediaStreamer
|
||||
mf *model.MediaFile
|
||||
filePath string
|
||||
format string
|
||||
bitRate int
|
||||
sampleRate int
|
||||
bitDepth int
|
||||
channels int
|
||||
offset int
|
||||
}
|
||||
|
||||
func (j *streamJob) Key() string {
|
||||
return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset)
|
||||
}
|
||||
|
||||
func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) {
|
||||
var format string
|
||||
var bitRate int
|
||||
var cached bool
|
||||
defer func() {
|
||||
log.Info(ctx, "Streaming file", "title", mf.Title, "artist", mf.Artist, "format", format, "cached", cached,
|
||||
"bitRate", bitRate, "sampleRate", req.SampleRate, "bitDepth", req.BitDepth, "channels", req.Channels,
|
||||
"user", userName(ctx), "transcoding", format != "raw",
|
||||
"originalFormat", mf.Suffix, "originalBitRate", mf.BitRate)
|
||||
}()
|
||||
|
||||
format = req.Format
|
||||
bitRate = req.BitRate
|
||||
if format == "" || format == "raw" {
|
||||
format = "raw"
|
||||
bitRate = 0
|
||||
}
|
||||
s := &Stream{ctx: ctx, mf: mf, format: format, bitRate: bitRate}
|
||||
filePath := mf.AbsolutePath()
|
||||
|
||||
if format == "raw" {
|
||||
log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format)
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ReadCloser = f
|
||||
s.Seeker = f
|
||||
s.format = mf.Suffix
|
||||
return s, nil
|
||||
}
|
||||
|
||||
job := &streamJob{
|
||||
ms: ms,
|
||||
mf: mf,
|
||||
filePath: filePath,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
sampleRate: req.SampleRate,
|
||||
bitDepth: req.BitDepth,
|
||||
channels: req.Channels,
|
||||
offset: req.Offset,
|
||||
}
|
||||
r, err := ms.cache.Get(ctx, job)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err)
|
||||
return nil, err
|
||||
}
|
||||
cached = r.Cached
|
||||
|
||||
s.ReadCloser = r
|
||||
s.Seeker = r.Seeker
|
||||
|
||||
log.Debug(ctx, "Streaming TRANSCODED file", "id", mf.ID, "path", filePath,
|
||||
"requestBitrate", req.BitRate, "requestFormat", req.Format, "requestOffset", req.Offset,
|
||||
"originalBitrate", mf.BitRate, "originalFormat", mf.Suffix,
|
||||
"selectedBitrate", bitRate, "selectedFormat", format, "cached", cached, "seekable", s.Seekable())
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type Stream struct {
|
||||
ctx context.Context
|
||||
mf *model.MediaFile
|
||||
bitRate int
|
||||
format string
|
||||
io.ReadCloser
|
||||
io.Seeker
|
||||
}
|
||||
|
||||
func (s *Stream) Seekable() bool { return s.Seeker != nil }
|
||||
func (s *Stream) Duration() float32 { return s.mf.Duration }
|
||||
func (s *Stream) ContentType() string { return mime.TypeByExtension("." + s.format) }
|
||||
func (s *Stream) Name() string { return s.mf.Title + "." + s.format }
|
||||
func (s *Stream) ModTime() time.Time { return s.mf.UpdatedAt }
|
||||
func (s *Stream) EstimatedContentLength() int {
|
||||
return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024)
|
||||
}
|
||||
|
||||
// NewTestStream creates a Stream for testing purposes.
|
||||
func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream {
|
||||
return &Stream{
|
||||
ctx: context.Background(),
|
||||
mf: mf,
|
||||
format: format,
|
||||
bitRate: bitRate,
|
||||
ReadCloser: io.NopCloser(strings.NewReader("")),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
onceTranscodingCache sync.Once
|
||||
instanceTranscodingCache TranscodingCache
|
||||
)
|
||||
|
||||
func GetTranscodingCache() TranscodingCache {
|
||||
onceTranscodingCache.Do(func() {
|
||||
instanceTranscodingCache = NewTranscodingCache()
|
||||
})
|
||||
return instanceTranscodingCache
|
||||
}
|
||||
|
||||
func NewTranscodingCache() TranscodingCache {
|
||||
return cache.NewFileCache("Transcoding", conf.Server.TranscodingCacheSize,
|
||||
consts.TranscodingCacheDir, consts.DefaultTranscodingCacheMaxItems,
|
||||
func(ctx context.Context, arg cache.Item) (io.Reader, error) {
|
||||
job := arg.(*streamJob)
|
||||
command := LookupTranscodeCommand(ctx, job.ms.ds, job.format)
|
||||
if command == "" {
|
||||
log.Error(ctx, "No transcoding command available", "format", job.format)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
// Choose the appropriate context based on EnableTranscodingCancellation configuration.
|
||||
// This is where we decide whether transcoding processes should be cancellable or not.
|
||||
var transcodingCtx context.Context
|
||||
if conf.Server.EnableTranscodingCancellation {
|
||||
// Use the request context directly, allowing cancellation when client disconnects
|
||||
transcodingCtx = ctx
|
||||
} else {
|
||||
// Use background context with request values preserved.
|
||||
// This prevents cancellation but maintains request metadata (user, client, etc.)
|
||||
transcodingCtx = request.AddValues(context.Background(), ctx)
|
||||
}
|
||||
|
||||
out, err := job.ms.transcoder.Transcode(transcodingCtx, ffmpeg.TranscodeOptions{
|
||||
Command: command,
|
||||
Format: job.format,
|
||||
FilePath: job.filePath,
|
||||
BitRate: job.bitRate,
|
||||
SampleRate: job.sampleRate,
|
||||
BitDepth: job.bitDepth,
|
||||
Channels: job.channels,
|
||||
Offset: job.offset,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
}
|
||||
|
||||
// userName extracts the username from the context for logging purposes.
|
||||
func userName(ctx context.Context) string {
|
||||
if user, ok := request.UserFrom(ctx); !ok {
|
||||
return "UNKNOWN"
|
||||
} else {
|
||||
return user.UserName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package stream_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("MediaStreamer", func() {
|
||||
var streamer stream.MediaStreamer
|
||||
var ds model.DataStore
|
||||
ffmpeg := tests.NewMockFFmpeg("fake data")
|
||||
ctx := log.NewContext(context.TODO())
|
||||
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches")
|
||||
conf.Server.TranscodingCacheSize = "100MB"
|
||||
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||
ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
|
||||
{ID: "123", Path: "tests/fixtures/test.mp3", Suffix: "mp3", BitRate: 128, Duration: 257.0},
|
||||
})
|
||||
testCache := stream.NewTranscodingCache()
|
||||
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
|
||||
streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache)
|
||||
})
|
||||
AfterEach(func() {
|
||||
_ = os.RemoveAll(conf.Server.CacheFolder)
|
||||
})
|
||||
|
||||
Context("NewStream", func() {
|
||||
var mf *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
mf, err = ds.MediaFile(ctx).Get("123")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
It("returns a seekable stream if format is 'raw'", func() {
|
||||
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "raw"})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a seekable stream if no format is specified (direct play)", func() {
|
||||
s, err := streamer.NewStream(ctx, mf, stream.Request{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
It("returns a NON seekable stream if transcode is required", func() {
|
||||
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 64})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeFalse())
|
||||
Expect(s.Duration()).To(Equal(float32(257.0)))
|
||||
})
|
||||
It("returns a seekable stream if the file is complete in the cache", func() {
|
||||
s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
_, _ = io.ReadAll(s)
|
||||
_ = s.Close()
|
||||
Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue())
|
||||
|
||||
s, err = streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32})
|
||||
Expect(err).To(BeNil())
|
||||
Expect(s.Seekable()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestStream(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Stream Suite")
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const tokenTTL = 48 * time.Hour
|
||||
|
||||
// params contains the parameters extracted from a transcode token.
|
||||
// TargetBitrate is in kilobits per second (kbps).
|
||||
type params struct {
|
||||
MediaID string
|
||||
DirectPlay bool
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
TargetSampleRate int
|
||||
TargetBitDepth int
|
||||
SourceUpdatedAt time.Time
|
||||
}
|
||||
|
||||
// toClaimsMap converts a Decision into a JWT claims map for token encoding.
|
||||
// Only non-zero transcode fields are included.
|
||||
func (d *TranscodeDecision) toClaimsMap() map[string]any {
|
||||
m := map[string]any{
|
||||
"mid": d.MediaID,
|
||||
"ua": d.SourceUpdatedAt.Truncate(time.Second).Unix(),
|
||||
jwt.ExpirationKey: time.Now().Add(tokenTTL).UTC().Unix(),
|
||||
}
|
||||
if d.CanDirectPlay {
|
||||
m["dp"] = true
|
||||
}
|
||||
if d.CanTranscode && d.TargetFormat != "" {
|
||||
m["f"] = d.TargetFormat
|
||||
if d.TargetBitrate != 0 {
|
||||
m["b"] = d.TargetBitrate
|
||||
}
|
||||
if d.TargetChannels != 0 {
|
||||
m["ch"] = d.TargetChannels
|
||||
}
|
||||
if d.TargetSampleRate != 0 {
|
||||
m["sr"] = d.TargetSampleRate
|
||||
}
|
||||
if d.TargetBitDepth != 0 {
|
||||
m["bd"] = d.TargetBitDepth
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// paramsFromToken extracts and validates Params from a parsed JWT token.
|
||||
// Returns an error if required claims (media ID, source timestamp) are missing.
|
||||
func paramsFromToken(token jwt.Token) (*params, error) {
|
||||
var p params
|
||||
var mid string
|
||||
if err := token.Get("mid", &mid); err == nil {
|
||||
p.MediaID = mid
|
||||
}
|
||||
if p.MediaID == "" {
|
||||
return nil, fmt.Errorf("%w: missing media ID", ErrTokenInvalid)
|
||||
}
|
||||
|
||||
var dp bool
|
||||
if err := token.Get("dp", &dp); err == nil {
|
||||
p.DirectPlay = dp
|
||||
}
|
||||
|
||||
ua := getIntClaim(token, "ua")
|
||||
if ua != 0 {
|
||||
p.SourceUpdatedAt = time.Unix(int64(ua), 0)
|
||||
}
|
||||
if p.SourceUpdatedAt.IsZero() {
|
||||
return nil, fmt.Errorf("%w: missing source timestamp", ErrTokenInvalid)
|
||||
}
|
||||
|
||||
var f string
|
||||
if err := token.Get("f", &f); err == nil {
|
||||
p.TargetFormat = f
|
||||
}
|
||||
p.TargetBitrate = getIntClaim(token, "b")
|
||||
p.TargetChannels = getIntClaim(token, "ch")
|
||||
p.TargetSampleRate = getIntClaim(token, "sr")
|
||||
p.TargetBitDepth = getIntClaim(token, "bd")
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// getIntClaim extracts an int claim from a JWT token, handling the case where
|
||||
// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
|
||||
func getIntClaim(token jwt.Token, key string) int {
|
||||
var v int
|
||||
if err := token.Get(key, &v); err == nil {
|
||||
return v
|
||||
}
|
||||
var v64 int64
|
||||
if err := token.Get(key, &v64); err == nil {
|
||||
return int(v64)
|
||||
}
|
||||
var f float64
|
||||
if err := token.Get(key, &f); err == nil {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *deciderService) CreateTranscodeParams(decision *TranscodeDecision) (string, error) {
|
||||
return auth.EncodeToken(decision.toClaimsMap())
|
||||
}
|
||||
|
||||
func (s *deciderService) parseTranscodeParams(tokenStr string) (*params, error) {
|
||||
token, err := auth.DecodeAndVerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return paramsFromToken(token)
|
||||
}
|
||||
|
||||
func (s *deciderService) ResolveRequestFromToken(ctx context.Context, token string, mf *model.MediaFile, offset int) (Request, error) {
|
||||
p, err := s.parseTranscodeParams(token)
|
||||
if err != nil {
|
||||
return Request{}, errors.Join(ErrTokenInvalid, err)
|
||||
}
|
||||
if p.MediaID != mf.ID {
|
||||
return Request{}, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mf.ID)
|
||||
}
|
||||
if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
|
||||
log.Info(ctx, "Transcode token is stale", "mediaID", mf.ID,
|
||||
"tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
|
||||
return Request{}, ErrTokenStale
|
||||
}
|
||||
|
||||
req := Request{Offset: offset}
|
||||
if !p.DirectPlay && p.TargetFormat != "" {
|
||||
req.Format = p.TargetFormat
|
||||
req.BitRate = p.TargetBitrate
|
||||
req.SampleRate = p.TargetSampleRate
|
||||
req.BitDepth = p.TargetBitDepth
|
||||
req.Channels = p.TargetChannels
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/jwtauth/v5"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Token", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
ff *tests.MockFFmpeg
|
||||
svc TranscodeDecider
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = GinkgoT().Context()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
}
|
||||
ff = tests.NewMockFFmpeg("")
|
||||
auth.Init(ds)
|
||||
svc = NewTranscodeDecider(ds, ff)
|
||||
})
|
||||
|
||||
Describe("Token round-trip", func() {
|
||||
var (
|
||||
sourceTime time.Time
|
||||
impl *deciderService
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
|
||||
impl = svc.(*deciderService)
|
||||
})
|
||||
|
||||
It("creates and parses a direct play token", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-123",
|
||||
CanDirectPlay: true,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-123"))
|
||||
Expect(params.DirectPlay).To(BeTrue())
|
||||
Expect(params.TargetFormat).To(BeEmpty())
|
||||
Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
|
||||
})
|
||||
|
||||
It("creates and parses a transcode token with kbps bitrate", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-456",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256, // kbps
|
||||
TargetChannels: 2,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-456"))
|
||||
Expect(params.DirectPlay).To(BeFalse())
|
||||
Expect(params.TargetFormat).To(Equal("mp3"))
|
||||
Expect(params.TargetBitrate).To(Equal(256)) // kbps
|
||||
Expect(params.TargetChannels).To(Equal(2))
|
||||
Expect(params.SourceUpdatedAt.Unix()).To(Equal(sourceTime.Unix()))
|
||||
})
|
||||
|
||||
It("creates and parses a transcode token with sample rate", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-789",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "flac",
|
||||
TargetBitrate: 0,
|
||||
TargetChannels: 2,
|
||||
TargetSampleRate: 48000,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-789"))
|
||||
Expect(params.DirectPlay).To(BeFalse())
|
||||
Expect(params.TargetFormat).To(Equal("flac"))
|
||||
Expect(params.TargetSampleRate).To(Equal(48000))
|
||||
Expect(params.TargetChannels).To(Equal(2))
|
||||
})
|
||||
|
||||
It("creates and parses a transcode token with bit depth", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-bd",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "flac",
|
||||
TargetBitrate: 0,
|
||||
TargetChannels: 2,
|
||||
TargetBitDepth: 24,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-bd"))
|
||||
Expect(params.TargetBitDepth).To(Equal(24))
|
||||
})
|
||||
|
||||
It("omits bit depth from token when 0", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-nobd",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256,
|
||||
TargetBitDepth: 0,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.TargetBitDepth).To(Equal(0))
|
||||
})
|
||||
|
||||
It("omits sample rate from token when 0", func() {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-100",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256,
|
||||
TargetSampleRate: 0,
|
||||
SourceUpdatedAt: sourceTime,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.TargetSampleRate).To(Equal(0))
|
||||
})
|
||||
|
||||
It("truncates SourceUpdatedAt to seconds", func() {
|
||||
timeWithNanos := time.Date(2025, 6, 15, 10, 30, 0, 123456789, time.UTC)
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: "media-trunc",
|
||||
CanDirectPlay: true,
|
||||
SourceUpdatedAt: timeWithNanos,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := impl.parseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.SourceUpdatedAt.Unix()).To(Equal(timeWithNanos.Truncate(time.Second).Unix()))
|
||||
})
|
||||
|
||||
It("rejects an invalid token", func() {
|
||||
_, err := impl.parseTranscodeParams("invalid-token")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ResolveRequestFromToken", func() {
|
||||
var sourceTime time.Time
|
||||
|
||||
BeforeEach(func() {
|
||||
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
|
||||
})
|
||||
|
||||
createTokenForMedia := func(mediaID string, updatedAt time.Time) string {
|
||||
decision := &TranscodeDecision{
|
||||
MediaID: mediaID,
|
||||
CanDirectPlay: true,
|
||||
SourceUpdatedAt: updatedAt,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return token
|
||||
}
|
||||
|
||||
It("returns stream request for valid token", func() {
|
||||
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
|
||||
token := createTokenForMedia("song-1", sourceTime)
|
||||
|
||||
req, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(req.Format).To(BeEmpty()) // direct play has no target format
|
||||
})
|
||||
|
||||
It("returns ErrTokenInvalid for invalid token", func() {
|
||||
mf := &model.MediaFile{ID: "song-1", UpdatedAt: sourceTime}
|
||||
_, err := svc.ResolveRequestFromToken(ctx, "bad-token", mf, 0)
|
||||
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
|
||||
})
|
||||
|
||||
It("returns ErrTokenInvalid when mediaID does not match token", func() {
|
||||
mf := &model.MediaFile{ID: "song-2", UpdatedAt: sourceTime}
|
||||
token := createTokenForMedia("song-1", sourceTime)
|
||||
|
||||
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
|
||||
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
|
||||
})
|
||||
|
||||
It("returns ErrTokenStale when media file has changed", func() {
|
||||
newTime := sourceTime.Add(1 * time.Hour)
|
||||
mf := &model.MediaFile{ID: "song-1", UpdatedAt: newTime}
|
||||
token := createTokenForMedia("song-1", sourceTime)
|
||||
|
||||
_, err := svc.ResolveRequestFromToken(ctx, token, mf, 0)
|
||||
Expect(err).To(MatchError(ErrTokenStale))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("paramsFromToken", func() {
|
||||
It("returns error when media ID is missing", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token, _, err := tokenAuth.Encode(map[string]any{"ua": int64(1700000000)})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = paramsFromToken(token)
|
||||
Expect(err).To(MatchError(ContainSubstring("missing media ID")))
|
||||
})
|
||||
|
||||
It("returns error when source timestamp is missing", func() {
|
||||
tokenAuth := jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||
token, _, err := tokenAuth.Encode(map[string]any{"mid": "song-5"})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = paramsFromToken(token)
|
||||
Expect(err).To(MatchError(ContainSubstring("missing source timestamp")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("invalid or expired transcode token")
|
||||
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
|
||||
)
|
||||
|
||||
// TranscodeOptions controls optional behavior of MakeTranscodeDecision.
|
||||
type TranscodeOptions struct {
|
||||
// SkipProbe prevents MakeTranscodeDecision from running ffprobe on the media file.
|
||||
// When true, source stream details are derived from tag metadata only.
|
||||
SkipProbe bool
|
||||
}
|
||||
|
||||
// Request contains the resolved parameters for creating a media stream.
|
||||
type Request struct {
|
||||
Format string
|
||||
BitRate int // kbps
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Offset int // seconds
|
||||
}
|
||||
|
||||
// ClientInfo represents client playback capabilities.
|
||||
// All bitrate values are in kilobits per second (kbps)
|
||||
type ClientInfo struct {
|
||||
Name string
|
||||
Platform string
|
||||
MaxAudioBitrate int
|
||||
MaxTranscodingAudioBitrate int
|
||||
DirectPlayProfiles []DirectPlayProfile
|
||||
TranscodingProfiles []Profile
|
||||
CodecProfiles []CodecProfile
|
||||
}
|
||||
|
||||
// DirectPlayProfile describes a format the client can play directly
|
||||
type DirectPlayProfile struct {
|
||||
Containers []string
|
||||
AudioCodecs []string
|
||||
Protocols []string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// Profile describes a transcoding target the client supports
|
||||
type Profile struct {
|
||||
Container string
|
||||
AudioCodec string
|
||||
Protocol string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// CodecProfile describes codec-specific limitations
|
||||
type CodecProfile struct {
|
||||
Type string
|
||||
Name string
|
||||
Limitations []Limitation
|
||||
}
|
||||
|
||||
// Limitation describes a specific codec limitation
|
||||
type Limitation struct {
|
||||
Name string
|
||||
Comparison string
|
||||
Values []string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Protocol values (OpenSubsonic spec enum)
|
||||
const (
|
||||
ProtocolHTTP = "http"
|
||||
ProtocolHLS = "hls"
|
||||
)
|
||||
|
||||
// Comparison operators (OpenSubsonic spec enum)
|
||||
const (
|
||||
ComparisonEquals = "Equals"
|
||||
ComparisonNotEquals = "NotEquals"
|
||||
ComparisonLessThanEqual = "LessThanEqual"
|
||||
ComparisonGreaterThanEqual = "GreaterThanEqual"
|
||||
)
|
||||
|
||||
// Limitation names (OpenSubsonic spec enum)
|
||||
const (
|
||||
LimitationAudioChannels = "audioChannels"
|
||||
LimitationAudioBitrate = "audioBitrate"
|
||||
LimitationAudioProfile = "audioProfile"
|
||||
LimitationAudioSamplerate = "audioSamplerate"
|
||||
LimitationAudioBitdepth = "audioBitdepth"
|
||||
)
|
||||
|
||||
// Codec profile types (OpenSubsonic spec enum)
|
||||
const (
|
||||
CodecProfileTypeAudio = "AudioCodec"
|
||||
)
|
||||
|
||||
// TranscodeDecision represents the internal decision result.
|
||||
// All bitrate values are in kilobits per second (kbps).
|
||||
type TranscodeDecision struct {
|
||||
MediaID string
|
||||
CanDirectPlay bool
|
||||
CanTranscode bool
|
||||
TranscodeReasons []string
|
||||
ErrorReason string
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
TargetSampleRate int
|
||||
TargetBitDepth int
|
||||
SourceStream Details
|
||||
SourceUpdatedAt time.Time
|
||||
TranscodeStream *Details
|
||||
}
|
||||
|
||||
// Details describes audio stream properties.
|
||||
// Bitrate is in kilobits per second (kbps).
|
||||
type Details struct {
|
||||
Container string
|
||||
Codec string
|
||||
Profile string // Audio profile (e.g., "LC", "HE-AACv2"). Populated from ffprobe data.
|
||||
Bitrate int
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Duration float32
|
||||
Size int64
|
||||
IsLossless bool
|
||||
}
|
||||
Reference in New Issue
Block a user