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.
This commit is contained in:
Deluan 2026-03-08 21:20:07 -04:00
parent 41141219d8
commit a905a01008
7 changed files with 591 additions and 539 deletions

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"))
})
})
})

View File

@ -2,26 +2,27 @@ package transcode
import (
"context"
"errors"
"encoding/json"
"fmt"
"strings"
"time"
"encoding/json"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
)
const (
tokenTTL = 12 * time.Hour
defaultBitrate = 256 // kbps
)
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{
@ -422,123 +423,3 @@ func (s *deciderService) ensureProbed(ctx context.Context, mf *model.MediaFile)
"sampleRate", result.SampleRate, "bitDepth", result.BitDepth, "channels", result.Channels)
return result, nil
}
// 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
}
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
}

View File

@ -4,9 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-chi/jwtauth/v5"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/auth"
@ -1086,303 +1084,4 @@ var _ = Describe("Decider", func() {
})
})
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("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"))
})
})
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")))
})
})
})

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
}

155
core/transcode/token.go Normal file
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
}

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")))
})
})
})

View File

@ -1,13 +1,8 @@
package transcode
import (
"context"
"errors"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/model"
)
var (
@ -34,14 +29,6 @@ type StreamRequest struct {
Offset int // seconds
}
// 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
}
// ClientInfo represents client playback capabilities.
// All bitrate values are in kilobits per second (kbps)
type ClientInfo struct {
@ -131,35 +118,6 @@ type Decision struct {
TranscodeStream *StreamDetails
}
// 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
}
// StreamDetails describes audio stream properties.
// Bitrate is in kilobits per second (kbps).
type StreamDetails struct {
@ -174,70 +132,3 @@ type StreamDetails struct {
Size int64
IsLossless bool
}
// 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
}
// 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
}