navidrome/core/stream/decider.go
Deluan Quintão b293b96256
refactor(persistence): stateless repositories with per-call context (#6149)
* refactor(persistence): adopt generic deluan/rest repository API

Pin deluan/rest to the refactor branch. REST-facing repository methods
take a context and return typed values. Drop DataStore.Resource and
ResourceRepository; the native API names typed repositories directly
through a per-request adapter that later commits remove.

* refactor(persistence): base repository helpers take a context

* refactor(persistence): LibraryRepository takes a context per call

* refactor(persistence): PropertyRepository takes a context per call

* refactor(persistence): UserPropsRepository takes a context per call

* refactor(persistence): TranscodingRepository takes a context per call

* refactor(persistence): ShareRepository takes a context per call

* refactor(persistence): PlayerRepository takes a context per call

* refactor(persistence): RadioRepository takes a context per call

* refactor(persistence): PlayQueueRepository takes a context per call

* refactor(persistence): Tag and Genre repositories take a context per call

* refactor(persistence): PluginRepository takes a context per call

* refactor(persistence): Scrobble repositories take a context per call

* refactor(persistence): FolderRepository takes a context per call

* refactor(persistence): Artwork repositories take a context per call

* refactor(persistence): UserRepository takes a context per call

* refactor(persistence): ArtistRepository takes a context per call

ReadAll no longer rewrites the shared sort mappings for the role filter;
it works on a per-call copy.

* test(persistence): assert artist role sort sanitization in ReadAll

* refactor(persistence): AlbumRepository takes a context per call

* test(persistence): pass the test context to album repository helpers

* refactor(persistence): MediaFileRepository takes a context per call

* refactor(persistence): Playlist repositories take a context per call

* refactor(persistence): build all repositories once per store

* refactor(core): REST repository wrappers are built once

* refactor(persistence): repositories are stateless

Remove the context field from the base repository and the per-request
REST adapter. Enable the containedctx linter so no repository can hold a
request context again.

* chore(lint): skip containedctx in test files

* refactor: share simplifications from the stateless repositories sweep

Add deleteOwnedAll on sqlRepository and use it in player/share Delete
to remove the duplicated bulk-delete loop; have Share.Repository()
return model.ShareRepository so subsonic sharing.go drops its repeated
type assertions.

* chore(core): assert REST wrappers implement Persistable

* chore: reformat imports

* perf(persistence): build repositories on first use

Each transaction store used to construct all 21 repositories up front,
paying for filter and sort mapping setup the block never touched. Fields
are now sync.OnceValue thunks, so a store only builds what it uses.

* fix(persistence): clean plugin references per deleted user

A bulk user delete that fails on a later id had already removed the
earlier rows but skipped their plugin cleanup. Cleanup now runs right
after each successful delete.

* fix(core): unload disabled plugins even when a user delete fails

A bulk delete can fail on a later id after earlier users were removed
and their plugins auto-disabled. The wrapper returned before unloading,
leaving those plugins running until the next successful delete or a
restart.

* chore(deps): pin deluan/rest to v1.0.1

Replaces the pseudo-version of the refactor branch with the tagged
release. REST error messages now name the bare type (Artist, not
model.Artist).

* test: use the spec context instead of context.Background()

Replace the context.Background()/context.TODO() calls this branch added
to tests with the spec's ctx, GinkgoT().Context(), or t/b.Context(), so
repository calls are bound to the running spec's lifetime.

* test: declare the spec context once per Describe

Set ctx from GinkgoT().Context() first in each top-level BeforeEach and reuse it, building user contexts on top of it instead of repeating inline calls.
2026-09-25 18:06:10 -04:00

459 lines
17 KiB
Go

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"
"github.com/navidrome/navidrome/utils/gg"
)
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
ResolveClientRequest(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo, 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 {
if !s.ff.IsProbeAvailable() {
log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID)
} else {
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
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 = gg.V(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
}
// matchesPCMWAVBridge bridges Navidrome's internal "pcm" codec name with the
// "wav" codec name that browsers use to advertise audio/wav support. The match
// is scoped to WAV-container sources so AIFF files (which also normalize to
// codec "pcm" but use a different container) cannot false-match a codec-only
// ["wav"] profile.
func matchesPCMWAVBridge(src *Details, profile *DirectPlayProfile) bool {
return strings.EqualFold(src.Codec, "pcm") &&
strings.EqualFold(src.Container, "wav") &&
containsIgnoreCase(profile.AudioCodecs, "wav")
}
// 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 fmt.Sprintf("container '%s' not supported by profile %s", src.Container, profile)
}
// Check codec
if len(profile.AudioCodecs) > 0 && !matchesCodec(src.Codec, profile.AudioCodecs) && !matchesPCMWAVBridge(src, profile) {
return fmt.Sprintf("audio codec '%s' not supported by profile %s", src.Codec, profile)
}
// Check channels
if profile.MaxAudioChannels > 0 && src.Channels > profile.MaxAudioChannels {
return fmt.Sprintf("audio channels %d not supported by profile %s (max %d)", src.Channels, profile, profile.MaxAudioChannels)
}
// 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: targetBitDepth(src.BitDepth, src.Codec, targetIsLossless),
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
}
if maxCh := codecMaxChannels(ts.Codec); maxCh > 0 && ts.Channels > maxCh {
ts.Channels = maxCh
}
// 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. Compare against the
// already-clamped ts.Channels (not src.Channels) so the codec hard limit
// applied above is never raised by a looser profile setting.
if profile.MaxAudioChannels > 0 && ts.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().FindByFormat(ctx, 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().FindByFormat(ctx, 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().UpdateProbeData(ctx, 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
}