mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
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:
parent
41141219d8
commit
a905a01008
69
core/transcode/codec_test.go
Normal file
69
core/transcode/codec_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
@ -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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
85
core/transcode/legacy_client.go
Normal file
85
core/transcode/legacy_client.go
Normal 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
155
core/transcode/token.go
Normal 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
core/transcode/token_test.go
Normal file
272
core/transcode/token_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user