feat(subsonic): implement OpenSubsonic Transcoding extension (#4990)

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

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(subsonic): update codec limitation structure and decision logic for improved clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcoding): update bitrate handling to use kilobits per second (kbps) across transcode decision logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): simplify container alias handling in matchesContainer function

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcoding): enforce POST method for GetTranscodeDecision and handle non-POST requests

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(transcoding): add enums for protocol, comparison operators, limitations, and codec profiles in transcode decision logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): streamline limitation checks and applyLimitation logic for improved readability and maintainability

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): replace strings.EqualFold with direct comparison for protocol and limitation checks

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): rename token methods to CreateTranscodeParams and ParseTranscodeParams for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance logging for transcode decision process and client info conversion

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): rename TranscodeDecision to Decider and update related methods for clarity

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance transcoding config lookup logic for audio codecs

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance transcoding options with sample rate support and improve command handling

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): add bit depth support for audio transcoding and enhance related logic

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): enhance AAC command handling and support for audio channels in streaming

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): streamline transcoding logic by consolidating stream parameter handling and enhancing alias mapping

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcoding): update default command handling and add codec support for transcoding

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: implement noopDecider for transcoding decision handling in tests

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: address review findings for OpenSubsonic transcoding PR

Fix multiple issues identified during code review of the transcoding
extension: add missing return after error in shared stream handler
preventing nil pointer panic, replace dead r.Body nil check with
MaxBytesReader size limit, distinguish not-found from other DB errors,
fix bpsToKbps integer truncation with rounding, add "pcm" to
isLosslessFormat for consistency with model.IsLossless(), add
sampleRate/bitDepth/channels to streaming log, fix outdated test
comment, and add tests for conversion functions and GetTranscodeStream
parameter passing.

* feat(transcoding): add sourceUpdatedAt to decision and validate transcode parameters

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: small issues

Updated mock AAC transcoding command to use the new default (ipod with
fragmented MP4) matching the migration, ensuring tests exercise the same
buildDynamicArgs code path as production. Improved archiver test mock to
match on the whole StreamRequest struct instead of decomposing fields,
making it resilient to future field additions. Added named constants for
JWT claim keys in the transcode token and wrapped ParseTranscodeParams
errors with ErrTokenInvalid for consistency. Documented the IsLossless
BitDepth fallback heuristic as temporary until Codec column is populated.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcoding): adapt transcode claims to struct-based auth.Claims

Updated transcode token handling to use the struct-based auth.Claims
introduced on master, replacing the previous map[string]any approach.
Extended auth.Claims with transcoding-specific fields (MediaID, DirectPlay,
UpdatedAt, Channels, SampleRate, BitDepth) and added float64 fallback in
ClaimsFromToken for numeric claims that lose their Go type during JWT
string serialization. Also added the missing lyrics parameter to all
subsonic.New() calls in test files.

* feat(model): add ProbeData field and UpdateProbeData repository method

Add probe_data TEXT column to media_file for caching ffprobe results.
Add UpdateProbeData to MediaFileRepository interface and implementations.
Use hash:"ignore" tag so probe data doesn't affect MediaFile fingerprints.

* feat(ffmpeg): add ProbeAudioStream for authoritative audio metadata

Add ProbeAudioStream to FFmpeg interface, using ffprobe to extract
codec, profile, bitrate, sample rate, bit depth, and channels.
Parse bits_per_raw_sample as fallback for FLAC/ALAC bit depth.
Normalize "unknown" profile to empty string.
All parseProbeOutput tests use real ffprobe JSON from actual files.

* feat(transcoding): integrate ffprobe into transcode decisions

Add ensureProbed to probe media files on first transcode decision,
caching results in probe_data. Build SourceStream from probe data
with fallback to tag-based metadata.

Refactor decision logic to pass StreamDetails instead of MediaFile,
enabling codec profile limitations (e.g., audioProfile) to use
probe data. Add normalizeProbeCodec to map ffprobe codec names
(dsd_lsbf_planar, pcm_s16le) to internal names (dsd, pcm).

