mirror of
https://github.com/navidrome/navidrome.git
synced 2026-02-07 13:31:10 -05:00
Compare commits
22 Commits
v0.60.0
...
transcodin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d684d0bee | ||
|
|
9eb5fdc067 | ||
|
|
6e2a9974f3 | ||
|
|
864eeae858 | ||
|
|
92e10b3e6c | ||
|
|
52fcf5b059 | ||
|
|
200d943155 | ||
|
|
970aa8f3f5 | ||
|
|
dd0eb9b7f3 | ||
|
|
4c6708ed11 | ||
|
|
64b229270b | ||
|
|
d9c487e549 | ||
|
|
6fb4cd277e | ||
|
|
e11206f0ee | ||
|
|
b4e03673ba | ||
|
|
01c839d9be | ||
|
|
2731e25fd2 | ||
|
|
4f3845bbe3 | ||
|
|
e8863ed147 | ||
|
|
19ea338bed | ||
|
|
338853468f | ||
|
|
4e720ee931 |
@@ -15,4 +15,5 @@ dist
|
||||
binaries
|
||||
cache
|
||||
music
|
||||
music.old
|
||||
!Dockerfile
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ cache/*
|
||||
coverage.out
|
||||
dist
|
||||
music
|
||||
music.old
|
||||
*.db*
|
||||
.gitinfo
|
||||
docker-compose.yml
|
||||
|
||||
@@ -13,12 +13,15 @@ package gotaglib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
@@ -61,6 +64,7 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
Channels: int(props.Channels),
|
||||
SampleRate: int(props.SampleRate),
|
||||
BitDepth: int(props.BitsPerSample),
|
||||
Codec: props.Codec,
|
||||
}
|
||||
|
||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||
@@ -94,7 +98,17 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||
|
||||
// openFile opens the file at filePath using the extractor's filesystem.
|
||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
||||
func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
|
||||
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
||||
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
|
||||
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
|
||||
debug.SetPanicOnFault(true)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
|
||||
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// Open the file from the filesystem
|
||||
file, err := e.fs.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -105,12 +119,12 @@ func (e extractor) openFile(filePath string) (*taglib.File, func(), error) {
|
||||
file.Close()
|
||||
return nil, nil, errors.New("file is not seekable")
|
||||
}
|
||||
f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
f, err = taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
closeFunc := func() {
|
||||
closeFunc = func() {
|
||||
f.Close()
|
||||
file.Close()
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ var ignoredContent = []string{
|
||||
`<a href="https://www.last.fm/music/`,
|
||||
}
|
||||
|
||||
var lastFMReadMoreRegex = regexp.MustCompile(`\s*<a href="https://www\.last\.fm/music/[^"]*">Read more on Last\.fm</a>\.?`)
|
||||
|
||||
func cleanContent(content string) string {
|
||||
return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
|
||||
}
|
||||
|
||||
type lastfmAgent struct {
|
||||
ds model.DataStore
|
||||
sessionKeys *agents.SessionKeys
|
||||
@@ -95,7 +101,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
||||
resp.MBID = a.MBID
|
||||
resp.URL = a.URL
|
||||
if isValidContent(a.Description.Summary) {
|
||||
resp.Description = strings.TrimSpace(a.Description.Summary)
|
||||
resp.Description = cleanContent(a.Description.Summary)
|
||||
return &resp, nil
|
||||
}
|
||||
log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
|
||||
@@ -171,7 +177,7 @@ func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid str
|
||||
return "", err
|
||||
}
|
||||
if isValidContent(a.Bio.Summary) {
|
||||
return strings.TrimSpace(a.Bio.Summary), nil
|
||||
return cleanContent(a.Bio.Summary), nil
|
||||
}
|
||||
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
It("returns the biography", func() {
|
||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. <a href=\"https://www.last.fm/music/U2\">Read more on Last.fm</a>"))
|
||||
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
@@ -535,7 +535,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
|
||||
Name: "Believe",
|
||||
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
|
||||
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob <a href=\"https://www.last.fm/music/Cher/Believe\">Read more on Last.fm</a>.",
|
||||
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
|
||||
URL: "https://www.last.fm/music/Cher/Believe",
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
@@ -102,7 +103,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
||||
decider := transcode.NewDecider(dataStore)
|
||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics, decider)
|
||||
return router
|
||||
}
|
||||
|
||||
|
||||
689
core/transcode/transcode.go
Normal file
689
core/transcode/transcode.go
Normal file
@@ -0,0 +1,689 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
const (
|
||||
tokenTTL = 12 * time.Hour
|
||||
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) (*Decision, error)
|
||||
CreateTranscodeParams(decision *Decision) (string, error)
|
||||
ParseTranscodeParams(token string) (*Params, error)
|
||||
}
|
||||
|
||||
// ClientInfo represents client playback capabilities.
|
||||
// All bitrate values are in kilobits per second (kbps)
|
||||
type ClientInfo struct {
|
||||
Name string
|
||||
Platform string
|
||||
MaxAudioBitrate int
|
||||
MaxTranscodingAudioBitrate int
|
||||
DirectPlayProfiles []DirectPlayProfile
|
||||
TranscodingProfiles []Profile
|
||||
CodecProfiles []CodecProfile
|
||||
}
|
||||
|
||||
// DirectPlayProfile describes a format the client can play directly
|
||||
type DirectPlayProfile struct {
|
||||
Containers []string
|
||||
AudioCodecs []string
|
||||
Protocols []string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// Profile describes a transcoding target the client supports
|
||||
type Profile struct {
|
||||
Container string
|
||||
AudioCodec string
|
||||
Protocol string
|
||||
MaxAudioChannels int
|
||||
}
|
||||
|
||||
// CodecProfile describes codec-specific limitations
|
||||
type CodecProfile struct {
|
||||
Type string
|
||||
Name string
|
||||
Limitations []Limitation
|
||||
}
|
||||
|
||||
// Limitation describes a specific codec limitation
|
||||
type Limitation struct {
|
||||
Name string
|
||||
Comparison string
|
||||
Values []string
|
||||
Required bool
|
||||
}
|
||||
|
||||
// Protocol values (OpenSubsonic spec enum)
|
||||
const (
|
||||
ProtocolHTTP = "http"
|
||||
ProtocolHLS = "hls"
|
||||
)
|
||||
|
||||
// Comparison operators (OpenSubsonic spec enum)
|
||||
const (
|
||||
ComparisonEquals = "Equals"
|
||||
ComparisonNotEquals = "NotEquals"
|
||||
ComparisonLessThanEqual = "LessThanEqual"
|
||||
ComparisonGreaterThanEqual = "GreaterThanEqual"
|
||||
)
|
||||
|
||||
// Limitation names (OpenSubsonic spec enum)
|
||||
const (
|
||||
LimitationAudioChannels = "audioChannels"
|
||||
LimitationAudioBitrate = "audioBitrate"
|
||||
LimitationAudioProfile = "audioProfile"
|
||||
LimitationAudioSamplerate = "audioSamplerate"
|
||||
LimitationAudioBitdepth = "audioBitdepth"
|
||||
)
|
||||
|
||||
// Codec profile types (OpenSubsonic spec enum)
|
||||
const (
|
||||
CodecProfileTypeAudio = "AudioCodec"
|
||||
)
|
||||
|
||||
// Decision represents the internal decision result.
|
||||
// All bitrate values are in kilobits per second (kbps).
|
||||
type Decision struct {
|
||||
MediaID string
|
||||
CanDirectPlay bool
|
||||
CanTranscode bool
|
||||
TranscodeReasons []string
|
||||
ErrorReason string
|
||||
TargetFormat string
|
||||
TargetBitrate int
|
||||
TargetChannels int
|
||||
SourceStream StreamDetails
|
||||
TranscodeStream *StreamDetails
|
||||
}
|
||||
|
||||
// StreamDetails describes audio stream properties.
|
||||
// Bitrate is in kilobits per second (kbps).
|
||||
type StreamDetails struct {
|
||||
Container string
|
||||
Codec string
|
||||
Profile string // Audio profile (e.g., "LC", "HE-AAC"). Empty until scanner support is added.
|
||||
Bitrate int
|
||||
SampleRate int
|
||||
BitDepth int
|
||||
Channels int
|
||||
Duration float32
|
||||
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
|
||||
}
|
||||
|
||||
func NewDecider(ds model.DataStore) Decider {
|
||||
return &deciderService{
|
||||
ds: ds,
|
||||
}
|
||||
}
|
||||
|
||||
type deciderService struct {
|
||||
ds model.DataStore
|
||||
}
|
||||
|
||||
func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, clientInfo *ClientInfo) (*Decision, error) {
|
||||
decision := &Decision{
|
||||
MediaID: mf.ID,
|
||||
}
|
||||
|
||||
sourceBitrate := mf.BitRate // kbps
|
||||
|
||||
log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", mf.Suffix,
|
||||
"codec", mf.AudioCodec(), "bitrate", sourceBitrate, "channels", mf.Channels,
|
||||
"sampleRate", mf.SampleRate, "lossless", mf.IsLossless(), "client", clientInfo.Name)
|
||||
|
||||
// Build source stream details
|
||||
decision.SourceStream = StreamDetails{
|
||||
Container: mf.Suffix,
|
||||
Codec: mf.AudioCodec(),
|
||||
Bitrate: sourceBitrate,
|
||||
SampleRate: mf.SampleRate,
|
||||
BitDepth: mf.BitDepth,
|
||||
Channels: mf.Channels,
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
IsLossless: mf.IsLossless(),
|
||||
}
|
||||
|
||||
// Check global bitrate constraint first.
|
||||
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Global bitrate constraint exceeded, skipping direct play",
|
||||
"sourceBitrate", sourceBitrate, "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(mf, sourceBitrate, &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", mf.Suffix, "codec", mf.AudioCodec())
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// Try transcoding profiles (in order of preference)
|
||||
for _, profile := range clientInfo.TranscodingProfiles {
|
||||
if ts := s.computeTranscodedStream(ctx, mf, sourceBitrate, &profile, clientInfo); ts != nil {
|
||||
decision.CanTranscode = true
|
||||
decision.TargetFormat = ts.Container
|
||||
decision.TargetBitrate = ts.Bitrate
|
||||
decision.TargetChannels = ts.Channels
|
||||
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", mf.Suffix, "codec", mf.AudioCodec(), "reasons", decision.TranscodeReasons)
|
||||
}
|
||||
|
||||
return decision, nil
|
||||
}
|
||||
|
||||
// checkDirectPlayProfile returns "" if the profile matches (direct play OK),
|
||||
// or a typed reason string if it doesn't match.
|
||||
func (s *deciderService) checkDirectPlayProfile(mf *model.MediaFile, sourceBitrate int, 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(mf.Suffix, profile.Containers) {
|
||||
return "container not supported"
|
||||
}
|
||||
|
||||
// Check codec
|
||||
if len(profile.AudioCodecs) > 0 && !matchesCodec(mf.AudioCodec(), profile.AudioCodecs) {
|
||||
return "audio codec not supported"
|
||||
}
|
||||
|
||||
// Check channels
|
||||
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
|
||||
return "audio channels not supported"
|
||||
}
|
||||
|
||||
// Check codec-specific limitations
|
||||
for _, codecProfile := range clientInfo.CodecProfiles {
|
||||
if strings.EqualFold(codecProfile.Type, CodecProfileTypeAudio) && matchesCodec(mf.AudioCodec(), []string{codecProfile.Name}) {
|
||||
if reason := checkLimitations(mf, sourceBitrate, codecProfile.Limitations); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// checkLimitations checks codec profile limitations against source media.
|
||||
// Returns "" if all limitations pass, or a typed reason string for the first failure.
|
||||
func checkLimitations(mf *model.MediaFile, sourceBitrate int, limitations []Limitation) string {
|
||||
for _, lim := range limitations {
|
||||
var ok bool
|
||||
var reason string
|
||||
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
ok = checkIntLimitation(mf.Channels, lim.Comparison, lim.Values)
|
||||
reason = "audio channels not supported"
|
||||
case LimitationAudioSamplerate:
|
||||
ok = checkIntLimitation(mf.SampleRate, lim.Comparison, lim.Values)
|
||||
reason = "audio samplerate not supported"
|
||||
case LimitationAudioBitrate:
|
||||
ok = checkIntLimitation(sourceBitrate, lim.Comparison, lim.Values)
|
||||
reason = "audio bitrate not supported"
|
||||
case LimitationAudioBitdepth:
|
||||
ok = checkIntLimitation(mf.BitDepth, lim.Comparison, lim.Values)
|
||||
reason = "audio bitdepth not supported"
|
||||
case LimitationAudioProfile:
|
||||
// TODO: populate source profile when MediaFile has audio profile info
|
||||
ok = checkStringLimitation("", lim.Comparison, lim.Values)
|
||||
reason = "audio profile not supported"
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
if !ok && lim.Required {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// adjustResult represents the outcome of applying a limitation to a transcoded stream value
|
||||
type adjustResult int
|
||||
|
||||
const (
|
||||
adjustNone adjustResult = iota // Value already satisfies the limitation
|
||||
adjustAdjusted // Value was changed to fit the limitation
|
||||
adjustCannotFit // Cannot satisfy the limitation (reject this profile)
|
||||
)
|
||||
|
||||
// computeTranscodedStream attempts to build a valid transcoded stream for the given profile.
|
||||
// Returns nil if the profile cannot produce a valid output.
|
||||
func (s *deciderService) computeTranscodedStream(ctx context.Context, mf *model.MediaFile, sourceBitrate int, profile *Profile, clientInfo *ClientInfo) *StreamDetails {
|
||||
// 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
|
||||
}
|
||||
|
||||
targetFormat := strings.ToLower(profile.Container)
|
||||
if targetFormat == "" {
|
||||
targetFormat = strings.ToLower(profile.AudioCodec)
|
||||
}
|
||||
|
||||
// Verify we have a transcoding config for this format
|
||||
tc, err := s.ds.Transcoding(ctx).FindByFormat(targetFormat)
|
||||
if err != nil || tc == nil {
|
||||
log.Trace(ctx, "Skipping transcoding profile: no transcoding config", "targetFormat", targetFormat)
|
||||
return nil
|
||||
}
|
||||
|
||||
targetIsLossless := isLosslessFormat(targetFormat)
|
||||
|
||||
// Reject lossy to lossless conversion
|
||||
if !mf.IsLossless() && targetIsLossless {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossy to lossless not allowed", "targetFormat", targetFormat)
|
||||
return nil
|
||||
}
|
||||
|
||||
ts := &StreamDetails{
|
||||
Container: targetFormat,
|
||||
Codec: strings.ToLower(profile.AudioCodec),
|
||||
SampleRate: mf.SampleRate,
|
||||
Channels: mf.Channels,
|
||||
IsLossless: targetIsLossless,
|
||||
}
|
||||
if ts.Codec == "" {
|
||||
ts.Codec = targetFormat
|
||||
}
|
||||
|
||||
// Determine target bitrate (all in kbps)
|
||||
if mf.IsLossless() {
|
||||
if !targetIsLossless {
|
||||
// Lossless to lossy: use client's max transcoding bitrate or default
|
||||
if clientInfo.MaxTranscodingAudioBitrate > 0 {
|
||||
ts.Bitrate = clientInfo.MaxTranscodingAudioBitrate
|
||||
} else {
|
||||
ts.Bitrate = defaultBitrate
|
||||
}
|
||||
} else {
|
||||
// Lossless to lossless: check if bitrate is under the global max
|
||||
if clientInfo.MaxAudioBitrate > 0 && sourceBitrate > clientInfo.MaxAudioBitrate {
|
||||
log.Trace(ctx, "Skipping transcoding profile: lossless target exceeds bitrate limit",
|
||||
"targetFormat", targetFormat, "sourceBitrate", sourceBitrate, "maxAudioBitrate", clientInfo.MaxAudioBitrate)
|
||||
return nil
|
||||
}
|
||||
// No explicit bitrate for lossless target (leave 0)
|
||||
}
|
||||
} else {
|
||||
// Lossy to lossy: preserve source bitrate
|
||||
ts.Bitrate = sourceBitrate
|
||||
}
|
||||
|
||||
// Apply maxAudioBitrate as final cap on transcoded stream (#5)
|
||||
if clientInfo.MaxAudioBitrate > 0 && ts.Bitrate > 0 && ts.Bitrate > clientInfo.MaxAudioBitrate {
|
||||
ts.Bitrate = clientInfo.MaxAudioBitrate
|
||||
}
|
||||
|
||||
// Apply MaxAudioChannels from the transcoding profile
|
||||
if profile.MaxAudioChannels > 0 && mf.Channels > profile.MaxAudioChannels {
|
||||
ts.Channels = profile.MaxAudioChannels
|
||||
}
|
||||
|
||||
// Apply codec profile limitations to the TARGET codec (#4)
|
||||
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)
|
||||
// For lossless codecs, adjusting bitrate is not valid
|
||||
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 nil
|
||||
}
|
||||
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 nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
// applyLimitation adjusts a transcoded stream parameter to satisfy the limitation.
|
||||
// Returns the adjustment result.
|
||||
func applyLimitation(sourceBitrate int, lim *Limitation, ts *StreamDetails) adjustResult {
|
||||
switch lim.Name {
|
||||
case LimitationAudioChannels:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.Channels, func(v int) { ts.Channels = v })
|
||||
case LimitationAudioBitrate:
|
||||
current := ts.Bitrate
|
||||
if current == 0 {
|
||||
current = sourceBitrate
|
||||
}
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, current, func(v int) { ts.Bitrate = v })
|
||||
case LimitationAudioSamplerate:
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.SampleRate, func(v int) { ts.SampleRate = v })
|
||||
case LimitationAudioBitdepth:
|
||||
if ts.BitDepth > 0 {
|
||||
return applyIntLimitation(lim.Comparison, lim.Values, ts.BitDepth, func(v int) { ts.BitDepth = v })
|
||||
}
|
||||
case LimitationAudioProfile:
|
||||
// TODO: implement when audio profile data is available
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
// applyIntLimitation applies a limitation comparison to a value.
|
||||
// If the value needs adjusting, calls the setter and returns the result.
|
||||
func applyIntLimitation(comparison string, values []string, current int, setter func(int)) adjustResult {
|
||||
if len(values) == 0 {
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current <= limit {
|
||||
return adjustNone
|
||||
}
|
||||
setter(limit)
|
||||
return adjustAdjusted
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return adjustNone
|
||||
}
|
||||
if current >= limit {
|
||||
return adjustNone
|
||||
}
|
||||
// Cannot upscale
|
||||
return adjustCannotFit
|
||||
case ComparisonEquals:
|
||||
// Check if current value matches any allowed value
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustNone
|
||||
}
|
||||
}
|
||||
// Find the closest allowed value below current (don't upscale)
|
||||
var closest int
|
||||
found := false
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && limit < current {
|
||||
if !found || limit > closest {
|
||||
closest = limit
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
setter(closest)
|
||||
return adjustAdjusted
|
||||
}
|
||||
return adjustCannotFit
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && current == limit {
|
||||
return adjustCannotFit
|
||||
}
|
||||
}
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
return adjustNone
|
||||
}
|
||||
|
||||
func (s *deciderService) CreateTranscodeParams(decision *Decision) (string, error) {
|
||||
exp := time.Now().Add(tokenTTL)
|
||||
claims := map[string]any{
|
||||
"mid": decision.MediaID,
|
||||
"dp": decision.CanDirectPlay,
|
||||
}
|
||||
if decision.CanTranscode && decision.TargetFormat != "" {
|
||||
claims["fmt"] = decision.TargetFormat
|
||||
claims["br"] = decision.TargetBitrate
|
||||
if decision.TargetChannels > 0 {
|
||||
claims["ch"] = decision.TargetChannels
|
||||
}
|
||||
}
|
||||
return auth.CreateExpiringPublicToken(exp, claims)
|
||||
}
|
||||
|
||||
func (s *deciderService) ParseTranscodeParams(token string) (*Params, error) {
|
||||
claims, err := auth.Validate(token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
params := &Params{}
|
||||
if mid, ok := claims["mid"].(string); ok {
|
||||
params.MediaID = mid
|
||||
}
|
||||
if dp, ok := claims["dp"].(bool); ok {
|
||||
params.DirectPlay = dp
|
||||
}
|
||||
if fmt, ok := claims["fmt"].(string); ok {
|
||||
params.TargetFormat = fmt
|
||||
}
|
||||
if br, ok := claims["br"].(float64); ok {
|
||||
params.TargetBitrate = int(br)
|
||||
}
|
||||
if ch, ok := claims["ch"].(float64); ok {
|
||||
params.TargetChannels = int(ch)
|
||||
}
|
||||
|
||||
return params, nil
|
||||
}
|
||||
|
||||
func containsIgnoreCase(slice []string, s string) bool {
|
||||
return slices.ContainsFunc(slice, func(item string) bool {
|
||||
return strings.EqualFold(item, s)
|
||||
})
|
||||
}
|
||||
|
||||
// containerAliasGroups maps each container alias to a canonical group name.
|
||||
var containerAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts", "m4a", "mp4", "m4b", "m4p"},
|
||||
{"mpeg", "mp3", "mp2"},
|
||||
{"ogg", "oga"},
|
||||
{"aif", "aiff"},
|
||||
{"asf", "wma"},
|
||||
{"mpc", "mpp"},
|
||||
{"wv"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
canonical := g[0]
|
||||
for _, name := range g {
|
||||
m[name] = canonical
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// matchesWithAliases checks if a value matches any entry in candidates,
|
||||
// consulting the alias map for equivalent names.
|
||||
func matchesWithAliases(value string, candidates []string, aliases map[string]string) bool {
|
||||
value = strings.ToLower(value)
|
||||
canonical := aliases[value]
|
||||
for _, c := range candidates {
|
||||
c = strings.ToLower(c)
|
||||
if c == value {
|
||||
return true
|
||||
}
|
||||
if canonical != "" && aliases[c] == canonical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesContainer checks if a file suffix matches any of the container names,
|
||||
// including common aliases.
|
||||
func matchesContainer(suffix string, containers []string) bool {
|
||||
return matchesWithAliases(suffix, containers, containerAliasGroups)
|
||||
}
|
||||
|
||||
// codecAliasGroups maps each codec alias to a canonical group name.
|
||||
// Codecs within the same group are considered equivalent.
|
||||
var codecAliasGroups = func() map[string]string {
|
||||
groups := [][]string{
|
||||
{"aac", "adts"},
|
||||
{"ac3", "ac-3"},
|
||||
{"eac3", "e-ac3", "e-ac-3", "eac-3"},
|
||||
{"mpc7", "musepack7"},
|
||||
{"mpc8", "musepack8"},
|
||||
{"wma1", "wmav1"},
|
||||
{"wma2", "wmav2"},
|
||||
{"wmalossless", "wma9lossless"},
|
||||
{"wmapro", "wma9pro"},
|
||||
{"shn", "shorten"},
|
||||
{"mp4als", "als"},
|
||||
}
|
||||
m := make(map[string]string)
|
||||
for _, g := range groups {
|
||||
for _, name := range g {
|
||||
m[name] = g[0] // canonical = first entry
|
||||
}
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// matchesCodec checks if a codec matches any of the codec names,
|
||||
// including common aliases.
|
||||
func matchesCodec(codec string, codecs []string) bool {
|
||||
return matchesWithAliases(codec, codecs, codecAliasGroups)
|
||||
}
|
||||
|
||||
func checkIntLimitation(value int, comparison string, values []string) bool {
|
||||
if len(values) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
switch comparison {
|
||||
case ComparisonLessThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return value <= limit
|
||||
case ComparisonGreaterThanEqual:
|
||||
limit, ok := parseInt(values[0])
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return value >= limit
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && value == limit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if limit, ok := parseInt(v); ok && value == limit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// checkStringLimitation checks a string value against a limitation.
|
||||
// Only Equals and NotEquals comparisons are meaningful for strings.
|
||||
// LessThanEqual/GreaterThanEqual are not applicable and always pass.
|
||||
func checkStringLimitation(value string, comparison string, values []string) bool {
|
||||
switch comparison {
|
||||
case ComparisonEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case ComparisonNotEquals:
|
||||
for _, v := range values {
|
||||
if strings.EqualFold(value, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, bool) {
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
func isLosslessFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "flac", "alac", "wav", "aiff", "ape", "wv", "tta", "tak", "shn", "dsd":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
17
core/transcode/transcode_suite_test.go
Normal file
17
core/transcode/transcode_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestTranscode(t *testing.T) {
|
||||
tests.Init(t, false)
|
||||
log.SetLevel(log.LevelFatal)
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Transcode Suite")
|
||||
}
|
||||
657
core/transcode/transcode_test.go
Normal file
657
core/transcode/transcode_test.go
Normal file
@@ -0,0 +1,657 @@
|
||||
package transcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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("Decider", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
svc Decider
|
||||
ctx context.Context
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
ds = &tests.MockDataStore{
|
||||
MockedProperty: &tests.MockedPropertyRepo{},
|
||||
MockedTranscoding: &tests.MockTranscodingRepo{},
|
||||
}
|
||||
auth.Init(ds)
|
||||
svc = NewDecider(ds)
|
||||
})
|
||||
|
||||
Describe("MakeDecision", func() {
|
||||
Context("Direct Play", func() {
|
||||
It("allows direct play when profile matches", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}, MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects direct play when container doesn't match", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
|
||||
})
|
||||
|
||||
It("rejects direct play when codec doesn't match", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "ALAC", BitRate: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio codec not supported"))
|
||||
})
|
||||
|
||||
It("rejects direct play when channels exceed limit", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}, MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio channels not supported"))
|
||||
})
|
||||
|
||||
It("handles container aliases (aac -> m4a)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"aac"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("handles container aliases (mp4 -> m4a)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("handles codec aliases (adts -> aac)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"m4a"}, AudioCodecs: []string{"adts"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("allows when protocol list is empty (any protocol)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, AudioCodecs: []string{"flac"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("allows when both container and codec lists are empty (wildcard)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{}, AudioCodecs: []string{}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("MaxAudioBitrate constraint", func() {
|
||||
It("revokes direct play when bitrate exceeds maxAudioBitrate", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1500, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
MaxAudioBitrate: 500, // kbps
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Transcoding", func() {
|
||||
It("selects transcoding when direct play isn't possible", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 256, // kbps
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||
Expect(decision.TargetBitrate).To(Equal(256)) // kbps
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("container not supported"))
|
||||
})
|
||||
|
||||
It("rejects lossy to lossless transcoding", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "flac", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
})
|
||||
|
||||
It("uses default bitrate when client doesn't specify", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, BitDepth: 16}
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetBitrate).To(Equal(defaultBitrate)) // 256 kbps
|
||||
})
|
||||
|
||||
It("preserves lossy bitrate when under max", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "ogg", BitRate: 192, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 256, // kbps
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetBitrate).To(Equal(192)) // source bitrate in kbps
|
||||
})
|
||||
|
||||
It("rejects unsupported transcoding format", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "aac", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
})
|
||||
|
||||
It("applies maxAudioBitrate as final cap on transcoded stream", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2}
|
||||
ci := &ClientInfo{
|
||||
MaxAudioBitrate: 96, // kbps
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetBitrate).To(Equal(96)) // capped by maxAudioBitrate
|
||||
})
|
||||
|
||||
It("selects first valid transcoding profile in order", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 48000, BitDepth: 16}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "opus", AudioCodec: "opus", Protocol: "http"},
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http", MaxAudioChannels: 2},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("opus"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Lossless to lossless transcoding", func() {
|
||||
It("allows lossless to lossless when samplerate needs downsampling", func() {
|
||||
// MockTranscodingRepo doesn't support "flac" format, so this would fail to find a config.
|
||||
// This test documents the behavior: lossless→lossless requires server transcoding config.
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "dsf", Codec: "DSD", BitRate: 5644, Channels: 2, SampleRate: 176400, BitDepth: 1}
|
||||
ci := &ClientInfo{
|
||||
MaxAudioBitrate: 1000,
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TargetFormat).To(Equal("mp3"))
|
||||
})
|
||||
|
||||
It("sets IsLossless=true on transcoded stream when target is lossless", func() {
|
||||
// Simulate DSD→FLAC transcoding by using a mock that supports "flac"
|
||||
mockTranscoding := &tests.MockTranscodingRepo{}
|
||||
ds.MockedTranscoding = mockTranscoding
|
||||
svc = NewDecider(ds)
|
||||
|
||||
// MockTranscodingRepo doesn't support flac, so this will skip lossless profile.
|
||||
// Use mp3 which is supported as the fallback.
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.IsLossless).To(BeFalse()) // mp3 is lossy
|
||||
})
|
||||
})
|
||||
|
||||
Context("No compatible profile", func() {
|
||||
It("returns error when nothing matches", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6}
|
||||
ci := &ClientInfo{}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
Expect(decision.ErrorReason).To(Equal("no compatible playback profile found"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Codec limitations on direct play", func() {
|
||||
It("rejects direct play when codec limitation fails (required)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio bitrate not supported"))
|
||||
})
|
||||
|
||||
It("allows direct play when optional limitation fails", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 512, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"320"}, Required: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("handles Equals comparison with multiple values", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "flac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects when Equals comparison doesn't match any value", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "flac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioChannels, Comparison: ComparisonEquals, Values: []string{"1", "2"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
})
|
||||
|
||||
It("rejects direct play when audioProfile limitation fails (required)", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "aac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
// Source profile is empty (not yet populated from scanner), so Equals("LC") fails
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio profile not supported"))
|
||||
})
|
||||
|
||||
It("allows direct play when audioProfile limitation is optional", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "m4a", Codec: "AAC", BitRate: 256, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"m4a"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "aac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioProfile, Comparison: ComparisonEquals, Values: []string{"LC"}, Required: false},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeTrue())
|
||||
})
|
||||
|
||||
It("rejects direct play due to samplerate limitation", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "flac",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(ContainElement("audio samplerate not supported"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Codec limitations on transcoded output", func() {
|
||||
It("applies bitrate limitation to transcoded stream", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 192, Channels: 2, SampleRate: 44100}
|
||||
ci := &ClientInfo{
|
||||
MaxAudioBitrate: 96, // force transcode
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioBitrate, Comparison: ComparisonLessThanEqual, Values: []string{"96"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.Bitrate).To(Equal(96))
|
||||
})
|
||||
|
||||
It("applies channel limitation to transcoded stream", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 6, SampleRate: 48000, BitDepth: 16}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioChannels, Comparison: ComparisonLessThanEqual, Values: []string{"2"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.Channels).To(Equal(2))
|
||||
})
|
||||
|
||||
It("applies samplerate limitation to transcoded stream", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioSamplerate, Comparison: ComparisonLessThanEqual, Values: []string{"48000"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeTrue())
|
||||
Expect(decision.TranscodeStream.SampleRate).To(Equal(48000))
|
||||
})
|
||||
|
||||
It("rejects transcoding profile when GreaterThanEqual cannot be satisfied", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}
|
||||
ci := &ClientInfo{
|
||||
MaxTranscodingAudioBitrate: 320,
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
CodecProfiles: []CodecProfile{
|
||||
{
|
||||
Type: CodecProfileTypeAudio,
|
||||
Name: "mp3",
|
||||
Limitations: []Limitation{
|
||||
{Name: LimitationAudioSamplerate, Comparison: ComparisonGreaterThanEqual, Values: []string{"96000"}, Required: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanTranscode).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("Typed transcode reasons from multiple profiles", func() {
|
||||
It("collects reasons from each failed direct play profile", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "ogg", Codec: "Vorbis", BitRate: 128, Channels: 2, SampleRate: 48000}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
{Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{"http"}},
|
||||
{Containers: []string{"m4a", "mp4"}, AudioCodecs: []string{"aac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
TranscodingProfiles: []Profile{
|
||||
{Container: "mp3", AudioCodec: "mp3", Protocol: "http"},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.CanDirectPlay).To(BeFalse())
|
||||
Expect(decision.TranscodeReasons).To(HaveLen(3))
|
||||
Expect(decision.TranscodeReasons[0]).To(Equal("container not supported"))
|
||||
Expect(decision.TranscodeReasons[1]).To(Equal("container not supported"))
|
||||
Expect(decision.TranscodeReasons[2]).To(Equal("container not supported"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Source stream details", func() {
|
||||
It("populates source stream correctly with kbps bitrate", func() {
|
||||
mf := &model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24, Duration: 300.5, Size: 50000000}
|
||||
ci := &ClientInfo{
|
||||
DirectPlayProfiles: []DirectPlayProfile{
|
||||
{Containers: []string{"flac"}, Protocols: []string{"http"}},
|
||||
},
|
||||
}
|
||||
decision, err := svc.MakeDecision(ctx, mf, ci)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(decision.SourceStream.Container).To(Equal("flac"))
|
||||
Expect(decision.SourceStream.Codec).To(Equal("flac"))
|
||||
Expect(decision.SourceStream.Bitrate).To(Equal(1000)) // kbps
|
||||
Expect(decision.SourceStream.SampleRate).To(Equal(96000))
|
||||
Expect(decision.SourceStream.BitDepth).To(Equal(24))
|
||||
Expect(decision.SourceStream.Channels).To(Equal(2))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Token round-trip", func() {
|
||||
It("creates and parses a direct play token", func() {
|
||||
decision := &Decision{
|
||||
MediaID: "media-123",
|
||||
CanDirectPlay: true,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(token).ToNot(BeEmpty())
|
||||
|
||||
params, err := svc.ParseTranscodeParams(token)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(params.MediaID).To(Equal("media-123"))
|
||||
Expect(params.DirectPlay).To(BeTrue())
|
||||
Expect(params.TargetFormat).To(BeEmpty())
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
token, err := svc.CreateTranscodeParams(decision)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
params, err := svc.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))
|
||||
})
|
||||
|
||||
It("rejects an invalid token", func() {
|
||||
_, err := svc.ParseTranscodeParams("invalid-token")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
)
|
||||
|
||||
var Set = wire.NewSet(
|
||||
@@ -20,6 +21,7 @@ var Set = wire.NewSet(
|
||||
NewLibrary,
|
||||
NewUser,
|
||||
NewMaintenance,
|
||||
transcode.NewDecider,
|
||||
agents.GetAgents,
|
||||
external.NewProvider,
|
||||
wire.Bind(new(external.Agents), new(*agents.Agents)),
|
||||
|
||||
11
db/migrations/20260205120000_add_codec_to_media_file.sql
Normal file
11
db/migrations/20260205120000_add_codec_to_media_file.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE media_file ADD COLUMN codec VARCHAR(255) DEFAULT '' NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS media_file_codec ON media_file(codec);
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS media_file_codec;
|
||||
ALTER TABLE media_file DROP COLUMN codec;
|
||||
-- +goose StatementEnd
|
||||
2
go.mod
2
go.mod
@@ -7,7 +7,7 @@ replace (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
|
||||
|
||||
// Fork to implement raw tags support
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798
|
||||
go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
4
go.sum
4
go.sum
@@ -36,8 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798 h1:q4fvcIK/LxElpyQILCejG6WPYjVb2F/4P93+k017ANk=
|
||||
github.com/deluan/go-taglib v0.0.0-20260119020817-8753c7531798/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57 h1:SXIwfjzTv0UzoUWpFREl8p3AxXVLmbcto1/ISih11a0=
|
||||
github.com/deluan/go-taglib v0.0.0-20260205042457-5d80806aee57/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4=
|
||||
github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8=
|
||||
github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4=
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
confmime "github.com/navidrome/navidrome/conf/mime"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/utils"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -56,6 +57,7 @@ type MediaFile struct {
|
||||
SampleRate int `structs:"sample_rate" json:"sampleRate"`
|
||||
BitDepth int `structs:"bit_depth" json:"bitDepth"`
|
||||
Channels int `structs:"channels" json:"channels"`
|
||||
Codec string `structs:"codec" json:"codec"`
|
||||
Genre string `structs:"genre" json:"genre"`
|
||||
Genres Genres `structs:"-" json:"genres,omitempty"`
|
||||
SortTitle string `structs:"sort_title" json:"sortTitle,omitempty"`
|
||||
@@ -161,6 +163,79 @@ func (mf MediaFile) AbsolutePath() string {
|
||||
return filepath.Join(mf.LibraryPath, mf.Path)
|
||||
}
|
||||
|
||||
// AudioCodec returns the audio codec for this file.
|
||||
// Uses the stored Codec field if available, otherwise infers from Suffix and audio properties.
|
||||
func (mf MediaFile) AudioCodec() string {
|
||||
// If we have a stored codec from scanning, normalize and return it
|
||||
if mf.Codec != "" {
|
||||
return strings.ToLower(mf.Codec)
|
||||
}
|
||||
// Fallback: infer from Suffix + BitDepth
|
||||
return mf.inferCodecFromSuffix()
|
||||
}
|
||||
|
||||
// inferCodecFromSuffix infers the codec from the file extension when Codec field is empty.
|
||||
func (mf MediaFile) inferCodecFromSuffix() string {
|
||||
switch strings.ToLower(mf.Suffix) {
|
||||
case "mp3", "mpga":
|
||||
return "mp3"
|
||||
case "mp2":
|
||||
return "mp2"
|
||||
case "ogg", "oga":
|
||||
return "vorbis"
|
||||
case "opus":
|
||||
return "opus"
|
||||
case "mpc":
|
||||
return "mpc"
|
||||
case "wma":
|
||||
return "wma"
|
||||
case "flac":
|
||||
return "flac"
|
||||
case "wav":
|
||||
return "pcm"
|
||||
case "aif", "aiff", "aifc":
|
||||
return "pcm"
|
||||
case "ape":
|
||||
return "ape"
|
||||
case "wv", "wvp":
|
||||
return "wv"
|
||||
case "tta":
|
||||
return "tta"
|
||||
case "tak":
|
||||
return "tak"
|
||||
case "shn":
|
||||
return "shn"
|
||||
case "dsf", "dff":
|
||||
return "dsd"
|
||||
case "m4a":
|
||||
// AAC if BitDepth==0, ALAC if BitDepth>0
|
||||
if mf.BitDepth > 0 {
|
||||
return "alac"
|
||||
}
|
||||
return "aac"
|
||||
case "m4b", "m4p", "m4r":
|
||||
return "aac"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// IsLossless returns true if this file uses a lossless codec.
|
||||
func (mf MediaFile) IsLossless() bool {
|
||||
codec := mf.AudioCodec()
|
||||
// Primary: codec-based check (most accurate for containers like M4A)
|
||||
switch codec {
|
||||
case "flac", "alac", "pcm", "ape", "wv", "tta", "tak", "shn", "dsd":
|
||||
return true
|
||||
}
|
||||
// Secondary: suffix-based check using configurable list from YAML
|
||||
if slices.Contains(confmime.LosslessFormats, mf.Suffix) {
|
||||
return true
|
||||
}
|
||||
// Fallback heuristic: if BitDepth is set, it's likely lossless
|
||||
return mf.BitDepth > 0
|
||||
}
|
||||
|
||||
type MediaFiles []MediaFile
|
||||
|
||||
// ToAlbum creates an Album object based on the attributes of this MediaFiles collection.
|
||||
|
||||
@@ -475,7 +475,7 @@ var _ = Describe("MediaFile", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.EnableMediaFileCoverArt = true
|
||||
})
|
||||
Describe(".CoverArtId()", func() {
|
||||
Describe("CoverArtId", func() {
|
||||
It("returns its own id if it HasCoverArt", func() {
|
||||
mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true}
|
||||
id := mf.CoverArtID()
|
||||
@@ -496,6 +496,94 @@ var _ = Describe("MediaFile", func() {
|
||||
Expect(id.ID).To(Equal(mf.AlbumID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AudioCodec", func() {
|
||||
It("returns normalized stored codec when available", func() {
|
||||
mf := MediaFile{Codec: "AAC", Suffix: "m4a"}
|
||||
Expect(mf.AudioCodec()).To(Equal("aac"))
|
||||
})
|
||||
|
||||
It("returns stored codec lowercased", func() {
|
||||
mf := MediaFile{Codec: "ALAC", Suffix: "m4a"}
|
||||
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||
})
|
||||
|
||||
DescribeTable("infers codec from suffix when Codec field is empty",
|
||||
func(suffix string, bitDepth int, expected string) {
|
||||
mf := MediaFile{Suffix: suffix, BitDepth: bitDepth}
|
||||
Expect(mf.AudioCodec()).To(Equal(expected))
|
||||
},
|
||||
Entry("mp3", "mp3", 0, "mp3"),
|
||||
Entry("mpga", "mpga", 0, "mp3"),
|
||||
Entry("mp2", "mp2", 0, "mp2"),
|
||||
Entry("ogg", "ogg", 0, "vorbis"),
|
||||
Entry("oga", "oga", 0, "vorbis"),
|
||||
Entry("opus", "opus", 0, "opus"),
|
||||
Entry("mpc", "mpc", 0, "mpc"),
|
||||
Entry("wma", "wma", 0, "wma"),
|
||||
Entry("flac", "flac", 0, "flac"),
|
||||
Entry("wav", "wav", 0, "pcm"),
|
||||
Entry("aif", "aif", 0, "pcm"),
|
||||
Entry("aiff", "aiff", 0, "pcm"),
|
||||
Entry("aifc", "aifc", 0, "pcm"),
|
||||
Entry("ape", "ape", 0, "ape"),
|
||||
Entry("wv", "wv", 0, "wv"),
|
||||
Entry("wvp", "wvp", 0, "wv"),
|
||||
Entry("tta", "tta", 0, "tta"),
|
||||
Entry("tak", "tak", 0, "tak"),
|
||||
Entry("shn", "shn", 0, "shn"),
|
||||
Entry("dsf", "dsf", 0, "dsd"),
|
||||
Entry("dff", "dff", 0, "dsd"),
|
||||
Entry("m4a with BitDepth=0 (AAC)", "m4a", 0, "aac"),
|
||||
Entry("m4a with BitDepth>0 (ALAC)", "m4a", 16, "alac"),
|
||||
Entry("m4b", "m4b", 0, "aac"),
|
||||
Entry("m4p", "m4p", 0, "aac"),
|
||||
Entry("m4r", "m4r", 0, "aac"),
|
||||
Entry("unknown suffix", "xyz", 0, ""),
|
||||
)
|
||||
|
||||
It("prefers stored codec over suffix inference", func() {
|
||||
mf := MediaFile{Codec: "ALAC", Suffix: "m4a", BitDepth: 0}
|
||||
Expect(mf.AudioCodec()).To(Equal("alac"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("IsLossless", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
DescribeTable("detects lossless codecs",
|
||||
func(codec string, suffix string, bitDepth int, expected bool) {
|
||||
mf := MediaFile{Codec: codec, Suffix: suffix, BitDepth: bitDepth}
|
||||
Expect(mf.IsLossless()).To(Equal(expected))
|
||||
},
|
||||
Entry("flac", "FLAC", "flac", 16, true),
|
||||
Entry("alac", "ALAC", "m4a", 24, true),
|
||||
Entry("pcm via wav", "", "wav", 16, true),
|
||||
Entry("pcm via aiff", "", "aiff", 24, true),
|
||||
Entry("ape", "", "ape", 16, true),
|
||||
Entry("wv", "", "wv", 0, true),
|
||||
Entry("tta", "", "tta", 0, true),
|
||||
Entry("tak", "", "tak", 0, true),
|
||||
Entry("shn", "", "shn", 0, true),
|
||||
Entry("dsd", "", "dsf", 0, true),
|
||||
Entry("mp3 is lossy", "MP3", "mp3", 0, false),
|
||||
Entry("aac is lossy", "AAC", "m4a", 0, false),
|
||||
Entry("vorbis is lossy", "", "ogg", 0, false),
|
||||
Entry("opus is lossy", "", "opus", 0, false),
|
||||
)
|
||||
|
||||
It("detects lossless via BitDepth fallback when codec is unknown", func() {
|
||||
mf := MediaFile{Suffix: "xyz", BitDepth: 24}
|
||||
Expect(mf.IsLossless()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns false for unknown with no BitDepth", func() {
|
||||
mf := MediaFile{Suffix: "xyz", BitDepth: 0}
|
||||
Expect(mf.IsLossless()).To(BeFalse())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
func t(v string) time.Time {
|
||||
|
||||
@@ -65,6 +65,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||
mf.SampleRate = md.AudioProperties().SampleRate
|
||||
mf.BitDepth = md.AudioProperties().BitDepth
|
||||
mf.Channels = md.AudioProperties().Channels
|
||||
mf.Codec = md.AudioProperties().Codec
|
||||
mf.Path = md.FilePath()
|
||||
mf.Suffix = md.Suffix()
|
||||
mf.Size = md.Size()
|
||||
|
||||
@@ -35,6 +35,7 @@ type AudioProperties struct {
|
||||
BitDepth int
|
||||
SampleRate int
|
||||
Channels int
|
||||
Codec string
|
||||
}
|
||||
|
||||
type Date string
|
||||
@@ -250,7 +251,15 @@ func processPairMapping(name model.TagName, mapping model.TagConf, lowered model
|
||||
id3Base := parseID3Pairs(name, lowered)
|
||||
|
||||
if len(aliasValues) > 0 {
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
// For lyrics, don't use parseVorbisPairs as parentheses in lyrics content
|
||||
// should not be interpreted as language keys (e.g. "(intro)" is not a language)
|
||||
if name == model.TagLyrics {
|
||||
for _, v := range aliasValues {
|
||||
id3Base = append(id3Base, NewPair("xxx", v))
|
||||
}
|
||||
} else {
|
||||
id3Base = append(id3Base, parseVorbisPairs(aliasValues)...)
|
||||
}
|
||||
}
|
||||
return id3Base
|
||||
}
|
||||
|
||||
@@ -246,6 +246,18 @@ var _ = Describe("Metadata", func() {
|
||||
metadata.NewPair("eng", "Lyrics"),
|
||||
))
|
||||
})
|
||||
|
||||
It("should preserve lyrics starting with parentheses from alias tags", func() {
|
||||
props.Tags = model.RawTags{
|
||||
"LYRICS": {"(line one)\nline two\nline three"},
|
||||
}
|
||||
md = metadata.New(filePath, props)
|
||||
|
||||
Expect(md.All()).To(HaveKey(model.TagLyrics))
|
||||
Expect(md.Strings(model.TagLyrics)).To(ContainElements(
|
||||
metadata.NewPair("xxx", "(line one)\nline two\nline three"),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ReplayGain", func() {
|
||||
|
||||
@@ -285,13 +285,16 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
|
||||
}
|
||||
|
||||
// Update when the playlist was last refreshed (for cache purposes)
|
||||
updSql := Update(r.tableName).Set("evaluated_at", time.Now()).Where(Eq{"id": pls.ID})
|
||||
now := time.Now()
|
||||
updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID})
|
||||
_, err = r.executeSQL(updSql)
|
||||
if err != nil {
|
||||
log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err)
|
||||
return false
|
||||
}
|
||||
|
||||
pls.EvaluatedAt = &now
|
||||
|
||||
log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start))
|
||||
|
||||
return true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
@@ -160,14 +161,23 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// TODO Validate these tests
|
||||
XContext("child smart playlists", func() {
|
||||
When("refresh day has expired", func() {
|
||||
Context("child smart playlists", func() {
|
||||
BeforeEach(func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
})
|
||||
|
||||
When("refresh delay has expired", func() {
|
||||
It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second
|
||||
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
|
||||
childRules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"title": "Day"},
|
||||
},
|
||||
}
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules}
|
||||
Expect(repo.Put(&nestedPls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
|
||||
|
||||
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
@@ -175,45 +185,69 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
},
|
||||
}}
|
||||
Expect(repo.Put(&parentPls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
|
||||
|
||||
// Nested playlist has not been evaluated yet
|
||||
nestedPlsRead, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(nestedPlsRead.EvaluatedAt).To(BeNil())
|
||||
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true, false)
|
||||
// Getting parent with refresh should recursively refresh the nested playlist
|
||||
pls, err := repo.GetWithTracks(parentPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.EvaluatedAt).ToNot(BeNil())
|
||||
Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
|
||||
// Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get
|
||||
// Parent should have tracks from the nested playlist
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID))
|
||||
|
||||
// Nested playlist should now have been refreshed (EvaluatedAt set)
|
||||
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt))
|
||||
Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil())
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
When("refresh day has not expired", func() {
|
||||
When("refresh delay has not expired", func() {
|
||||
It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() {
|
||||
conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour
|
||||
childEvaluatedAt := time.Now().Add(-30 * time.Minute)
|
||||
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules}
|
||||
childRules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"title": "Day"},
|
||||
},
|
||||
}
|
||||
nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt}
|
||||
Expect(repo.Put(&nestedPls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) })
|
||||
|
||||
// Parent has no EvaluatedAt, so it WILL refresh, but the child should not
|
||||
parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.InPlaylist{"id": nestedPls.ID},
|
||||
},
|
||||
}}
|
||||
Expect(repo.Put(&parentPls)).To(Succeed())
|
||||
DeferCleanup(func() { _ = repo.Delete(parentPls.ID) })
|
||||
|
||||
nestedPlsRead, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = repo.GetWithTracks(parentPls.ID, true, false)
|
||||
// Getting parent with refresh should NOT recursively refresh the nested playlist
|
||||
parent, err := repo.GetWithTracks(parentPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get
|
||||
// Parent should have been refreshed (its EvaluatedAt was nil)
|
||||
Expect(parent.EvaluatedAt).ToNot(BeNil())
|
||||
Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second))
|
||||
|
||||
// Nested playlist should NOT have been refreshed (still within delay window)
|
||||
nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second))
|
||||
Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,6 +282,9 @@ type ServiceB interface {
|
||||
|
||||
Entry("option pattern (value, exists bool)",
|
||||
"config_service.go.txt", "config_client_expected.go.txt", "config_client_expected.py", "config_client_expected.rs"),
|
||||
|
||||
Entry("raw=true binary response",
|
||||
"raw_service.go.txt", "raw_client_expected.go.txt", "raw_client_expected.py", "raw_client_expected.rs"),
|
||||
)
|
||||
|
||||
It("generates compilable client code for comprehensive service", func() {
|
||||
|
||||
@@ -264,6 +264,96 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
|
||||
})
|
||||
|
||||
It("should generate binary framing for raw=true methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateHost(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should include encoding/binary import for raw methods
|
||||
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||
|
||||
// Should NOT generate a response type for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("type StreamGetStreamResponse struct"))
|
||||
|
||||
// Should generate request type (request is still JSON)
|
||||
Expect(codeStr).To(ContainSubstring("type StreamGetStreamRequest struct"))
|
||||
|
||||
// Should build binary frame [0x00][4-byte CT len][CT][data]
|
||||
Expect(codeStr).To(ContainSubstring("frame[0] = 0x00"))
|
||||
Expect(codeStr).To(ContainSubstring("binary.BigEndian.PutUint32"))
|
||||
|
||||
// Should have writeRawError helper
|
||||
Expect(codeStr).To(ContainSubstring("streamWriteRawError"))
|
||||
|
||||
// Should use writeRawError instead of writeError for raw methods
|
||||
Expect(codeStr).To(ContainSubstring("streamWriteRawError(p, stack"))
|
||||
})
|
||||
|
||||
It("should generate both writeError and writeRawError for mixed services", func() {
|
||||
svc := Service{
|
||||
Name: "API",
|
||||
Permission: "api",
|
||||
Interface: "APIService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Call",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{NewParam("response", "string")},
|
||||
},
|
||||
{
|
||||
Name: "CallRaw",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateHost(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = format.Source(code)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should have both helpers
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteResponse"))
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteError"))
|
||||
Expect(codeStr).To(ContainSubstring("apiWriteRawError"))
|
||||
|
||||
// Should generate response type for non-raw method only
|
||||
Expect(codeStr).To(ContainSubstring("type APICallResponse struct"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("type APICallRawResponse struct"))
|
||||
})
|
||||
|
||||
It("should always include json import for JSON protocol", func() {
|
||||
// All services use JSON protocol, so json import is always needed
|
||||
svc := Service{
|
||||
@@ -626,6 +716,72 @@ var _ = Describe("Generator", func() {
|
||||
Expect(codeStr).To(ContainSubstring(`response.get("floatVal", 0.0)`))
|
||||
Expect(codeStr).To(ContainSubstring(`response.get("boolVal", False)`))
|
||||
})
|
||||
|
||||
It("should generate binary frame parsing for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientPython(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should import Tuple and struct for raw methods
|
||||
Expect(codeStr).To(ContainSubstring("from typing import Any, Tuple"))
|
||||
Expect(codeStr).To(ContainSubstring("import struct"))
|
||||
|
||||
// Should return Tuple[str, bytes]
|
||||
Expect(codeStr).To(ContainSubstring("-> Tuple[str, bytes]:"))
|
||||
|
||||
// Should parse binary frame instead of JSON
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes = response_mem.bytes()"))
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("struct.unpack"))
|
||||
Expect(codeStr).To(ContainSubstring("return content_type, data"))
|
||||
|
||||
// Should NOT use json.loads for response
|
||||
Expect(codeStr).NotTo(ContainSubstring("json.loads(extism.memory.string(response_mem))"))
|
||||
})
|
||||
|
||||
It("should not import Tuple or struct for non-raw services", func() {
|
||||
svc := Service{
|
||||
Name: "Test",
|
||||
Permission: "test",
|
||||
Interface: "TestService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "Call",
|
||||
HasError: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{NewParam("response", "string")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientPython(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
Expect(codeStr).NotTo(ContainSubstring("Tuple"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("import struct"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateGoDoc", func() {
|
||||
@@ -782,6 +938,47 @@ var _ = Describe("Generator", func() {
|
||||
// Check for PDK import
|
||||
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
|
||||
})
|
||||
|
||||
It("should include encoding/binary import for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientGo(svc, "host")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should include encoding/binary for raw binary frame parsing
|
||||
Expect(codeStr).To(ContainSubstring(`"encoding/binary"`))
|
||||
|
||||
// Should NOT generate response type struct for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("streamGetStreamResponse struct"))
|
||||
|
||||
// Should still generate request type
|
||||
Expect(codeStr).To(ContainSubstring("streamGetStreamRequest struct"))
|
||||
|
||||
// Should parse binary frame
|
||||
Expect(codeStr).To(ContainSubstring("responseBytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("binary.BigEndian.Uint32"))
|
||||
|
||||
// Should return (string, []byte, error)
|
||||
Expect(codeStr).To(ContainSubstring("func StreamGetStream(uri string) (string, []byte, error)"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GenerateClientGoStub", func() {
|
||||
@@ -1550,6 +1747,51 @@ var _ = Describe("Rust Generation", func() {
|
||||
Expect(codeStr).To(ContainSubstring("Result<bool, Error>"))
|
||||
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
|
||||
})
|
||||
|
||||
It("should generate raw extern C import and binary frame parsing for raw methods", func() {
|
||||
svc := Service{
|
||||
Name: "Stream",
|
||||
Permission: "stream",
|
||||
Interface: "StreamService",
|
||||
Methods: []Method{
|
||||
{
|
||||
Name: "GetStream",
|
||||
HasError: true,
|
||||
Raw: true,
|
||||
Params: []Param{NewParam("uri", "string")},
|
||||
Returns: []Param{
|
||||
NewParam("contentType", "string"),
|
||||
NewParam("data", "[]byte"),
|
||||
},
|
||||
Doc: "GetStream returns raw binary stream data.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
code, err := GenerateClientRust(svc)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
codeStr := string(code)
|
||||
|
||||
// Should use extern "C" with wasm_import_module for raw methods, not #[host_fn] extern "ExtismHost"
|
||||
Expect(codeStr).To(ContainSubstring(`#[link(wasm_import_module = "extism:host/user")]`))
|
||||
Expect(codeStr).To(ContainSubstring(`extern "C"`))
|
||||
Expect(codeStr).To(ContainSubstring("fn stream_getstream(offset: u64) -> u64"))
|
||||
|
||||
// Should NOT generate response type for raw methods
|
||||
Expect(codeStr).NotTo(ContainSubstring("StreamGetStreamResponse"))
|
||||
|
||||
// Should generate request type (request is still JSON)
|
||||
Expect(codeStr).To(ContainSubstring("struct StreamGetStreamRequest"))
|
||||
|
||||
// Should return Result<(String, Vec<u8>), Error>
|
||||
Expect(codeStr).To(ContainSubstring("Result<(String, Vec<u8>), Error>"))
|
||||
|
||||
// Should parse binary frame
|
||||
Expect(codeStr).To(ContainSubstring("response_bytes[0] == 0x01"))
|
||||
Expect(codeStr).To(ContainSubstring("u32::from_be_bytes"))
|
||||
Expect(codeStr).To(ContainSubstring("String::from_utf8_lossy"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -761,6 +761,7 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
m := Method{
|
||||
Name: name,
|
||||
ExportName: annotation["name"],
|
||||
Raw: annotation["raw"] == "true",
|
||||
Doc: doc,
|
||||
}
|
||||
|
||||
@@ -799,6 +800,13 @@ func parseMethod(name string, funcType *ast.FuncType, annotation map[string]stri
|
||||
}
|
||||
}
|
||||
|
||||
// Validate raw=true methods: must return exactly (string, []byte, error)
|
||||
if m.Raw {
|
||||
if !m.HasError || len(m.Returns) != 2 || m.Returns[0].Type != "string" || m.Returns[1].Type != "[]byte" {
|
||||
return m, fmt.Errorf("raw=true method %s must return (string, []byte, error) — content-type, data, error", name)
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,119 @@ type TestService interface {
|
||||
Expect(services[0].Methods[0].Name).To(Equal("Exported"))
|
||||
})
|
||||
|
||||
It("should parse raw=true annotation", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Stream permission=stream
|
||||
type StreamService interface {
|
||||
//nd:hostfunc raw=true
|
||||
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "stream.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
|
||||
m := services[0].Methods[0]
|
||||
Expect(m.Name).To(Equal("GetStream"))
|
||||
Expect(m.Raw).To(BeTrue())
|
||||
Expect(m.HasError).To(BeTrue())
|
||||
Expect(m.Returns).To(HaveLen(2))
|
||||
Expect(m.Returns[0].Name).To(Equal("contentType"))
|
||||
Expect(m.Returns[0].Type).To(Equal("string"))
|
||||
Expect(m.Returns[1].Name).To(Equal("data"))
|
||||
Expect(m.Returns[1].Type).To(Equal("[]byte"))
|
||||
})
|
||||
|
||||
It("should set Raw=false when raw annotation is absent", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (response string, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should reject raw=true with invalid return signature", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc raw=true
|
||||
BadRaw(ctx context.Context, uri string) (result string, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = ParseDirectory(tmpDir)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||
Expect(err.Error()).To(ContainSubstring("must return (string, []byte, error)"))
|
||||
})
|
||||
|
||||
It("should reject raw=true without error return", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Test permission=test
|
||||
type TestService interface {
|
||||
//nd:hostfunc raw=true
|
||||
BadRaw(ctx context.Context, uri string) (contentType string, data []byte)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "test.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
_, err = ParseDirectory(tmpDir)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("raw=true"))
|
||||
})
|
||||
|
||||
It("should parse mixed raw and non-raw methods", func() {
|
||||
src := `package host
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=API permission=api
|
||||
type APIService interface {
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||
|
||||
//nd:hostfunc raw=true
|
||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
`
|
||||
err := os.WriteFile(filepath.Join(tmpDir, "api.go"), []byte(src), 0600)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
services, err := ParseDirectory(tmpDir)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(services).To(HaveLen(1))
|
||||
Expect(services[0].Methods).To(HaveLen(2))
|
||||
Expect(services[0].Methods[0].Raw).To(BeFalse())
|
||||
Expect(services[0].Methods[1].Raw).To(BeTrue())
|
||||
Expect(services[0].HasRawMethods()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should handle custom export name", func() {
|
||||
src := `package host
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
package {{.Package}}
|
||||
|
||||
import (
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
{{- if .Service.HasErrors}}
|
||||
"errors"
|
||||
@@ -49,7 +52,7 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .IsErrorOnly}}
|
||||
{{- if and (not .IsErrorOnly) (not .Raw)}}
|
||||
|
||||
type {{responseType .}} struct {
|
||||
{{- range .Returns}}
|
||||
@@ -95,7 +98,27 @@ func {{$.Service.Name}}{{.Name}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
{{- if .IsErrorOnly}}
|
||||
{{- if .Raw}}
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
}
|
||||
if responseBytes[0] != 0x00 {
|
||||
return "", nil, errors.New("unknown response status")
|
||||
}
|
||||
if len(responseBytes) < 5 {
|
||||
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||
}
|
||||
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||
if uint32(len(responseBytes)) < 5+ctLen {
|
||||
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||
}
|
||||
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||
{{- else if .IsErrorOnly}}
|
||||
|
||||
// Parse error-only response
|
||||
var response struct {
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any{{- if .Service.HasRawMethods}}, Tuple{{end}}
|
||||
|
||||
import extism
|
||||
import json
|
||||
{{- if .Service.HasRawMethods}}
|
||||
import struct
|
||||
{{- end}}
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -29,7 +32,7 @@ def _{{exportName .}}(offset: int) -> int:
|
||||
{{- end}}
|
||||
{{- /* Generate dataclasses for multi-value returns */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .NeedsResultClass}}
|
||||
{{- if and .NeedsResultClass (not .Raw)}}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,7 +47,7 @@ class {{pythonResultType .}}:
|
||||
{{range .Service.Methods}}
|
||||
|
||||
|
||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||
def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonName}}: {{$p.PythonType}}{{end}}){{if .Raw}} -> Tuple[str, bytes]{{else if .NeedsResultClass}} -> {{pythonResultType .}}{{else if .HasReturns}} -> {{(index .Returns 0).PythonType}}{{else}} -> None{{end}}:
|
||||
"""{{if .Doc}}{{.Doc}}{{else}}Call the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
|
||||
@@ -53,7 +56,11 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
{{.PythonName}}: {{.PythonType}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if .HasReturns}}
|
||||
{{- if .Raw}}
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
{{- else if .HasReturns}}
|
||||
|
||||
Returns:
|
||||
{{- if .NeedsResultClass}}
|
||||
@@ -79,6 +86,24 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _{{exportName .}}(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
{{- if .Raw}}
|
||||
response_bytes = response_mem.bytes()
|
||||
|
||||
if len(response_bytes) == 0:
|
||||
raise HostFunctionError("empty response from host")
|
||||
if response_bytes[0] == 0x01:
|
||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||
if response_bytes[0] != 0x00:
|
||||
raise HostFunctionError("unknown response status")
|
||||
if len(response_bytes) < 5:
|
||||
raise HostFunctionError("malformed raw response: incomplete header")
|
||||
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||
if len(response_bytes) < 5 + ct_len:
|
||||
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||
data = response_bytes[5 + ct_len:]
|
||||
return content_type, data
|
||||
{{- else}}
|
||||
response = json.loads(extism.memory.string(response_mem))
|
||||
{{if .HasError}}
|
||||
if response.get("error"):
|
||||
@@ -94,3 +119,4 @@ def {{pythonFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.PythonNam
|
||||
return response.get("{{(index .Returns 0).JSONName}}"{{pythonDefault (index .Returns 0)}})
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -33,6 +33,7 @@ struct {{requestType .}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .Raw}}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -47,16 +48,92 @@ struct {{responseType .}} {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
{{- range .Service.Methods}}
|
||||
{{- if not .Raw}}
|
||||
fn {{exportName .}}(input: Json<{{if .HasParams}}{{requestType .}}{{else}}serde_json::Value{{end}}>) -> Json<{{responseType .}}>;
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
}
|
||||
{{- /* Declare raw extern "C" imports for raw methods */ -}}
|
||||
{{- range .Service.Methods}}
|
||||
{{- if .Raw}}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn {{exportName .}}(offset: u64) -> u64;
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- /* Generate wrapper functions */ -}}
|
||||
{{range .Service.Methods}}
|
||||
{{- if .Raw}}
|
||||
|
||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
///
|
||||
/// # Arguments
|
||||
{{- range .Params}}
|
||||
/// * `{{.RustName}}` - {{rustType .}} parameter.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<(String, Vec<u8>), Error> {
|
||||
{{- if .HasParams}}
|
||||
let req = {{requestType .}} {
|
||||
{{- range .Params}}
|
||||
{{.RustName}}: {{.RustName}}{{if .NeedsToOwned}}.to_owned(){{end}},
|
||||
{{- end}}
|
||||
};
|
||||
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||
{{- else}}
|
||||
let input_bytes = b"{}".to_vec();
|
||||
{{- end}}
|
||||
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||
|
||||
let response_offset = unsafe { {{exportName .}}(input_mem.offset()) };
|
||||
|
||||
let response_mem = Memory::find(response_offset)
|
||||
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||
let response_bytes = response_mem.to_vec();
|
||||
|
||||
if response_bytes.is_empty() {
|
||||
return Err(Error::msg("empty response from host"));
|
||||
}
|
||||
if response_bytes[0] == 0x01 {
|
||||
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||
return Err(Error::msg(msg));
|
||||
}
|
||||
if response_bytes[0] != 0x00 {
|
||||
return Err(Error::msg("unknown response status"));
|
||||
}
|
||||
if response_bytes.len() < 5 {
|
||||
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||
}
|
||||
let ct_len = u32::from_be_bytes([
|
||||
response_bytes[1],
|
||||
response_bytes[2],
|
||||
response_bytes[3],
|
||||
response_bytes[4],
|
||||
]) as usize;
|
||||
if ct_len > response_bytes.len() - 5 {
|
||||
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||
}
|
||||
let ct_end = 5 + ct_len;
|
||||
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||
let data = response_bytes[ct_end..].to_vec();
|
||||
Ok((content_type, data))
|
||||
}
|
||||
{{- else}}
|
||||
|
||||
{{if .Doc}}{{rustDocComment .Doc}}{{else}}/// Calls the {{exportName .}} host function.{{end}}
|
||||
{{- if .HasParams}}
|
||||
@@ -132,3 +209,4 @@ pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName
|
||||
}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -4,6 +4,9 @@ package {{.Package}}
|
||||
|
||||
import (
|
||||
"context"
|
||||
{{- if .Service.HasRawMethods}}
|
||||
"encoding/binary"
|
||||
{{- end}}
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@@ -20,6 +23,7 @@ type {{requestType .}} struct {
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{- if not .Raw}}
|
||||
|
||||
// {{responseType .}} is the response type for {{$.Service.Name}}.{{.Name}}.
|
||||
type {{responseType .}} struct {
|
||||
@@ -30,6 +34,7 @@ type {{responseType .}} struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
{{- end}}
|
||||
}
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
// Register{{.Service.Name}}HostFunctions registers {{.Service.Name}} service host functions.
|
||||
@@ -51,18 +56,48 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
{{- if .Raw}}
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||
{{- else}}
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
{{- end}}
|
||||
return
|
||||
}
|
||||
var req {{requestType .}}
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
{{- if .Raw}}
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, err)
|
||||
{{- else}}
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, err)
|
||||
{{- end}}
|
||||
return
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
// Call the service method
|
||||
{{- if .HasReturns}}
|
||||
{{- if .Raw}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
if svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteRawError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write binary-framed response to plugin memory:
|
||||
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||
ctBytes := []byte({{lower (index .Returns 0).Name}})
|
||||
frame := make([]byte, 1+4+len(ctBytes)+len({{lower (index .Returns 1).Name}}))
|
||||
frame[0] = 0x00 // success
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||
copy(frame[5+len(ctBytes):], {{lower (index .Returns 1).Name}})
|
||||
|
||||
respPtr, err := p.WriteBytes(frame)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
{{- else if .HasReturns}}
|
||||
{{- if .HasError}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}}, svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
if svcErr != nil {
|
||||
@@ -72,14 +107,6 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
||||
{{- else}}
|
||||
{{range $i, $r := .Returns}}{{if $i}}, {{end}}{{lower $r.Name}}{{end}} := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
{{- end}}
|
||||
{{- else if .HasError}}
|
||||
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
{{- else}}
|
||||
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
{{- end}}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{
|
||||
@@ -88,6 +115,22 @@ func new{{$.Service.Name}}{{.Name}}HostFunction(service {{$.Service.Interface}})
|
||||
{{- end}}
|
||||
}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
{{- else if .HasError}}
|
||||
if svcErr := service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}}); svcErr != nil {
|
||||
{{$.Service.Name | lower}}WriteError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
{{- else}}
|
||||
service.{{.Name}}(ctx{{range .Params}}, req.{{title .Name}}{{end}})
|
||||
|
||||
// Write JSON response to plugin memory
|
||||
resp := {{responseType .}}{}
|
||||
{{$.Service.Name | lower}}WriteResponse(p, stack, resp)
|
||||
{{- end}}
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
@@ -119,3 +162,16 @@ func {{.Service.Name | lower}}WriteError(p *extism.CurrentPlugin, stack []uint64
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- if .Service.HasRawMethods}}
|
||||
|
||||
// {{.Service.Name | lower}}WriteRawError writes a binary-framed error response to plugin memory.
|
||||
// Format: [0x01][UTF-8 error message]
|
||||
func {{.Service.Name | lower}}WriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errMsg := []byte(err.Error())
|
||||
frame := make([]byte, 1+len(errMsg))
|
||||
frame[0] = 0x01 // error
|
||||
copy(frame[1:], errMsg)
|
||||
respPtr, _ := p.WriteBytes(frame)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
{{- end}}
|
||||
|
||||
@@ -173,6 +173,16 @@ func (s Service) HasErrors() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// HasRawMethods returns true if any method in the service uses raw binary framing.
|
||||
func (s Service) HasRawMethods() bool {
|
||||
for _, m := range s.Methods {
|
||||
if m.Raw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Method represents a host function method within a service.
|
||||
type Method struct {
|
||||
Name string // Go method name (e.g., "Call")
|
||||
@@ -181,6 +191,7 @@ type Method struct {
|
||||
Returns []Param // Return values (excluding error)
|
||||
HasError bool // Whether the method returns an error
|
||||
Doc string // Documentation comment for the method
|
||||
Raw bool // If true, response uses binary framing instead of JSON
|
||||
}
|
||||
|
||||
// FunctionName returns the Extism host function export name.
|
||||
|
||||
66
plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt
vendored
Normal file
66
plugins/cmd/ndpgen/testdata/raw_client_expected.go.txt
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Stream host service.
|
||||
// It is intended for use in Navidrome plugins built with TinyGo.
|
||||
//
|
||||
//go:build wasip1
|
||||
|
||||
package ndpdk
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
|
||||
// stream_getstream is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user stream_getstream
|
||||
func stream_getstream(uint64) uint64
|
||||
|
||||
type streamGetStreamRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
// StreamGetStream calls the stream_getstream host function.
|
||||
// GetStream returns raw binary stream data with content type.
|
||||
func StreamGetStream(uri string) (string, []byte, error) {
|
||||
// Marshal request to JSON
|
||||
req := streamGetStreamRequest{
|
||||
Uri: uri,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := stream_getstream(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
}
|
||||
if responseBytes[0] != 0x00 {
|
||||
return "", nil, errors.New("unknown response status")
|
||||
}
|
||||
if len(responseBytes) < 5 {
|
||||
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||
}
|
||||
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||
if uint32(len(responseBytes)) < 5+ctLen {
|
||||
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||
}
|
||||
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||
}
|
||||
63
plugins/cmd/ndpgen/testdata/raw_client_expected.py
vendored
Normal file
63
plugins/cmd/ndpgen/testdata/raw_client_expected.py
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# Code generated by ndpgen. DO NOT EDIT.
|
||||
#
|
||||
# This file contains client wrappers for the Stream host service.
|
||||
# It is intended for use in Navidrome plugins built with extism-py.
|
||||
#
|
||||
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
|
||||
# The @extism.import_fn decorators are only detected when defined in the plugin's
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Tuple
|
||||
|
||||
import extism
|
||||
import json
|
||||
import struct
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
"""Raised when a host function returns an error."""
|
||||
pass
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "stream_getstream")
|
||||
def _stream_getstream(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def stream_get_stream(uri: str) -> Tuple[str, bytes]:
|
||||
"""GetStream returns raw binary stream data with content type.
|
||||
|
||||
Args:
|
||||
uri: str parameter.
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"uri": uri,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _stream_getstream(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response_bytes = response_mem.bytes()
|
||||
|
||||
if len(response_bytes) == 0:
|
||||
raise HostFunctionError("empty response from host")
|
||||
if response_bytes[0] == 0x01:
|
||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||
if response_bytes[0] != 0x00:
|
||||
raise HostFunctionError("unknown response status")
|
||||
if len(response_bytes) < 5:
|
||||
raise HostFunctionError("malformed raw response: incomplete header")
|
||||
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||
if len(response_bytes) < 5 + ct_len:
|
||||
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||
data = response_bytes[5 + ct_len:]
|
||||
return content_type, data
|
||||
73
plugins/cmd/ndpgen/testdata/raw_client_expected.rs
vendored
Normal file
73
plugins/cmd/ndpgen/testdata/raw_client_expected.rs
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
// Code generated by ndpgen. DO NOT EDIT.
|
||||
//
|
||||
// This file contains client wrappers for the Stream host service.
|
||||
// It is intended for use in Navidrome plugins built with extism-pdk.
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StreamGetStreamRequest {
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn stream_getstream(offset: u64) -> u64;
|
||||
}
|
||||
|
||||
/// GetStream returns raw binary stream data with content type.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `uri` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn get_stream(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
||||
let req = StreamGetStreamRequest {
|
||||
uri: uri.to_owned(),
|
||||
};
|
||||
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||
|
||||
let response_offset = unsafe { stream_getstream(input_mem.offset()) };
|
||||
|
||||
let response_mem = Memory::find(response_offset)
|
||||
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||
let response_bytes = response_mem.to_vec();
|
||||
|
||||
if response_bytes.is_empty() {
|
||||
return Err(Error::msg("empty response from host"));
|
||||
}
|
||||
if response_bytes[0] == 0x01 {
|
||||
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||
return Err(Error::msg(msg));
|
||||
}
|
||||
if response_bytes[0] != 0x00 {
|
||||
return Err(Error::msg("unknown response status"));
|
||||
}
|
||||
if response_bytes.len() < 5 {
|
||||
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||
}
|
||||
let ct_len = u32::from_be_bytes([
|
||||
response_bytes[1],
|
||||
response_bytes[2],
|
||||
response_bytes[3],
|
||||
response_bytes[4],
|
||||
]) as usize;
|
||||
if ct_len > response_bytes.len() - 5 {
|
||||
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||
}
|
||||
let ct_end = 5 + ct_len;
|
||||
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||
let data = response_bytes[ct_end..].to_vec();
|
||||
Ok((content_type, data))
|
||||
}
|
||||
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
Normal file
10
plugins/cmd/ndpgen/testdata/raw_service.go.txt
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
package testpkg
|
||||
|
||||
import "context"
|
||||
|
||||
//nd:hostservice name=Stream permission=stream
|
||||
type StreamService interface {
|
||||
// GetStream returns raw binary stream data with content type.
|
||||
//nd:hostfunc raw=true
|
||||
GetStream(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
@@ -15,4 +15,10 @@ type SubsonicAPIService interface {
|
||||
// e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
|
||||
//nd:hostfunc
|
||||
Call(ctx context.Context, uri string) (responseJSON string, err error)
|
||||
|
||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
//nd:hostfunc raw=true
|
||||
CallRaw(ctx context.Context, uri string) (contentType string, data []byte, err error)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
@@ -20,11 +21,17 @@ type SubsonicAPICallResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SubsonicAPICallRawRequest is the request type for SubsonicAPI.CallRaw.
|
||||
type SubsonicAPICallRawRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
// RegisterSubsonicAPIHostFunctions registers SubsonicAPI service host functions.
|
||||
// The returned host functions should be added to the plugin's configuration.
|
||||
func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService) []extism.HostFunction {
|
||||
return []extism.HostFunction{
|
||||
newSubsonicAPICallHostFunction(service),
|
||||
newSubsonicAPICallRawHostFunction(service),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +69,50 @@ func newSubsonicAPICallHostFunction(service SubsonicAPIService) extism.HostFunct
|
||||
)
|
||||
}
|
||||
|
||||
func newSubsonicAPICallRawHostFunction(service SubsonicAPIService) extism.HostFunction {
|
||||
return extism.NewHostFunctionWithStack(
|
||||
"subsonicapi_callraw",
|
||||
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
|
||||
// Read JSON request from plugin memory
|
||||
reqBytes, err := p.ReadBytes(stack[0])
|
||||
if err != nil {
|
||||
subsonicapiWriteRawError(p, stack, err)
|
||||
return
|
||||
}
|
||||
var req SubsonicAPICallRawRequest
|
||||
if err := json.Unmarshal(reqBytes, &req); err != nil {
|
||||
subsonicapiWriteRawError(p, stack, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Call the service method
|
||||
contenttype, data, svcErr := service.CallRaw(ctx, req.Uri)
|
||||
if svcErr != nil {
|
||||
subsonicapiWriteRawError(p, stack, svcErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Write binary-framed response to plugin memory:
|
||||
// [0x00][4-byte content-type length (big-endian)][content-type string][raw data]
|
||||
ctBytes := []byte(contenttype)
|
||||
frame := make([]byte, 1+4+len(ctBytes)+len(data))
|
||||
frame[0] = 0x00 // success
|
||||
binary.BigEndian.PutUint32(frame[1:5], uint32(len(ctBytes)))
|
||||
copy(frame[5:5+len(ctBytes)], ctBytes)
|
||||
copy(frame[5+len(ctBytes):], data)
|
||||
|
||||
respPtr, err := p.WriteBytes(frame)
|
||||
if err != nil {
|
||||
stack[0] = 0
|
||||
return
|
||||
}
|
||||
stack[0] = respPtr
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
)
|
||||
}
|
||||
|
||||
// subsonicapiWriteResponse writes a JSON response to plugin memory.
|
||||
func subsonicapiWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
|
||||
respBytes, err := json.Marshal(resp)
|
||||
@@ -86,3 +137,14 @@ func subsonicapiWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
respPtr, _ := p.WriteBytes(respBytes)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
// subsonicapiWriteRawError writes a binary-framed error response to plugin memory.
|
||||
// Format: [0x01][UTF-8 error message]
|
||||
func subsonicapiWriteRawError(p *extism.CurrentPlugin, stack []uint64, err error) {
|
||||
errMsg := []byte(err.Error())
|
||||
frame := make([]byte, 1+len(errMsg))
|
||||
frame[0] = 0x01 // error
|
||||
copy(frame[1:], errMsg)
|
||||
respPtr, _ := p.WriteBytes(frame)
|
||||
stack[0] = respPtr
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const subsonicAPIVersion = "1.16.1"
|
||||
//
|
||||
// Authentication: The plugin must provide a valid 'u' (username) parameter in the URL.
|
||||
// URL Format: Only the path and query parameters are used - host/protocol are ignored.
|
||||
// Automatic Parameters: The service adds 'c' (client), 'v' (version), 'f' (format).
|
||||
// Automatic Parameters: The service adds 'c' (client), 'v' (version), and optionally 'f' (format).
|
||||
type subsonicAPIServiceImpl struct {
|
||||
pluginID string
|
||||
router SubsonicRouter
|
||||
@@ -50,15 +50,18 @@ func newSubsonicAPIService(pluginID string, router SubsonicRouter, ds model.Data
|
||||
}
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
|
||||
// executeRequest handles URL parsing, validation, permission checks, HTTP request creation,
|
||||
// and router invocation. Shared between Call and CallRaw.
|
||||
// If setJSON is true, the 'f=json' query parameter is added.
|
||||
func (s *subsonicAPIServiceImpl) executeRequest(ctx context.Context, uri string, setJSON bool) (*httptest.ResponseRecorder, error) {
|
||||
if s.router == nil {
|
||||
return "", fmt.Errorf("SubsonicAPI router not available")
|
||||
return nil, fmt.Errorf("SubsonicAPI router not available")
|
||||
}
|
||||
|
||||
// Parse the input URL
|
||||
parsedURL, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL format: %w", err)
|
||||
return nil, fmt.Errorf("invalid URL format: %w", err)
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
@@ -67,18 +70,20 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
|
||||
// Validate that 'u' (username) parameter is present
|
||||
username := query.Get("u")
|
||||
if username == "" {
|
||||
return "", fmt.Errorf("missing required parameter 'u' (username)")
|
||||
return nil, fmt.Errorf("missing required parameter 'u' (username)")
|
||||
}
|
||||
|
||||
if err := s.checkPermissions(ctx, username); err != nil {
|
||||
log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err)
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add required Subsonic API parameters
|
||||
query.Set("c", s.pluginID) // Client name (plugin ID)
|
||||
query.Set("f", "json") // Response format
|
||||
query.Set("v", subsonicAPIVersion) // API version
|
||||
if setJSON {
|
||||
query.Set("f", "json") // Response format
|
||||
}
|
||||
|
||||
// Extract the endpoint from the path
|
||||
endpoint := path.Base(parsedURL.Path)
|
||||
@@ -96,7 +101,7 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
|
||||
// explicitly added in the next step via request.WithInternalAuth.
|
||||
httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// Set internal authentication context using the username from the 'u' parameter
|
||||
@@ -109,10 +114,26 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string,
|
||||
// Call the subsonic router
|
||||
s.router.ServeHTTP(recorder, httpReq)
|
||||
|
||||
// Return the response body as JSON
|
||||
return recorder, nil
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) Call(ctx context.Context, uri string) (string, error) {
|
||||
recorder, err := s.executeRequest(ctx, uri, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return recorder.Body.String(), nil
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) CallRaw(ctx context.Context, uri string) (string, []byte, error) {
|
||||
recorder, err := s.executeRequest(ctx, uri, false)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
contentType := recorder.Header().Get("Content-Type")
|
||||
return contentType, recorder.Body.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error {
|
||||
// If allUsers is true, allow any user
|
||||
if s.allUsers {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@@ -177,6 +178,61 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() {
|
||||
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("SubsonicAPI CallRaw", func() {
|
||||
var plugin *plugin
|
||||
|
||||
BeforeEach(func() {
|
||||
manager.mu.RLock()
|
||||
plugin = manager.plugins["test-subsonicapi-plugin"]
|
||||
manager.mu.RUnlock()
|
||||
Expect(plugin).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("successfully calls getCoverArt and returns binary data", func() {
|
||||
instance, err := plugin.instance(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
exit, output, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(exit).To(Equal(uint32(0)))
|
||||
|
||||
// Parse the metadata response from the test plugin
|
||||
var result map[string]any
|
||||
err = json.Unmarshal(output, &result)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(result["contentType"]).To(Equal("image/png"))
|
||||
Expect(result["size"]).To(BeNumerically("==", len(fakePNGHeader)))
|
||||
Expect(result["firstByte"]).To(BeNumerically("==", 0x89)) // PNG magic byte
|
||||
})
|
||||
|
||||
It("does NOT set f=json parameter for raw calls", func() {
|
||||
instance, err := plugin.instance(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
_, _, err = instance.Call("call_subsonic_api_raw", []byte("/getCoverArt?u=testuser&id=al-1"))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(router.lastRequest).ToNot(BeNil())
|
||||
query := router.lastRequest.URL.Query()
|
||||
Expect(query.Get("f")).To(BeEmpty())
|
||||
Expect(query.Get("c")).To(Equal("test-subsonicapi-plugin"))
|
||||
Expect(query.Get("v")).To(Equal("1.16.1"))
|
||||
})
|
||||
|
||||
It("returns error when username is missing", func() {
|
||||
instance, err := plugin.instance(GinkgoT().Context())
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer instance.Close(GinkgoT().Context())
|
||||
|
||||
exit, _, err := instance.Call("call_subsonic_api_raw", []byte("/getCoverArt"))
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(exit).To(Equal(uint32(1)))
|
||||
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("SubsonicAPIService", func() {
|
||||
@@ -323,6 +379,66 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CallRaw", func() {
|
||||
It("returns binary data and content-type", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
contentType, data, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(contentType).To(Equal("image/png"))
|
||||
Expect(data).To(Equal(fakePNGHeader))
|
||||
})
|
||||
|
||||
It("does not set f=json parameter", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(router.lastRequest).ToNot(BeNil())
|
||||
query := router.lastRequest.URL.Query()
|
||||
Expect(query.Get("f")).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("enforces permission checks", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, []string{"user2"}, false)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser&id=al-1")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not authorized"))
|
||||
})
|
||||
|
||||
It("returns error when username is missing", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("missing required parameter"))
|
||||
})
|
||||
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "/getCoverArt?u=testuser")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("router not available"))
|
||||
})
|
||||
|
||||
It("returns error for invalid URL", func() {
|
||||
service := newSubsonicAPIService("test-plugin", router, dataStore, nil, true)
|
||||
|
||||
ctx := GinkgoT().Context()
|
||||
_, _, err := service.CallRaw(ctx, "://invalid")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid URL"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Router Availability", func() {
|
||||
It("returns error when router is nil", func() {
|
||||
service := newSubsonicAPIService("test-plugin", nil, dataStore, nil, true)
|
||||
@@ -335,6 +451,9 @@ var _ = Describe("SubsonicAPIService", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// fakePNGHeader is a minimal PNG file header used in tests.
|
||||
var fakePNGHeader = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
|
||||
|
||||
// fakeSubsonicRouter is a mock Subsonic router that returns predictable responses.
|
||||
type fakeSubsonicRouter struct {
|
||||
lastRequest *http.Request
|
||||
@@ -343,13 +462,20 @@ type fakeSubsonicRouter struct {
|
||||
func (r *fakeSubsonicRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
r.lastRequest = req
|
||||
|
||||
// Return a successful ping response
|
||||
response := map[string]any{
|
||||
"subsonic-response": map[string]any{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
},
|
||||
endpoint := path.Base(req.URL.Path)
|
||||
switch endpoint {
|
||||
case "getCoverArt":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write(fakePNGHeader)
|
||||
default:
|
||||
// Return a successful ping response
|
||||
response := map[string]any{
|
||||
"subsonic-response": map[string]any{
|
||||
"status": "ok",
|
||||
"version": "1.16.1",
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,3 @@ require (
|
||||
github.com/extism/go-pdk v1.1.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package host
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
@@ -19,6 +20,11 @@ import (
|
||||
//go:wasmimport extism:host/user subsonicapi_call
|
||||
func subsonicapi_call(uint64) uint64
|
||||
|
||||
// subsonicapi_callraw is the host function provided by Navidrome.
|
||||
//
|
||||
//go:wasmimport extism:host/user subsonicapi_callraw
|
||||
func subsonicapi_callraw(uint64) uint64
|
||||
|
||||
type subsonicAPICallRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
@@ -28,6 +34,10 @@ type subsonicAPICallResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type subsonicAPICallRawRequest struct {
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
// SubsonicAPICall calls the subsonicapi_call host function.
|
||||
// Call executes a Subsonic API request and returns the JSON response.
|
||||
//
|
||||
@@ -65,3 +75,46 @@ func SubsonicAPICall(uri string) (string, error) {
|
||||
|
||||
return response.ResponseJSON, nil
|
||||
}
|
||||
|
||||
// SubsonicAPICallRaw calls the subsonicapi_callraw host function.
|
||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
// Marshal request to JSON
|
||||
req := subsonicAPICallRawRequest{
|
||||
Uri: uri,
|
||||
}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
reqMem := pdk.AllocateBytes(reqBytes)
|
||||
defer reqMem.Free()
|
||||
|
||||
// Call the host function
|
||||
responsePtr := subsonicapi_callraw(reqMem.Offset())
|
||||
|
||||
// Read the response from memory
|
||||
responseMem := pdk.FindMemory(responsePtr)
|
||||
responseBytes := responseMem.ReadBytes()
|
||||
|
||||
// Parse binary-framed response
|
||||
if len(responseBytes) == 0 {
|
||||
return "", nil, errors.New("empty response from host")
|
||||
}
|
||||
if responseBytes[0] == 0x01 { // error
|
||||
return "", nil, errors.New(string(responseBytes[1:]))
|
||||
}
|
||||
if responseBytes[0] != 0x00 {
|
||||
return "", nil, errors.New("unknown response status")
|
||||
}
|
||||
if len(responseBytes) < 5 {
|
||||
return "", nil, errors.New("malformed raw response: incomplete header")
|
||||
}
|
||||
ctLen := binary.BigEndian.Uint32(responseBytes[1:5])
|
||||
if uint32(len(responseBytes)) < 5+ctLen {
|
||||
return "", nil, errors.New("malformed raw response: content-type overflow")
|
||||
}
|
||||
return string(responseBytes[5 : 5+ctLen]), responseBytes[5+ctLen:], nil
|
||||
}
|
||||
|
||||
@@ -33,3 +33,17 @@ func (m *mockSubsonicAPIService) Call(uri string) (string, error) {
|
||||
func SubsonicAPICall(uri string) (string, error) {
|
||||
return SubsonicAPIMock.Call(uri)
|
||||
}
|
||||
|
||||
// CallRaw is the mock method for SubsonicAPICallRaw.
|
||||
func (m *mockSubsonicAPIService) CallRaw(uri string) (string, []byte, error) {
|
||||
args := m.Called(uri)
|
||||
return args.String(0), args.Get(1).([]byte), args.Error(2)
|
||||
}
|
||||
|
||||
// SubsonicAPICallRaw delegates to the mock instance.
|
||||
// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
func SubsonicAPICallRaw(uri string) (string, []byte, error) {
|
||||
return SubsonicAPIMock.CallRaw(uri)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
# main __init__.py file. Copy the needed functions from this file into your plugin.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, Tuple
|
||||
|
||||
import extism
|
||||
import json
|
||||
import struct
|
||||
|
||||
|
||||
class HostFunctionError(Exception):
|
||||
@@ -25,6 +26,12 @@ def _subsonicapi_call(offset: int) -> int:
|
||||
...
|
||||
|
||||
|
||||
@extism.import_fn("extism:host/user", "subsonicapi_callraw")
|
||||
def _subsonicapi_callraw(offset: int) -> int:
|
||||
"""Raw host function - do not call directly."""
|
||||
...
|
||||
|
||||
|
||||
def subsonicapi_call(uri: str) -> str:
|
||||
"""Call executes a Subsonic API request and returns the JSON response.
|
||||
|
||||
@@ -53,3 +60,42 @@ e.g., "getAlbumList2?type=random&size=10". The response is returned as raw JSON.
|
||||
raise HostFunctionError(response["error"])
|
||||
|
||||
return response.get("responseJson", "")
|
||||
|
||||
|
||||
def subsonicapi_call_raw(uri: str) -> Tuple[str, bytes]:
|
||||
"""CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
Optimized for binary endpoints like getCoverArt and stream that return
|
||||
non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
|
||||
Args:
|
||||
uri: str parameter.
|
||||
|
||||
Returns:
|
||||
Tuple of (content_type, data) with the raw binary response.
|
||||
|
||||
Raises:
|
||||
HostFunctionError: If the host function returns an error.
|
||||
"""
|
||||
request = {
|
||||
"uri": uri,
|
||||
}
|
||||
request_bytes = json.dumps(request).encode("utf-8")
|
||||
request_mem = extism.memory.alloc(request_bytes)
|
||||
response_offset = _subsonicapi_callraw(request_mem.offset)
|
||||
response_mem = extism.memory.find(response_offset)
|
||||
response_bytes = response_mem.bytes()
|
||||
|
||||
if len(response_bytes) == 0:
|
||||
raise HostFunctionError("empty response from host")
|
||||
if response_bytes[0] == 0x01:
|
||||
raise HostFunctionError(response_bytes[1:].decode("utf-8"))
|
||||
if response_bytes[0] != 0x00:
|
||||
raise HostFunctionError("unknown response status")
|
||||
if len(response_bytes) < 5:
|
||||
raise HostFunctionError("malformed raw response: incomplete header")
|
||||
ct_len = struct.unpack(">I", response_bytes[1:5])[0]
|
||||
if len(response_bytes) < 5 + ct_len:
|
||||
raise HostFunctionError("malformed raw response: content-type overflow")
|
||||
content_type = response_bytes[5:5 + ct_len].decode("utf-8")
|
||||
data = response_bytes[5 + ct_len:]
|
||||
return content_type, data
|
||||
|
||||
6
plugins/pdk/rust/nd-pdk-host/Cargo.lock
generated
6
plugins/pdk/rust/nd-pdk-host/Cargo.lock
generated
@@ -28,9 +28,9 @@ checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.0"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
@@ -171,7 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "nd-host"
|
||||
name = "nd-pdk-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"extism-pdk",
|
||||
|
||||
@@ -21,11 +21,22 @@ struct SubsonicAPICallResponse {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SubsonicAPICallRawRequest {
|
||||
uri: String,
|
||||
}
|
||||
|
||||
#[host_fn]
|
||||
extern "ExtismHost" {
|
||||
fn subsonicapi_call(input: Json<SubsonicAPICallRequest>) -> Json<SubsonicAPICallResponse>;
|
||||
}
|
||||
|
||||
#[link(wasm_import_module = "extism:host/user")]
|
||||
extern "C" {
|
||||
fn subsonicapi_callraw(offset: u64) -> u64;
|
||||
}
|
||||
|
||||
/// Call executes a Subsonic API request and returns the JSON response.
|
||||
///
|
||||
/// The uri parameter should be the Subsonic API path without the server prefix,
|
||||
@@ -52,3 +63,56 @@ pub fn call(uri: &str) -> Result<String, Error> {
|
||||
|
||||
Ok(response.0.response_json)
|
||||
}
|
||||
|
||||
/// CallRaw executes a Subsonic API request and returns the raw binary response.
|
||||
/// Optimized for binary endpoints like getCoverArt and stream that return
|
||||
/// non-JSON data. The response is returned as raw bytes without JSON encoding overhead.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `uri` - String parameter.
|
||||
///
|
||||
/// # Returns
|
||||
/// A tuple of (content_type, data) with the raw binary response.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if the host function call fails.
|
||||
pub fn call_raw(uri: &str) -> Result<(String, Vec<u8>), Error> {
|
||||
let req = SubsonicAPICallRawRequest {
|
||||
uri: uri.to_owned(),
|
||||
};
|
||||
let input_bytes = serde_json::to_vec(&req).map_err(|e| Error::msg(e.to_string()))?;
|
||||
let input_mem = Memory::from_bytes(&input_bytes).map_err(|e| Error::msg(e.to_string()))?;
|
||||
|
||||
let response_offset = unsafe { subsonicapi_callraw(input_mem.offset()) };
|
||||
|
||||
let response_mem = Memory::find(response_offset)
|
||||
.ok_or_else(|| Error::msg("empty response from host"))?;
|
||||
let response_bytes = response_mem.to_vec();
|
||||
|
||||
if response_bytes.is_empty() {
|
||||
return Err(Error::msg("empty response from host"));
|
||||
}
|
||||
if response_bytes[0] == 0x01 {
|
||||
let msg = String::from_utf8_lossy(&response_bytes[1..]).to_string();
|
||||
return Err(Error::msg(msg));
|
||||
}
|
||||
if response_bytes[0] != 0x00 {
|
||||
return Err(Error::msg("unknown response status"));
|
||||
}
|
||||
if response_bytes.len() < 5 {
|
||||
return Err(Error::msg("malformed raw response: incomplete header"));
|
||||
}
|
||||
let ct_len = u32::from_be_bytes([
|
||||
response_bytes[1],
|
||||
response_bytes[2],
|
||||
response_bytes[3],
|
||||
response_bytes[4],
|
||||
]) as usize;
|
||||
if ct_len > response_bytes.len() - 5 {
|
||||
return Err(Error::msg("malformed raw response: content-type overflow"));
|
||||
}
|
||||
let ct_end = 5 + ct_len;
|
||||
let content_type = String::from_utf8_lossy(&response_bytes[5..ct_end]).to_string();
|
||||
let data = response_bytes[ct_end..].to_vec();
|
||||
Ok((content_type, data))
|
||||
}
|
||||
|
||||
26
plugins/testdata/test-subsonicapi-plugin/main.go
vendored
26
plugins/testdata/test-subsonicapi-plugin/main.go
vendored
@@ -3,6 +3,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/host"
|
||||
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
|
||||
)
|
||||
@@ -28,4 +30,28 @@ func callSubsonicAPIExport() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// call_subsonic_api_raw is the exported function that tests the SubsonicAPI CallRaw host function.
|
||||
// Input: URI string (e.g., "/getCoverArt?u=testuser&id=al-1")
|
||||
// Output: JSON with contentType, size, and first bytes of the raw response
|
||||
//
|
||||
//go:wasmexport call_subsonic_api_raw
|
||||
func callSubsonicAPIRawExport() int32 {
|
||||
uri := pdk.InputString()
|
||||
|
||||
contentType, data, err := host.SubsonicAPICallRaw(uri)
|
||||
if err != nil {
|
||||
pdk.SetErrorString("failed to call SubsonicAPI raw: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
// Return metadata about the raw response as JSON
|
||||
firstByte := 0
|
||||
if len(data) > 0 {
|
||||
firstByte = int(data[0])
|
||||
}
|
||||
result := fmt.Sprintf(`{"contentType":%q,"size":%d,"firstByte":%d}`, contentType, len(data), firstByte)
|
||||
pdk.OutputString(result)
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {}
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
@@ -31,40 +32,42 @@ type handlerRaw = func(http.ResponseWriter, *http.Request) (*responses.Subsonic,
|
||||
|
||||
type Router struct {
|
||||
http.Handler
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists core.Playlists
|
||||
scanner model.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
metrics metrics.Metrics
|
||||
ds model.DataStore
|
||||
artwork artwork.Artwork
|
||||
streamer core.MediaStreamer
|
||||
archiver core.Archiver
|
||||
players core.Players
|
||||
provider external.Provider
|
||||
playlists core.Playlists
|
||||
scanner model.Scanner
|
||||
broker events.Broker
|
||||
scrobbler scrobbler.PlayTracker
|
||||
share core.Share
|
||||
playback playback.PlaybackServer
|
||||
metrics metrics.Metrics
|
||||
transcodeDecision transcode.Decider
|
||||
}
|
||||
|
||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
|
||||
players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
|
||||
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
|
||||
metrics metrics.Metrics,
|
||||
metrics metrics.Metrics, transcodeDecision transcode.Decider,
|
||||
) *Router {
|
||||
r := &Router{
|
||||
ds: ds,
|
||||
artwork: artwork,
|
||||
streamer: streamer,
|
||||
archiver: archiver,
|
||||
players: players,
|
||||
provider: provider,
|
||||
playlists: playlists,
|
||||
scanner: scanner,
|
||||
broker: broker,
|
||||
scrobbler: scrobbler,
|
||||
share: share,
|
||||
playback: playback,
|
||||
metrics: metrics,
|
||||
ds: ds,
|
||||
artwork: artwork,
|
||||
streamer: streamer,
|
||||
archiver: archiver,
|
||||
players: players,
|
||||
provider: provider,
|
||||
playlists: playlists,
|
||||
scanner: scanner,
|
||||
broker: broker,
|
||||
scrobbler: scrobbler,
|
||||
share: share,
|
||||
playback: playback,
|
||||
metrics: metrics,
|
||||
transcodeDecision: transcodeDecision,
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
return r
|
||||
@@ -169,6 +172,8 @@ func (api *Router) routes() http.Handler {
|
||||
h(r, "getLyricsBySongId", api.GetLyricsBySongId)
|
||||
hr(r, "stream", api.Stream)
|
||||
hr(r, "download", api.Download)
|
||||
hr(r, "getTranscodeDecision", api.GetTranscodeDecision)
|
||||
hr(r, "getTranscodeStream", api.GetTranscodeStream)
|
||||
})
|
||||
r.Group(func(r chi.Router) {
|
||||
// configure request throttling
|
||||
|
||||
@@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playTracker = &fakePlayTracker{}
|
||||
eventBroker = &fakeEventBroker{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
Describe("Scrobble", func() {
|
||||
|
||||
@@ -33,7 +33,7 @@ var _ = Describe("MediaRetrievalController", func() {
|
||||
MockedMediaFile: mockRepo,
|
||||
}
|
||||
artwork = &fakeArtwork{data: "image data"}
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.LyricsPriority = "embedded,.lrc"
|
||||
|
||||
@@ -13,6 +13,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
|
||||
{Name: "formPost", Versions: []int32{1}},
|
||||
{Name: "songLyrics", Versions: []int32{1}},
|
||||
{Name: "indexBasedQueue", Versions: []int32{1}},
|
||||
{Name: "transcoding", Versions: []int32{1}},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
w = httptest.NewRecorder()
|
||||
r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil)
|
||||
})
|
||||
@@ -35,11 +35,12 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
|
||||
HaveLen(4),
|
||||
HaveLen(5),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}),
|
||||
ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}),
|
||||
))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/navidrome/navidrome/utils/gg"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
@@ -162,7 +163,11 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response
|
||||
pls.Duration = int32(p.Duration)
|
||||
pls.Created = p.CreatedAt
|
||||
if p.IsSmartPlaylist() {
|
||||
pls.Changed = time.Now()
|
||||
if p.EvaluatedAt != nil {
|
||||
pls.Changed = *p.EvaluatedAt
|
||||
} else {
|
||||
pls.Changed = time.Now()
|
||||
}
|
||||
} else {
|
||||
pls.Changed = p.UpdatedAt
|
||||
}
|
||||
@@ -176,6 +181,24 @@ func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) response
|
||||
pls.Owner = p.OwnerName
|
||||
pls.Public = p.Public
|
||||
pls.CoverArt = p.CoverArtID().String()
|
||||
pls.OpenSubsonicPlaylist = buildOSPlaylist(ctx, p)
|
||||
|
||||
return pls
|
||||
}
|
||||
|
||||
func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubsonicPlaylist {
|
||||
pls := responses.OpenSubsonicPlaylist{}
|
||||
|
||||
if p.IsSmartPlaylist() {
|
||||
pls.Readonly = true
|
||||
|
||||
if p.EvaluatedAt != nil {
|
||||
pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay))
|
||||
}
|
||||
} else {
|
||||
user, ok := request.UserFrom(ctx)
|
||||
pls.Readonly = !ok || p.OwnerID != user.ID
|
||||
}
|
||||
|
||||
return &pls
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/criteria"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -23,98 +24,196 @@ var _ = Describe("buildPlaylist", func() {
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
|
||||
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
||||
|
||||
playlist = model.Playlist{
|
||||
ID: "pls-1",
|
||||
Name: "My Playlist",
|
||||
Comment: "Test comment",
|
||||
OwnerName: "admin",
|
||||
Public: true,
|
||||
SongCount: 10,
|
||||
Duration: 600,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
Context("with minimal client", func() {
|
||||
Describe("normal playlist", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
||||
|
||||
playlist = model.Playlist{
|
||||
ID: "pls-1",
|
||||
Name: "My Playlist",
|
||||
Comment: "Test comment",
|
||||
OwnerName: "admin",
|
||||
OwnerID: "1234",
|
||||
Public: true,
|
||||
SongCount: 10,
|
||||
Duration: 600,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
It("returns only basic fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
It("returns only basic fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
// These should not be set
|
||||
Expect(result.Comment).To(BeEmpty())
|
||||
Expect(result.Owner).To(BeEmpty())
|
||||
Expect(result.Public).To(BeFalse())
|
||||
Expect(result.CoverArt).To(BeEmpty())
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
|
||||
// These should not be set
|
||||
Expect(result.Comment).To(BeEmpty())
|
||||
Expect(result.Owner).To(BeEmpty())
|
||||
Expect(result.Public).To(BeFalse())
|
||||
Expect(result.CoverArt).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
Expect(result.Readonly).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns all fields when as owner", func() {
|
||||
ctx = request.WithUser(ctx, model.User{ID: "1234", UserName: "admin"})
|
||||
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
Expect(result.Readonly).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
Describe("smart playlist", func() {
|
||||
evaluatedAt := time.Date(2023, 2, 20, 15, 45, 0, 0, time.UTC)
|
||||
validUntil := evaluatedAt.Add(5 * time.Second)
|
||||
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
||||
|
||||
playlist = model.Playlist{
|
||||
ID: "pls-1",
|
||||
Name: "My Playlist",
|
||||
Comment: "Test comment",
|
||||
OwnerName: "admin",
|
||||
OwnerID: "1234",
|
||||
Public: true,
|
||||
SongCount: 10,
|
||||
Duration: 600,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
EvaluatedAt: &evaluatedAt,
|
||||
Rules: &criteria.Criteria{
|
||||
Expression: criteria.All{criteria.Contains{"title": "title"}},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
It("returns only basic fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(evaluatedAt))
|
||||
|
||||
// These should not be set
|
||||
Expect(result.Comment).To(BeEmpty())
|
||||
Expect(result.Owner).To(BeEmpty())
|
||||
Expect(result.Public).To(BeFalse())
|
||||
Expect(result.CoverArt).To(BeEmpty())
|
||||
Expect(result.OpenSubsonicPlaylist).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(*playlist.EvaluatedAt))
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
Expect(result.Readonly).To(BeTrue())
|
||||
Expect(result.ValidUntil).To(Equal(&validUntil))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
var _ = Describe("UpdatePlaylist", func() {
|
||||
@@ -125,7 +224,7 @@ var _ = Describe("UpdatePlaylist", func() {
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
playlists = &fakePlaylists{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil)
|
||||
})
|
||||
|
||||
It("clears the comment when parameter is empty", func() {
|
||||
|
||||
@@ -14,9 +14,20 @@
|
||||
"duration": 120,
|
||||
"public": true,
|
||||
"owner": "admin",
|
||||
"created": "2023-02-20T14:45:00Z",
|
||||
"changed": "2023-02-20T14:45:00Z",
|
||||
"coverArt": "pl-123123123123",
|
||||
"readonly": true,
|
||||
"validUntil": "2023-02-20T14:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "333",
|
||||
"name": "ccc",
|
||||
"songCount": 0,
|
||||
"duration": 0,
|
||||
"created": "0001-01-01T00:00:00Z",
|
||||
"changed": "0001-01-01T00:00:00Z",
|
||||
"coverArt": "pl-123123123123"
|
||||
"readonly": false
|
||||
},
|
||||
{
|
||||
"id": "222",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playlists>
|
||||
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z" coverArt="pl-123123123123"></playlist>
|
||||
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="2023-02-20T14:45:00Z" changed="2023-02-20T14:45:00Z" coverArt="pl-123123123123" readonly="true" validUntil="2023-02-20T14:45:00Z"></playlist>
|
||||
<playlist id="333" name="ccc" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
<playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
</playlists>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -61,6 +61,7 @@ type Subsonic struct {
|
||||
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
|
||||
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
|
||||
PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
|
||||
TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -298,16 +299,17 @@ type AlbumList2 struct {
|
||||
}
|
||||
|
||||
type Playlist struct {
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int32 `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int32 `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
*OpenSubsonicPlaylist `xml:",omitempty" json:",omitempty"`
|
||||
/*
|
||||
<xs:sequence>
|
||||
<xs:element name="allowedUser" type="xs:string" minOccurs="0" maxOccurs="unbounded"/> <!--Added in 1.8.0-->
|
||||
@@ -315,6 +317,11 @@ type Playlist struct {
|
||||
*/
|
||||
}
|
||||
|
||||
type OpenSubsonicPlaylist struct {
|
||||
Readonly bool `xml:"readonly,attr,omitempty" json:"readonly"`
|
||||
ValidUntil *time.Time `xml:"validUntil,attr,omitempty" json:"validUntil,omitempty"`
|
||||
}
|
||||
|
||||
type Playlists struct {
|
||||
Playlist []Playlist `xml:"playlist" json:"playlist,omitempty"`
|
||||
}
|
||||
@@ -611,3 +618,26 @@ func marshalJSONArray[T any](v []T) ([]byte, error) {
|
||||
}
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// TranscodeDecision represents the response for getTranscodeDecision (OpenSubsonic transcoding extension)
|
||||
type TranscodeDecision struct {
|
||||
CanDirectPlay bool `xml:"canDirectPlay,attr" json:"canDirectPlay"`
|
||||
CanTranscode bool `xml:"canTranscode,attr" json:"canTranscode"`
|
||||
TranscodeReasons []string `xml:"transcodeReason,omitempty" json:"transcodeReason,omitempty"`
|
||||
ErrorReason string `xml:"errorReason,attr,omitempty" json:"errorReason,omitempty"`
|
||||
TranscodeParams string `xml:"transcodeParams,attr,omitempty" json:"transcodeParams,omitempty"`
|
||||
SourceStream *StreamDetails `xml:"sourceStream,omitempty" json:"sourceStream,omitempty"`
|
||||
TranscodeStream *StreamDetails `xml:"transcodeStream,omitempty" json:"transcodeStream,omitempty"`
|
||||
}
|
||||
|
||||
// StreamDetails describes audio stream properties for transcoding decisions
|
||||
type StreamDetails struct {
|
||||
Protocol string `xml:"protocol,attr,omitempty" json:"protocol,omitempty"`
|
||||
Container string `xml:"container,attr,omitempty" json:"container,omitempty"`
|
||||
Codec string `xml:"codec,attr,omitempty" json:"codec,omitempty"`
|
||||
AudioChannels int32 `xml:"audioChannels,attr,omitempty" json:"audioChannels,omitempty"`
|
||||
AudioBitrate int32 `xml:"audioBitrate,attr,omitempty" json:"audioBitrate,omitempty"`
|
||||
AudioProfile string `xml:"audioProfile,attr,omitempty" json:"audioProfile,omitempty"`
|
||||
AudioSamplerate int32 `xml:"audioSamplerate,attr,omitempty" json:"audioSamplerate,omitempty"`
|
||||
AudioBitdepth int32 `xml:"audioBitdepth,attr,omitempty" json:"audioBitdepth,omitempty"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
. "github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/gg"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
@@ -531,9 +532,9 @@ var _ = Describe("Responses", func() {
|
||||
})
|
||||
|
||||
Context("with data", func() {
|
||||
timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00")
|
||||
timestamp := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
||||
BeforeEach(func() {
|
||||
pls := make([]Playlist, 2)
|
||||
pls := make([]Playlist, 3)
|
||||
pls[0] = Playlist{
|
||||
Id: "111",
|
||||
Name: "aaa",
|
||||
@@ -545,8 +546,13 @@ var _ = Describe("Responses", func() {
|
||||
CoverArt: "pl-123123123123",
|
||||
Created: timestamp,
|
||||
Changed: timestamp,
|
||||
OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{
|
||||
Readonly: true,
|
||||
ValidUntil: ×tamp,
|
||||
},
|
||||
}
|
||||
pls[1] = Playlist{Id: "222", Name: "bbb"}
|
||||
pls[1] = Playlist{Id: "333", Name: "ccc", OpenSubsonicPlaylist: &responses.OpenSubsonicPlaylist{}}
|
||||
pls[2] = Playlist{Id: "222", Name: "bbb"}
|
||||
response.Playlists.Playlist = pls
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ var _ = Describe("Search", func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// Get references to the mock repositories so we can inspect their Options
|
||||
mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo)
|
||||
|
||||
349
server/subsonic/transcode.go
Normal file
349
server/subsonic/transcode.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
// API-layer request structs for JSON unmarshaling (decoupled from core structs)
|
||||
|
||||
// clientInfoRequest represents client playback capabilities from the request body
|
||||
type clientInfoRequest struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
MaxAudioBitrate int `json:"maxAudioBitrate,omitempty"`
|
||||
MaxTranscodingAudioBitrate int `json:"maxTranscodingAudioBitrate,omitempty"`
|
||||
DirectPlayProfiles []directPlayProfileReq `json:"directPlayProfiles,omitempty"`
|
||||
TranscodingProfiles []transcodingProfileReq `json:"transcodingProfiles,omitempty"`
|
||||
CodecProfiles []codecProfileReq `json:"codecProfiles,omitempty"`
|
||||
}
|
||||
|
||||
// directPlayProfileReq describes a format the client can play directly
|
||||
type directPlayProfileReq struct {
|
||||
Containers []string `json:"containers,omitempty"`
|
||||
AudioCodecs []string `json:"audioCodecs,omitempty"`
|
||||
Protocols []string `json:"protocols,omitempty"`
|
||||
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||
}
|
||||
|
||||
// transcodingProfileReq describes a transcoding target the client supports
|
||||
type transcodingProfileReq struct {
|
||||
Container string `json:"container,omitempty"`
|
||||
AudioCodec string `json:"audioCodec,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
MaxAudioChannels int `json:"maxAudioChannels,omitempty"`
|
||||
}
|
||||
|
||||
// codecProfileReq describes codec-specific limitations
|
||||
type codecProfileReq struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Limitations []limitationReq `json:"limitations,omitempty"`
|
||||
}
|
||||
|
||||
// limitationReq describes a specific codec limitation
|
||||
type limitationReq struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Comparison string `json:"comparison,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
}
|
||||
|
||||
// toCoreClientInfo converts the API request struct to the transcode.ClientInfo struct.
|
||||
// The OpenSubsonic spec uses bps for bitrate values; core uses kbps.
|
||||
func (r *clientInfoRequest) toCoreClientInfo() *transcode.ClientInfo {
|
||||
ci := &transcode.ClientInfo{
|
||||
Name: r.Name,
|
||||
Platform: r.Platform,
|
||||
MaxAudioBitrate: bpsToKbps(r.MaxAudioBitrate),
|
||||
MaxTranscodingAudioBitrate: bpsToKbps(r.MaxTranscodingAudioBitrate),
|
||||
}
|
||||
|
||||
for _, dp := range r.DirectPlayProfiles {
|
||||
ci.DirectPlayProfiles = append(ci.DirectPlayProfiles, transcode.DirectPlayProfile{
|
||||
Containers: dp.Containers,
|
||||
AudioCodecs: dp.AudioCodecs,
|
||||
Protocols: dp.Protocols,
|
||||
MaxAudioChannels: dp.MaxAudioChannels,
|
||||
})
|
||||
}
|
||||
|
||||
for _, tp := range r.TranscodingProfiles {
|
||||
ci.TranscodingProfiles = append(ci.TranscodingProfiles, transcode.Profile{
|
||||
Container: tp.Container,
|
||||
AudioCodec: tp.AudioCodec,
|
||||
Protocol: tp.Protocol,
|
||||
MaxAudioChannels: tp.MaxAudioChannels,
|
||||
})
|
||||
}
|
||||
|
||||
for _, cp := range r.CodecProfiles {
|
||||
coreCP := transcode.CodecProfile{
|
||||
Type: cp.Type,
|
||||
Name: cp.Name,
|
||||
}
|
||||
for _, lim := range cp.Limitations {
|
||||
coreLim := transcode.Limitation{
|
||||
Name: lim.Name,
|
||||
Comparison: lim.Comparison,
|
||||
Values: lim.Values,
|
||||
Required: lim.Required,
|
||||
}
|
||||
// Convert audioBitrate limitation values from bps to kbps
|
||||
if lim.Name == transcode.LimitationAudioBitrate {
|
||||
coreLim.Values = convertBitrateValues(lim.Values)
|
||||
}
|
||||
coreCP.Limitations = append(coreCP.Limitations, coreLim)
|
||||
}
|
||||
ci.CodecProfiles = append(ci.CodecProfiles, coreCP)
|
||||
}
|
||||
|
||||
return ci
|
||||
}
|
||||
|
||||
// bpsToKbps converts bits per second to kilobits per second.
|
||||
func bpsToKbps(bps int) int {
|
||||
return bps / 1000
|
||||
}
|
||||
|
||||
// kbpsToBps converts kilobits per second to bits per second.
|
||||
func kbpsToBps(kbps int) int {
|
||||
return kbps * 1000
|
||||
}
|
||||
|
||||
// convertBitrateValues converts a slice of bps string values to kbps string values.
|
||||
func convertBitrateValues(bpsValues []string) []string {
|
||||
result := make([]string, len(bpsValues))
|
||||
for i, v := range bpsValues {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err == nil {
|
||||
result[i] = strconv.Itoa(n / 1000)
|
||||
} else {
|
||||
result[i] = v // preserve unparseable values as-is
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validate checks that all enum fields in the request contain valid values per the OpenSubsonic spec.
|
||||
func (r *clientInfoRequest) validate() error {
|
||||
for _, dp := range r.DirectPlayProfiles {
|
||||
for _, p := range dp.Protocols {
|
||||
if !isValidProtocol(p) {
|
||||
return fmt.Errorf("invalid protocol: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, tp := range r.TranscodingProfiles {
|
||||
if tp.Protocol != "" && !isValidProtocol(tp.Protocol) {
|
||||
return fmt.Errorf("invalid protocol: %s", tp.Protocol)
|
||||
}
|
||||
}
|
||||
for _, cp := range r.CodecProfiles {
|
||||
if !isValidCodecProfileType(cp.Type) {
|
||||
return fmt.Errorf("invalid codec profile type: %s", cp.Type)
|
||||
}
|
||||
for _, lim := range cp.Limitations {
|
||||
if !isValidLimitationName(lim.Name) {
|
||||
return fmt.Errorf("invalid limitation name: %s", lim.Name)
|
||||
}
|
||||
if !isValidComparison(lim.Comparison) {
|
||||
return fmt.Errorf("invalid comparison: %s", lim.Comparison)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidProtocol(p string) bool {
|
||||
return p == transcode.ProtocolHTTP || p == transcode.ProtocolHLS
|
||||
}
|
||||
|
||||
func isValidCodecProfileType(t string) bool {
|
||||
return t == transcode.CodecProfileTypeAudio
|
||||
}
|
||||
|
||||
func isValidLimitationName(n string) bool {
|
||||
return n == transcode.LimitationAudioChannels ||
|
||||
n == transcode.LimitationAudioBitrate ||
|
||||
n == transcode.LimitationAudioProfile ||
|
||||
n == transcode.LimitationAudioSamplerate ||
|
||||
n == transcode.LimitationAudioBitdepth
|
||||
}
|
||||
|
||||
func isValidComparison(c string) bool {
|
||||
return c == transcode.ComparisonEquals ||
|
||||
c == transcode.ComparisonNotEquals ||
|
||||
c == transcode.ComparisonLessThanEqual ||
|
||||
c == transcode.ComparisonGreaterThanEqual
|
||||
}
|
||||
|
||||
// GetTranscodeDecision handles the OpenSubsonic getTranscodeDecision endpoint.
|
||||
// It receives client capabilities and returns a decision on whether to direct play or transcode.
|
||||
func (api *Router) GetTranscodeDecision(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
|
||||
mediaID, err := p.String("mediaId")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
|
||||
}
|
||||
|
||||
mediaType, err := p.String("mediaType")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
|
||||
}
|
||||
|
||||
// Only support songs for now
|
||||
if mediaType != "song" {
|
||||
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
|
||||
}
|
||||
|
||||
// Parse and validate ClientInfo from request body (required per OpenSubsonic spec)
|
||||
var clientInfoReq clientInfoRequest
|
||||
if r.Body == nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required JSON request body")
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&clientInfoReq); err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "invalid JSON request body")
|
||||
}
|
||||
if err := clientInfoReq.validate(); err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "%v", err)
|
||||
}
|
||||
clientInfo := clientInfoReq.toCoreClientInfo()
|
||||
|
||||
// Get media file
|
||||
mf, err := api.ds.MediaFile(ctx).Get(mediaID)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorDataNotFound, "media file not found: %s", mediaID)
|
||||
}
|
||||
|
||||
// Make the decision
|
||||
decision, err := api.transcodeDecision.MakeDecision(ctx, mf, clientInfo)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "failed to make transcode decision: %v", err)
|
||||
}
|
||||
|
||||
// Create transcode params token
|
||||
transcodeParams, err := api.transcodeDecision.CreateTranscodeParams(decision)
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorGeneric, "failed to create transcode token: %v", err)
|
||||
}
|
||||
|
||||
// Build response (convert kbps from core to bps for the API)
|
||||
response := newResponse()
|
||||
response.TranscodeDecision = &responses.TranscodeDecision{
|
||||
CanDirectPlay: decision.CanDirectPlay,
|
||||
CanTranscode: decision.CanTranscode,
|
||||
TranscodeReasons: decision.TranscodeReasons,
|
||||
ErrorReason: decision.ErrorReason,
|
||||
TranscodeParams: transcodeParams,
|
||||
SourceStream: &responses.StreamDetails{
|
||||
Protocol: "http",
|
||||
Container: decision.SourceStream.Container,
|
||||
Codec: decision.SourceStream.Codec,
|
||||
AudioBitrate: int32(kbpsToBps(decision.SourceStream.Bitrate)),
|
||||
AudioProfile: decision.SourceStream.Profile,
|
||||
AudioSamplerate: int32(decision.SourceStream.SampleRate),
|
||||
AudioBitdepth: int32(decision.SourceStream.BitDepth),
|
||||
AudioChannels: int32(decision.SourceStream.Channels),
|
||||
},
|
||||
}
|
||||
|
||||
if decision.TranscodeStream != nil {
|
||||
response.TranscodeDecision.TranscodeStream = &responses.StreamDetails{
|
||||
Protocol: "http",
|
||||
Container: decision.TranscodeStream.Container,
|
||||
Codec: decision.TranscodeStream.Codec,
|
||||
AudioBitrate: int32(kbpsToBps(decision.TranscodeStream.Bitrate)),
|
||||
AudioProfile: decision.TranscodeStream.Profile,
|
||||
AudioSamplerate: int32(decision.TranscodeStream.SampleRate),
|
||||
AudioBitdepth: int32(decision.TranscodeStream.BitDepth),
|
||||
AudioChannels: int32(decision.TranscodeStream.Channels),
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetTranscodeStream handles the OpenSubsonic getTranscodeStream endpoint.
|
||||
// It streams media using the decision encoded in the transcodeParams JWT token.
|
||||
func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) {
|
||||
ctx := r.Context()
|
||||
p := req.Params(r)
|
||||
|
||||
mediaID, err := p.String("mediaId")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaId")
|
||||
}
|
||||
|
||||
mediaType, err := p.String("mediaType")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: mediaType")
|
||||
}
|
||||
|
||||
transcodeParams, err := p.String("transcodeParams")
|
||||
if err != nil {
|
||||
return nil, newError(responses.ErrorMissingParameter, "missing required parameter: transcodeParams")
|
||||
}
|
||||
|
||||
// Only support songs for now
|
||||
if mediaType != "song" {
|
||||
return nil, newError(responses.ErrorGeneric, "mediaType '%s' is not yet supported", mediaType)
|
||||
}
|
||||
|
||||
// Parse and validate the token
|
||||
params, err := api.transcodeDecision.ParseTranscodeParams(transcodeParams)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Failed to parse transcode token", err)
|
||||
return nil, newError(responses.ErrorDataNotFound, "invalid or expired transcodeParams token")
|
||||
}
|
||||
|
||||
// Verify mediaId matches token
|
||||
if params.MediaID != mediaID {
|
||||
return nil, newError(responses.ErrorDataNotFound, "mediaId does not match token")
|
||||
}
|
||||
|
||||
// Determine streaming parameters
|
||||
format := ""
|
||||
maxBitRate := 0
|
||||
if !params.DirectPlay && params.TargetFormat != "" {
|
||||
format = params.TargetFormat
|
||||
maxBitRate = params.TargetBitrate // Already in kbps, matching the streamer
|
||||
}
|
||||
|
||||
// Get offset parameter
|
||||
offset := p.IntOr("offset", 0)
|
||||
|
||||
// Create stream
|
||||
stream, err := api.streamer.NewStream(ctx, mediaID, format, maxBitRate, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure the stream will be closed at the end
|
||||
defer func() {
|
||||
if err := stream.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||
log.Error("Error closing stream", "id", mediaID, "file", stream.Name(), err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
api.serveStream(ctx, w, r, stream, mediaID)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
268
server/subsonic/transcode_test.go
Normal file
268
server/subsonic/transcode_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core/transcode"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Transcode endpoints", func() {
|
||||
var (
|
||||
router *Router
|
||||
ds *tests.MockDataStore
|
||||
mockTD *mockTranscodeDecision
|
||||
w *httptest.ResponseRecorder
|
||||
mockMFRepo *tests.MockMediaFileRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
mockMFRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo}
|
||||
mockTD = &mockTranscodeDecision{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD)
|
||||
w = httptest.NewRecorder()
|
||||
})
|
||||
|
||||
Describe("GetTranscodeDecision", func() {
|
||||
It("returns 405 for non-POST requests", func() {
|
||||
r := newGetRequest("mediaId=123", "mediaType=song")
|
||||
resp, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp).To(BeNil())
|
||||
Expect(w.Code).To(Equal(http.StatusMethodNotAllowed))
|
||||
Expect(w.Header().Get("Allow")).To(Equal("POST"))
|
||||
})
|
||||
|
||||
It("returns error when mediaId is missing", func() {
|
||||
r := newJSONPostRequest("mediaType=song", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when mediaType is missing", func() {
|
||||
r := newJSONPostRequest("mediaId=123", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for unsupported mediaType", func() {
|
||||
r := newJSONPostRequest("mediaId=123&mediaType=podcast", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("not yet supported"))
|
||||
})
|
||||
|
||||
It("returns error when media file not found", func() {
|
||||
mockMFRepo.SetError(true)
|
||||
r := newJSONPostRequest("mediaId=notfound&mediaType=song", "{}")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when body is empty", func() {
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when body contains invalid JSON", func() {
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", "not-json{{{")
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for invalid protocol in direct play profile", func() {
|
||||
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["ftp"]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
||||
})
|
||||
|
||||
It("returns error for invalid comparison operator", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"InvalidOp","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
||||
})
|
||||
|
||||
It("returns error for invalid limitation name", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"unknownField","comparison":"Equals","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
||||
})
|
||||
|
||||
It("returns error for invalid codec profile type", func() {
|
||||
body := `{"codecProfiles":[{"type":"VideoCodec","name":"mp3"}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case protocol", func() {
|
||||
body := `{"directPlayProfiles":[{"containers":["mp3"],"audioCodecs":["mp3"],"protocols":["HTTP"]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid protocol"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case codec profile type", func() {
|
||||
body := `{"codecProfiles":[{"type":"audiocodec","name":"mp3"}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid codec profile type"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case comparison operator", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"audioBitrate","comparison":"lessthanequal","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid comparison"))
|
||||
})
|
||||
|
||||
It("rejects wrong-case limitation name", func() {
|
||||
body := `{"codecProfiles":[{"type":"AudioCodec","name":"mp3","limitations":[{"name":"AudioBitrate","comparison":"Equals","values":["320"]}]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
_, err := router.GetTranscodeDecision(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("invalid limitation name"))
|
||||
})
|
||||
|
||||
It("returns a valid decision response", func() {
|
||||
mockMFRepo.SetData(model.MediaFiles{
|
||||
{ID: "song-1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100},
|
||||
})
|
||||
mockTD.decision = &transcode.Decision{
|
||||
MediaID: "song-1",
|
||||
CanDirectPlay: true,
|
||||
SourceStream: transcode.StreamDetails{
|
||||
Container: "mp3", Codec: "mp3", Bitrate: 320,
|
||||
SampleRate: 44100, Channels: 2,
|
||||
},
|
||||
}
|
||||
mockTD.token = "test-jwt-token"
|
||||
|
||||
body := `{"directPlayProfiles":[{"containers":["mp3"],"protocols":["http"]}]}`
|
||||
r := newJSONPostRequest("mediaId=song-1&mediaType=song", body)
|
||||
resp, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.TranscodeDecision).ToNot(BeNil())
|
||||
Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue())
|
||||
Expect(resp.TranscodeDecision.TranscodeParams).To(Equal("test-jwt-token"))
|
||||
Expect(resp.TranscodeDecision.SourceStream).ToNot(BeNil())
|
||||
Expect(resp.TranscodeDecision.SourceStream.Protocol).To(Equal("http"))
|
||||
Expect(resp.TranscodeDecision.SourceStream.Container).To(Equal("mp3"))
|
||||
Expect(resp.TranscodeDecision.SourceStream.AudioBitrate).To(Equal(int32(320_000)))
|
||||
})
|
||||
|
||||
It("includes transcode stream when transcoding", func() {
|
||||
mockMFRepo.SetData(model.MediaFiles{
|
||||
{ID: "song-2", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 96000, BitDepth: 24},
|
||||
})
|
||||
mockTD.decision = &transcode.Decision{
|
||||
MediaID: "song-2",
|
||||
CanDirectPlay: false,
|
||||
CanTranscode: true,
|
||||
TargetFormat: "mp3",
|
||||
TargetBitrate: 256,
|
||||
TranscodeReasons: []string{"container not supported"},
|
||||
SourceStream: transcode.StreamDetails{
|
||||
Container: "flac", Codec: "flac", Bitrate: 1000,
|
||||
SampleRate: 96000, BitDepth: 24, Channels: 2,
|
||||
},
|
||||
TranscodeStream: &transcode.StreamDetails{
|
||||
Container: "mp3", Codec: "mp3", Bitrate: 256,
|
||||
SampleRate: 96000, Channels: 2,
|
||||
},
|
||||
}
|
||||
mockTD.token = "transcode-token"
|
||||
|
||||
r := newJSONPostRequest("mediaId=song-2&mediaType=song", "{}")
|
||||
resp, err := router.GetTranscodeDecision(w, r)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue())
|
||||
Expect(resp.TranscodeDecision.TranscodeReasons).To(ConsistOf("container not supported"))
|
||||
Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil())
|
||||
Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetTranscodeStream", func() {
|
||||
It("returns error when mediaId is missing", func() {
|
||||
r := newGetRequest("mediaType=song", "transcodeParams=abc")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when transcodeParams is missing", func() {
|
||||
r := newGetRequest("mediaId=123", "mediaType=song")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error for invalid token", func() {
|
||||
mockTD.parseErr = model.ErrNotFound
|
||||
r := newGetRequest("mediaId=123", "mediaType=song", "transcodeParams=bad-token")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("returns error when mediaId doesn't match token", func() {
|
||||
mockTD.params = &transcode.Params{MediaID: "other-id", DirectPlay: true}
|
||||
r := newGetRequest("mediaId=wrong-id", "mediaType=song", "transcodeParams=valid-token")
|
||||
_, err := router.GetTranscodeStream(w, r)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("does not match"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// newJSONPostRequest creates an HTTP POST request with JSON body and query params
|
||||
func newJSONPostRequest(queryParams string, jsonBody string) *http.Request {
|
||||
r := httptest.NewRequest("POST", "/getTranscodeDecision?"+queryParams, bytes.NewBufferString(jsonBody))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
return r
|
||||
}
|
||||
|
||||
// mockTranscodeDecision is a test double for core.TranscodeDecision
|
||||
type mockTranscodeDecision struct {
|
||||
decision *transcode.Decision
|
||||
token string
|
||||
tokenErr error
|
||||
params *transcode.Params
|
||||
parseErr error
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) MakeDecision(_ context.Context, _ *model.MediaFile, _ *transcode.ClientInfo) (*transcode.Decision, error) {
|
||||
if m.decision != nil {
|
||||
return m.decision, nil
|
||||
}
|
||||
return &transcode.Decision{}, nil
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) CreateTranscodeParams(_ *transcode.Decision) (string, error) {
|
||||
return m.token, m.tokenErr
|
||||
}
|
||||
|
||||
func (m *mockTranscodeDecision) ParseTranscodeParams(_ string) (*transcode.Params, error) {
|
||||
if m.parseErr != nil {
|
||||
return nil, m.parseErr
|
||||
}
|
||||
return m.params, nil
|
||||
}
|
||||
6
ui/package-lock.json
generated
6
ui/package-lock.json
generated
@@ -2395,9 +2395,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { IconButton, Tooltip, Link } from '@material-ui/core'
|
||||
|
||||
import { ImLastfm2 } from 'react-icons/im'
|
||||
import MusicBrainz from '../icons/MusicBrainz'
|
||||
import { intersperse } from '../utils'
|
||||
import { intersperse, isLastFmURL } from '../utils'
|
||||
import config from '../config'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
@@ -38,13 +38,13 @@ const ArtistExternalLinks = ({ artistInfo, record }) => {
|
||||
}
|
||||
|
||||
if (config.lastFMEnabled) {
|
||||
if (lastFMlink) {
|
||||
if (lastFMlink && isLastFmURL(lastFMlink[2])) {
|
||||
addLink(
|
||||
lastFMlink[2],
|
||||
'message.openIn.lastfm',
|
||||
<ImLastfm2 className="lastfm-icon" />,
|
||||
)
|
||||
} else if (artistInfo?.lastFmUrl) {
|
||||
} else if (isLastFmURL(artistInfo?.lastFmUrl)) {
|
||||
addLink(
|
||||
artistInfo?.lastFmUrl,
|
||||
'message.openIn.lastfm',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export const SafeHTML = ({ children }) => {
|
||||
const purified = useMemo(() => {
|
||||
@@ -23,5 +23,5 @@ export const SafeHTML = ({ children }) => {
|
||||
return purify.sanitize(children, { ADD_ATTR: ['referrer-policy'] })
|
||||
}, [children])
|
||||
|
||||
return <Fragment dangerouslySetInnerHTML={{ __html: purified }} />
|
||||
return <span dangerouslySetInnerHTML={{ __html: purified }} />
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// ============================================
|
||||
|
||||
const ACCENT_COLOR = '#009688' // Material teal
|
||||
const UNBOUNDED_FONT_PATH = 'fonts/Unbounded-Variable.woff2'
|
||||
|
||||
// ============================================
|
||||
// DESIGN TOKENS
|
||||
@@ -69,7 +70,7 @@ const tokens = {
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Unbounded-Variable.woff2') format('woff2');
|
||||
src: url('${UNBOUNDED_FONT_PATH}') format('woff2');
|
||||
}
|
||||
`,
|
||||
},
|
||||
@@ -275,7 +276,7 @@ const NautilineTheme = {
|
||||
fontStyle: 'normal',
|
||||
fontWeight: '300 800',
|
||||
fontDisplay: 'swap',
|
||||
src: "url('/fonts/Unbounded-Variable.woff2') format('woff2')",
|
||||
src: `url('${UNBOUNDED_FONT_PATH}') format('woff2')`,
|
||||
},
|
||||
body: {
|
||||
backgroundColor: colors.background.primary,
|
||||
@@ -794,7 +795,7 @@ const NautilineTheme = {
|
||||
font-style: normal;
|
||||
font-weight: 300 800;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Unbounded-Variable.woff2') format('woff2');
|
||||
src: url('${UNBOUNDED_FONT_PATH}') format('woff2');
|
||||
}
|
||||
|
||||
.react-jinke-music-player-main {
|
||||
|
||||
@@ -44,3 +44,16 @@ export const shareCoverUrl = (id, square) => {
|
||||
}
|
||||
|
||||
export const docsUrl = (path) => `https://www.navidrome.org${path}`
|
||||
|
||||
export const isLastFmURL = (url) => {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||
(parsed.hostname === 'last.fm' || parsed.hostname.endsWith('.last.fm')) &&
|
||||
parsed.pathname.startsWith('/music/')
|
||||
)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
25
ui/src/utils/urls.test.js
Normal file
25
ui/src/utils/urls.test.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { isLastFmURL } from './urls'
|
||||
|
||||
describe('isLastFmURL', () => {
|
||||
it('returns true for valid Last.fm music URLs', () => {
|
||||
expect(isLastFmURL('https://last.fm/music/The+Beatles')).toBe(true)
|
||||
expect(isLastFmURL('http://last.fm/music/Radiohead')).toBe(true)
|
||||
expect(isLastFmURL('https://www.last.fm/music/Daft+Punk')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-http(s) protocols (XSS prevention)', () => {
|
||||
expect(isLastFmURL('javascript:alert(1)//last.fm/music/')).toBe(false)
|
||||
expect(isLastFmURL('data:text/html,<script>//last.fm/music/')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for non-last.fm domains', () => {
|
||||
expect(isLastFmURL('https://example.com/?q=last.fm/music/')).toBe(false)
|
||||
expect(isLastFmURL('https://fake-last.fm/music/Artist')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for invalid paths or inputs', () => {
|
||||
expect(isLastFmURL('https://last.fm/user/someone')).toBe(false)
|
||||
expect(isLastFmURL(null)).toBe(false)
|
||||
expect(isLastFmURL('not-a-url')).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user