NewDecider now accepts ffmpeg.FFmpeg; wire_gen.go regenerated.

* feat(transcoding): add DevEnableMediaFileProbe config flag

Add DevEnableMediaFileProbe (default true) to allow disabling ffprobe-
based media file probing as a safety fallback. When disabled, the
decider uses tag-based metadata from the scanner instead.

* test(transcode): add ensureProbed unit tests

Test probing when ProbeData is empty, skipping when already set,
error propagation from ffprobe, and DevEnableMediaFileProbe flag.

* refactor(ffmpeg): use command constant and select_streams for ProbeAudioStream

Move ffprobe arguments to a probeAudioStreamCmd constant, following the
same pattern as extractImageCmd and probeCmd. Add -select_streams a:0 to
only probe the first audio stream, avoiding unnecessary parsing of video
and artwork streams. Derive the ffprobe binary path safely using
filepath.Dir/Base instead of replacing within the full path string.

* refactor(transcode): decouple transcode token claims from auth.Claims

Remove six transcode-specific fields (MediaID, DirectPlay, UpdatedAt,
Channels, SampleRate, BitDepth) from auth.Claims, which is shared with
session and share tokens. Transcode tokens are signed parameter-passing
tokens, not authentication tokens, so coupling them to auth created
misleading dependencies.

The transcode package now owns its own JWT claim serialization via
Decision.toClaimsMap() and paramsFromToken(), using generic
auth.EncodeToken/DecodeAndVerifyToken wrappers that keep TokenAuth
encapsulated. Wire format (JWT claim keys) is unchanged, so in-flight
tokens remain compatible.

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcode): simplify code after review

Extract getIntClaim helper to eliminate repeated int/int64/float64 JWT
claim extraction pattern in paramsFromToken and ClaimsFromToken. Rewrite
checkIntLimitation as a one-liner delegating to applyIntLimitation.
Return probe result from ensureProbed to avoid redundant JSON round-trip.
Extract toResponseStreamDetails helper and mediaTypeSong constant in
the API layer, and use transcode.ProtocolHTTP constant instead of
hardcoded string.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(ffmpeg): enhance bit_rate parsing logic for audio streams

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcode): improve code review findings across transcode implementation

- Fix parseProbeData to return nil on JSON unmarshal failure instead of
  a zero-valued struct, preventing silent degradation of source stream details
- Use probe-resolved codec for lossless detection in buildSourceStream
  instead of the potentially stale scanner data
- Remove MediaFile.IsLossless() (dead code) and consolidate lossless
  detection in isLosslessFormat(), using codec name only — bit depth is
  not reliable since lossy codecs like ADPCM report non-zero values
- Add "wavpack" to lossless codec list (ffprobe codec_name for WavPack)
- Guard bpsToKbps against negative input values
- Fix misleading comment in buildTemplateArgs about conditional injection
- Avoid leaking internal error details in Subsonic API responses
- Add missing test for ErrNotFound branch in GetTranscodeDecision
- Add TODO for hardcoded protocol in toResponseStreamDetails

* refactor(transcode): streamline transcoding command lookup and format resolution

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(transcode): implement server-side transcoding override for player formats

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(transcode): honor bit depth and channel constraints in transcoding selection

selectTranscodingOptions only checked sample rate when deciding whether
same-format transcoding was needed, ignoring requested bit depth and
channel reductions. This caused the streamer to return raw audio when
the transcode decision requested downmix or bit-depth conversion.

* refactor(transcode): unify streaming decision engine via MakeDecision

Move transcoding decision-making out of mediaStreamer and into the
subsonic Stream/Download handlers, using transcode.Decider.MakeDecision
as the single decision engine. This eliminates selectTranscodingOptions
and the mismatch between decision and streaming code paths (decision
used LookupTranscodeCommand with built-in fallbacks, while streaming
used FindByFormat which only checked the DB).

- Add DecisionOptions with SkipProbe to MakeDecision so the legacy
  streaming path never calls ffprobe
- Add buildLegacyClientInfo to translate legacy stream params (format,
  maxBitRate, DefaultDownsamplingFormat) into a synthetic ClientInfo
- Add resolveStreamRequest on the subsonic Router to resolve legacy
  params into a fully specified StreamRequest via MakeDecision
- Simplify DoStream to a dumb executor that receives pre-resolved params
- Remove selectTranscodingOptions entirely

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(transcode): move MediaStreamer into core/transcode and unify StreamRequest

Moved MediaStreamer, Stream, TranscodingCache and related types from
core/media_streamer.go into core/transcode/, eliminating the duplicate
StreamRequest type. The transcode.StreamRequest now carries all fields
(ID, Format, BitRate, SampleRate, BitDepth, Channels, Offset) and
ResolveStream returns a fully-populated value, removing manual field
copying at every call site. Also moved buildLegacyClientInfo into the
transcode package alongside ResolveStream, and unexported
ParseTranscodeParams since it was only used internally by
ValidateTranscodeParams.

* refactor(transcode): rename Decider methods and unexport Params type

Rename ResolveStream → ResolveRequest and ValidateTranscodeParams →
ResolveRequestFromToken for clarity and consistency. The new
ResolveRequestFromToken returns a StreamRequest directly (instead of
the intermediate Params type), eliminating manual Params→StreamRequest
conversion in callers. Unexport Params to params since it is now only
used internally for JWT token parsing.

* test(transcode): remove redundant tests and use constants

Remove tests that duplicate coverage from integration-level tests
(toClaimsMap, paramsFromToken round-trips, applyServerOverride direct
call, duplicate 410 handler test). Replace raw "http" strings with
ProtocolHTTP constant. Consolidate lossy -sample_fmt tests into
DescribeTable.

* refactor(transcode): split oversized files into focused modules

Split transcode.go and transcode_test.go into focused files by concern:
- decider.go: decision engine (MakeDecision, direct play/transcode evaluation, probe)
- token.go: JWT token encode/decode (params, toClaimsMap, paramsFromToken, CreateTranscodeParams, ResolveRequestFromToken)
- legacy_client.go: legacy Subsonic bridge (buildLegacyClientInfo, ResolveRequest)
- codec_test.go: isLosslessFormat and normalizeProbeCodec tests
- token_test.go: token round-trip and ResolveRequestFromToken tests

Moved the Decider interface from types.go to decider.go to keep it near
its implementation, and cleaned up types.go to contain only pure type
definitions and constants. No public API changes.

* refactor(transcode): reorder parameters in applyServerOverride function

Signed-off-by: Deluan <deluan@navidrome.org>

* test(e2e): add NewTestStream function and implement spyStreamer for testing

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan Quintão
2026-03-08 23:57:49 -04:00
committed by GitHub
parent e1b3412999
commit ae1e0ddb11
51 changed files with 4828 additions and 352 deletions
+87
View File
@@ -0,0 +1,87 @@
package transcode
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"},
{"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)
})
}
+77
View File
@@ -0,0 +1,77 @@
package transcode
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
}
+69
View File
@@ -0,0 +1,69 @@
package transcode
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"))
})
})
})
+425
View File
@@ -0,0 +1,425 @@
package transcode
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 defaultBitrate = 256 // kbps
// Decider is the core service interface for making transcoding decisions
type Decider interface {
MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, opts DecisionOptions) (*Decision, error)
CreateTranscodeParams(decision *Decision) (string, error)
ResolveRequestFromToken(ctx context.Context, token string, mediaID string, offset int) (StreamRequest, *model.MediaFile, error)
ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest
}
func NewDecider(ds model.DataStore, ff ffmpeg.FFmpeg) Decider {
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 DecisionOptions) (*Decision, error) {
decision := &Decision{
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)
}
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) StreamDetails {
sd := StreamDetails{
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 *StreamDetails, 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 *StreamDetails, profile *Profile, clientInfo *ClientInfo) (*StreamDetails, 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 := &StreamDetails{
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
}
// 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 *StreamDetails, targetFormat string, targetIsLossless bool, clientInfo *ClientInfo, ts *StreamDetails) bool {
if src.IsLossless {
if !targetIsLossless {
if clientInfo.MaxTranscodingAudioBitrate > 0 {
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
} else {
ts.Bitrate = defaultBitrate
}
} 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 *StreamDetails) 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
+85
View File
@@ -0,0 +1,85 @@
package transcode
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 StreamRequest.
func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, offset int) StreamRequest {
var req StreamRequest
req.ID = mf.ID
req.Offset = offset
if reqFormat == "raw" {
req.Format = "raw"
return req
}
clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate)
decision, err := s.MakeDecision(ctx, mf, clientInfo, DecisionOptions{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
}
+84
View File
@@ -0,0 +1,84 @@
package transcode
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())
})
})
+171
View File
@@ -0,0 +1,171 @@
package transcode
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 *StreamDetails, 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 *StreamDetails) 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
}
+217
View File
@@ -0,0 +1,217 @@
package transcode
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, req StreamRequest) (*Stream, error)
DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*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, req StreamRequest) (*Stream, error) {
mf, err := ms.ds.MediaFile(ctx).Get(req.ID)
if err != nil {
return nil, err
}
return ms.DoStream(ctx, mf, req)
}
func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, req StreamRequest) (*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
}
}
+69
View File
@@ -0,0 +1,69 @@
package transcode_test
import (
"context"
"io"
"os"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/transcode"
"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 transcode.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 := transcode.NewTranscodingCache()
Eventually(func() bool { return testCache.Available(context.TODO()) }).Should(BeTrue())
streamer = transcode.NewMediaStreamer(ds, ffmpeg, testCache)
})
AfterEach(func() {
_ = os.RemoveAll(conf.Server.CacheFolder)
})
Context("NewStream", func() {
It("returns a seekable stream if format is 'raw'", func() {
s, err := streamer.NewStream(ctx, transcode.StreamRequest{ID: "123", 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, transcode.StreamRequest{ID: "123"})
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, transcode.StreamRequest{ID: "123", 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, transcode.StreamRequest{ID: "123", 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, transcode.StreamRequest{ID: "123", Format: "mp3", BitRate: 32})
Expect(err).To(BeNil())
Expect(s.Seekable()).To(BeTrue())
})
})
})
+155
View File
@@ -0,0 +1,155 @@
package transcode
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 = 12 * 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 *Decision) 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 *Decision) (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, mediaID string, offset int) (StreamRequest, *model.MediaFile, error) {
p, err := s.parseTranscodeParams(token)
if err != nil {
return StreamRequest{}, nil, errors.Join(ErrTokenInvalid, err)
}
if p.MediaID != mediaID {
return StreamRequest{}, nil, fmt.Errorf("%w: token mediaID %q does not match %q", ErrTokenInvalid, p.MediaID, mediaID)
}
mf, err := s.ds.MediaFile(ctx).Get(mediaID)
if err != nil {
if errors.Is(err, model.ErrNotFound) {
return StreamRequest{}, nil, ErrMediaNotFound
}
return StreamRequest{}, nil, err
}
if !mf.UpdatedAt.Truncate(time.Second).Equal(p.SourceUpdatedAt) {
log.Info(ctx, "Transcode token is stale", "mediaID", mediaID,
"tokenUpdatedAt", p.SourceUpdatedAt, "fileUpdatedAt", mf.UpdatedAt)
return StreamRequest{}, nil, ErrTokenStale
}
req := StreamRequest{ID: mediaID, 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, mf, nil
}
+272
View File
@@ -0,0 +1,272 @@
package transcode
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 Decider
ctx context.Context
)
BeforeEach(func() {
ctx = GinkgoT().Context()
ds = &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedTranscoding: &tests.MockTranscodingRepo{},
}
ff = tests.NewMockFFmpeg("")
auth.Init(ds)
svc = NewDecider(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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 := &Decision{
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 (
mockMFRepo *tests.MockMediaFileRepo
sourceTime time.Time
)
BeforeEach(func() {
sourceTime = time.Date(2025, 6, 15, 10, 30, 0, 0, time.UTC)
mockMFRepo = &tests.MockMediaFileRepo{}
ds.MockedMediaFile = mockMFRepo
})
createTokenForMedia := func(mediaID string, updatedAt time.Time) string {
decision := &Decision{
MediaID: mediaID,
CanDirectPlay: true,
SourceUpdatedAt: updatedAt,
}
token, err := svc.CreateTranscodeParams(decision)
Expect(err).ToNot(HaveOccurred())
return token
}
It("returns stream request and media file for valid token", func() {
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-1", UpdatedAt: sourceTime},
})
token := createTokenForMedia("song-1", sourceTime)
req, mf, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 0)
Expect(err).ToNot(HaveOccurred())
Expect(req.ID).To(Equal("song-1"))
Expect(req.Format).To(BeEmpty()) // direct play has no target format
Expect(mf.ID).To(Equal("song-1"))
})
It("returns ErrTokenInvalid for invalid token", func() {
_, _, err := svc.ResolveRequestFromToken(ctx, "bad-token", "song-1", 0)
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
})
It("returns ErrTokenInvalid when mediaID does not match token", func() {
token := createTokenForMedia("song-1", sourceTime)
_, _, err := svc.ResolveRequestFromToken(ctx, token, "song-2", 0)
Expect(err).To(MatchError(ContainSubstring(ErrTokenInvalid.Error())))
})
It("returns ErrMediaNotFound when media file does not exist", func() {
token := createTokenForMedia("gone-id", sourceTime)
_, _, err := svc.ResolveRequestFromToken(ctx, token, "gone-id", 0)
Expect(err).To(MatchError(ErrMediaNotFound))
})
It("returns ErrTokenStale when media file has changed", func() {
newTime := sourceTime.Add(1 * time.Hour)
mockMFRepo.SetData(model.MediaFiles{
{ID: "song-1", UpdatedAt: newTime},
})
token := createTokenForMedia("song-1", sourceTime)
_, _, err := svc.ResolveRequestFromToken(ctx, token, "song-1", 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")))
})
})
})
+17
View File
@@ -0,0 +1,17 @@
package transcode
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestTranscode(t *testing.T) {
tests.Init(t, false)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Transcode Suite")
}
+134
View File
@@ -0,0 +1,134 @@
package transcode
import (
"errors"
"time"
)
var (
ErrTokenInvalid = errors.New("invalid or expired transcode token")
ErrMediaNotFound = errors.New("media file not found")
ErrTokenStale = errors.New("transcode token is stale: media file has changed")
)
// DecisionOptions controls optional behavior of MakeDecision.
type DecisionOptions struct {
// SkipProbe prevents MakeDecision from running ffprobe on the media file.
// When true, source stream details are derived from tag metadata only.
SkipProbe bool
}
// StreamRequest contains the resolved parameters for creating a media stream.
type StreamRequest struct {
ID string
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"
)
// Decision represents the internal decision result.
// All bitrate values are in kilobits per second (kbps).
type Decision struct {
MediaID string
CanDirectPlay bool
CanTranscode bool
TranscodeReasons []string
ErrorReason string
TargetFormat string
TargetBitrate int
TargetChannels int
TargetSampleRate int
TargetBitDepth int
SourceStream StreamDetails
SourceUpdatedAt time.Time
TranscodeStream *StreamDetails
}
// StreamDetails describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type StreamDetails 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
}