Compare commits

..

1 Commits

Author SHA1 Message Date
Deluan
4ccc18ba02 Revert "fix: build on Go 1.25.6"
This reverts commit 84bf4fac04.

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-16 19:20:40 -05:00
132 changed files with 2806 additions and 11451 deletions

View File

@@ -23,7 +23,5 @@ RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
&& rmdir /usr/include/taglib \
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
ENV CGO_CFLAGS_ALLOW="--define-prefix"
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View File

@@ -15,7 +15,6 @@ concurrency:
env:
CROSS_TAGLIB_VERSION: "2.1.1-1"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:

3
.gitignore vendored
View File

@@ -35,5 +35,4 @@ AGENTS.md
*.test
*.wasm
*.ndp
openspec/
go.work*
openspec/

View File

@@ -94,7 +94,6 @@ RUN --mount=type=bind,source=. \
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
export CGO_CFLAGS_ALLOW="--define-prefix"
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
cat $(go env GOENV)

View File

@@ -2,7 +2,6 @@ GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
# Set global environment variables, required for most targets
export CGO_CFLAGS_ALLOW=--define-prefix
export ND_ENABLEINSIGHTSCOLLECTOR=false
ifneq ("$(wildcard .git/HEAD)","")

View File

@@ -45,28 +45,6 @@ var _ = Describe("client", func() {
})
})
Describe("TopTracks", func() {
It("returns top tracks with artist and album info from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
Expect(err).To(BeNil())
Expect(tracks).To(HaveLen(5))
// Verify first track has all expected fields
Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
// Verify second track
Expect(tracks[1].Title).To(Equal("One More Time"))
Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[1].Album.Title).To(Equal("Discovery"))
})
})
Describe("ArtistBio", func() {
BeforeEach(func() {
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes

View File

@@ -135,9 +135,7 @@ func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ st
res := slice.Map(tracks, func(r Track) agents.Song {
return agents.Song{
Name: r.Title,
Album: r.Album.Title,
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
Name: r.Title,
}
})
return res, nil

View File

@@ -1,274 +0,0 @@
package gotaglib
import (
"io/fs"
"os"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
"github.com/navidrome/navidrome/utils/gg"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type testFileInfo struct {
fs.FileInfo
}
func (t testFileInfo) BirthTime() time.Time {
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return t.FileInfo.ModTime()
}
var _ = Describe("Extractor", func() {
toP := func(name, sortName, mbid string) model.Participant {
return model.Participant{
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
}
}
roles := []struct {
model.Role
model.ParticipantList
}{
{model.RoleComposer, model.ParticipantList{
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
}},
{model.RoleLyricist, model.ParticipantList{
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
}},
{model.RoleArranger, model.ParticipantList{
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
}},
{model.RoleConductor, model.ParticipantList{
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
}},
{model.RoleDirector, model.ParticipantList{
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
}},
{model.RoleEngineer, model.ParticipantList{
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
}},
{model.RoleProducer, model.ParticipantList{
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
}},
{model.RoleRemixer, model.ParticipantList{
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
}},
{model.RoleDJMixer, model.ParticipantList{
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
}},
{model.RoleMixer, model.ParticipantList{
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
}},
}
var e *extractor
parseTestFile := func(path string) *model.MediaFile {
mds, err := e.Parse(path)
Expect(err).ToNot(HaveOccurred())
info, ok := mds[path]
Expect(ok).To(BeTrue())
fileInfo, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
mf := metadata.ToMediaFile(1, "folderID")
return &mf
}
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
})
Describe("ReplayGain", func() {
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
mf := parseTestFile("tests/fixtures/" + file)
Expect(mf.RGTrackGain).To(Equal(trackGain))
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
Expect(mf.RGAlbumGain).To(Equal(albumGain))
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
)
})
Describe("lyrics", func() {
makeLyrics := func(code, secondLine string) model.Lyrics {
return model.Lyrics{
DisplayArtist: "",
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: gg.P(int64(0)), Value: "This is"},
{Start: gg.P(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,
}
}
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(2))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[1].Synced).To(BeFalse())
})
It("should handle mp3 with uslt and sylt", func() {
mf := parseTestFile("tests/fixtures/test.mp3")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(4))
engSylt := makeLyrics("eng", "English SYLT")
engUslt := makeLyrics("eng", "English")
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
mf := parseTestFile("tests/fixtures/" + file)
lyrics, err := mf.StructuredLyrics()
Expect(err).To(Not(HaveOccurred()))
Expect(lyrics).To(HaveLen(2))
unspec := makeLyrics("xxx", "unspecified")
eng := makeLyrics("xxx", "English")
if isId3 {
eng.Lang = "eng"
}
Expect(lyrics).To(Or(
Equal(model.LyricList{unspec, eng}),
Equal(model.LyricList{eng, unspec})))
},
Entry("flac", "test.flac", false),
Entry("m4a", "test.m4a", false),
Entry("ogg", "test.ogg", false),
Entry("wma", "test.wma", false),
Entry("wv", "test.wv", false),
Entry("wav", "test.wav", true),
Entry("aiff", "test.aiff", true),
)
})
Describe("Participants", func() {
DescribeTable("test tags consistent across formats", func(format string) {
mf := parseTestFile("tests/fixtures/test." + format)
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
Expect(actual).To(HaveLen(len(artists)))
for i := range artists {
actualArtist := actual[i]
expectedArtist := artists[i]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
}
}
if format != "m4a" {
performers := mf.Participants[model.RolePerformer]
Expect(performers).To(HaveLen(8))
rules := map[string][]string{
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
}
for name, rule := range rules {
mbid := rule[0]
for i := 1; i < len(rule); i++ {
found := false
for _, mapped := range performers {
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
found = true
break
}
}
Expect(found).To(BeTrue(), "Could not find matching artist")
}
}
}
},
Entry("FLAC format", "flac"),
Entry("M4a format", "m4a"),
Entry("OGG format", "ogg"),
Entry("WV format", "wv"),
Entry("MP3 format", "mp3"),
Entry("WAV format", "wav"),
Entry("AIFF format", "aiff"),
)
It("should parse wma", func() {
mf := parseTestFile("tests/fixtures/test.wma")
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
// WMA has no Arranger role
if role == model.RoleArranger {
Expect(actual).To(HaveLen(0))
continue
}
Expect(actual).To(HaveLen(len(artists)), role.String())
// For some bizarre reason, the order is inverted. We also don't get
// sort names or MBIDs
for i := range artists {
idx := len(artists) - 1 - i
actualArtist := actual[i]
expectedArtist := artists[idx]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
}
}
})
})
})

View File

@@ -1,263 +0,0 @@
// Package gotaglib provides an alternative metadata extractor using go-taglib,
// a pure Go (WASM-based) implementation of TagLib.
//
// This extractor aims for parity with the CGO-based taglib extractor. It uses
// TagLib's PropertyMap interface for standard tags. The File handle API provides
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
// through a single file open operation.
//
// This extractor is registered under the name "gotaglib". It only works with a filesystem
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
// must implement io.ReadSeeker for go-taglib to read them.
package gotaglib
import (
"errors"
"io"
"io/fs"
"strings"
"time"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/model/metadata"
"go.senan.xyz/taglib"
)
type extractor struct {
fs fs.FS
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
return "go-taglib (TagLib 2.1.1 WASM)"
}
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
f, close, err := e.openFile(filePath)
if err != nil {
return nil, err
}
defer close()
// Get all tags and properties in one go
allTags := f.AllTags()
props := f.Properties()
// Map properties to AudioProperties
ap := metadata.AudioProperties{
Duration: props.Length.Round(time.Millisecond * 10),
BitRate: int(props.Bitrate),
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
normalizedTags := make(map[string][]string, len(allTags.Tags))
for key, values := range allTags.Tags {
lowerKey := strings.ToLower(key)
normalizedTags[lowerKey] = values
}
// Process format-specific raw tags
processRawTags(allTags, normalizedTags)
// Parse track/disc totals from "N/Total" format
parseTuple(normalizedTags, "track")
parseTuple(normalizedTags, "disc")
// Adjust some ID3 tags
parseLyrics(normalizedTags)
parseTIPL(normalizedTags)
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
// Determine if file has embedded picture
hasPicture := len(props.Images) > 0
return &metadata.Info{
Tags: normalizedTags,
AudioProperties: ap,
HasPicture: hasPicture,
}, nil
}
// 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) {
// Open the file from the filesystem
file, err := e.fs.Open(filePath)
if err != nil {
return nil, nil, err
}
rs, isSeekable := file.(io.ReadSeeker)
if !isSeekable {
file.Close()
return nil, nil, errors.New("file is not seekable")
}
f, err := taglib.OpenStream(rs, taglib.WithReadStyle(taglib.ReadStyleFast))
if err != nil {
file.Close()
return nil, nil, err
}
closeFunc := func() {
f.Close()
file.Close()
}
return f, closeFunc, nil
}
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
func parseTuple(tags map[string][]string, prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
// parseLyrics ensures lyrics tags have a language code.
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// processRawTags processes format-specific raw tags based on the detected file format.
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
switch allTags.Format {
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
parseID3v2Frames(allTags.Raw, normalizedTags)
case taglib.FormatMP4:
parseMP4Atoms(allTags.Raw, normalizedTags)
case taglib.FormatASF:
parseASFAttributes(allTags.Raw, normalizedTags)
}
}
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
// Process frames that have language-specific data
for key, values := range rawFrames {
lowerKey := strings.ToLower(key)
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
parts := strings.SplitN(lowerKey, ":", 2)
if len(parts) == 2 && parts[1] != "" {
lang := parts[1]
lyricsKey := "lyrics:" + lang
tags[lyricsKey] = append(tags[lyricsKey], values...)
}
}
}
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
for key := range tags {
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
delete(tags, "lyrics")
break
}
}
}
const iTunesKeyPrefix = "----:com.apple.iTunes:"
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
// Process all atoms and add them to tags
for key, values := range rawAtoms {
// Strip iTunes prefix and convert to lowercase
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
normalizedKey = strings.ToLower(normalizedKey)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
// Process all attributes and add them to tags
for key, values := range rawAttrs {
normalizedKey := strings.ToLower(key)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for _, part := range strings.Split(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
}

View File

@@ -1,17 +0,0 @@
package gotaglib
import (
"testing"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestGoTagLib(t *testing.T) {
tests.Init(t, true)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "GoTagLib Suite")
}

View File

@@ -1,302 +0,0 @@
package gotaglib
import (
"io/fs"
"os"
"strings"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
})
Describe("Parse", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := e.Parse(
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
// Test MP3
m := mds["tests/fixtures/test.mp3"]
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.HasPicture).To(BeTrue())
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
Expect(m.AudioProperties.BitRate).To(Equal(192))
Expect(m.AudioProperties.Channels).To(Equal(2))
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("tcmp", []string{"1"})),
)
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
Expect(m.Tags).ToNot(HaveKey("lyrics"))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English SYLT\n",
"[00:00.00]This is\n[00:02.50]English",
}), HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
"[00:00.00]This is\n[00:02.50]English SYLT\n",
})))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
"[00:00.00]This is\n[00:02.50]unspecified",
}), HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
})))
// Test OGG
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TagLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[file]
Expect(m.HasPicture).To(Equal(image))
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
Expect(m.AudioProperties.Channels).To(Equal(channels))
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
))
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
HaveKeyWithValue("tracknumber", []string{"3"}),
HaveKeyWithValue("tracknumber", []string{"3/10"}),
))
if !strings.HasSuffix(file, "test.wma") {
// TODO Not sure why this is not working for WMA
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
}
Expect(m.Tags).To(Or(
HaveKeyWithValue("discnumber", []string{"1"}),
HaveKeyWithValue("discnumber", []string{"1/2"}),
))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
// WMA does not have a "compilation" tag, but "wm/iscompilation"
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
)
if id3Lyrics {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
} else {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]English",
}))
}
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
)
// Skip these tests when running as root
Context("Access Forbidden", func() {
var accessForbiddenFile string
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
// Use root fs for absolute paths in temp directory
e = &extractor{fs: os.DirFS("/")}
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("correctly handle unreadable file due to insufficient read permission", func() {
// Strip leading slash for DirFS rooted at "/"
_, err := e.extractMetadata(accessForbiddenFile[1:])
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
// Get current working directory to construct paths relative to root
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
// Strip leading slash for DirFS rooted at "/"
files := []string{
cwd[1:] + "/tests/fixtures/test.mp3",
cwd[1:] + "/tests/fixtures/test.ogg",
accessForbiddenFile[1:],
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
})
})
})
})
Describe("Error Checking", func() {
It("returns a generic ErrPath if file does not exist", func() {
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
_, err := e.extractMetadata(testFilePath)
Expect(err).To(MatchError(fs.ErrNotExist))
})
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
// File has an empty TDAT frame
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
})
})
Describe("parseTIPL", func() {
var tags map[string][]string
BeforeEach(func() {
tags = make(map[string][]string)
})
Context("when the TIPL string is populated", func() {
It("correctly parses roles and names", func() {
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
})
It("handles multiple names for a single role", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
It("discards roles without names", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
parseTIPL(tags)
Expect(tags).ToNot(HaveKey("producer"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
})
Context("when the TIPL string is empty", func() {
It("does nothing", func() {
tags["tipl"] = []string{""}
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
Context("when the TIPL is not present", func() {
It("does nothing", func() {
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
})
})

View File

@@ -192,26 +192,6 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, 0, len(resp))
for _, t := range resp {
res = append(res, agents.Song{
Name: t.Name,
MBID: t.MBID,
Artist: t.Artist.Name,
ArtistMBID: t.Artist.MBID,
})
}
return res, nil
}
var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
@@ -310,15 +290,6 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
s, err := l.client.trackGetSimilar(ctx, name, artist, count)
if err != nil {
log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
return nil, err
}
return s.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
return track.Participants[role][0].Name

View File

@@ -177,54 +177,6 @@ var _ = Describe("lastfmAgent", func() {
})
})
Describe("GetSimilarSongsByTrack", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", "pt", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns similar songs", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"},
{Name: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "7fb50287-029d-47cc-825a-235ca28024b2"},
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
})
It("returns ErrNotFound when no similar songs found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
})
Describe("Scrobbling", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient

View File

@@ -95,19 +95,6 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
return &response.TopTracks, nil
}
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
params := url.Values{}
params.Add("method", "track.getSimilar")
params.Add("track", name)
params.Add("artist", artist)
params.Add("limit", strconv.Itoa(limit))
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
if err != nil {
return nil, err
}
return &response.SimilarTracks, nil
}
func (c *client) GetToken(ctx context.Context) (string, error) {
params := url.Values{}
params.Add("method", "auth.getToken")

View File

@@ -121,30 +121,6 @@ var _ = Describe("client", func() {
})
})
Describe("trackGetSimilar", func() {
It("returns similar tracks for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
Expect(err).To(BeNil())
Expect(len(similar.Track)).To(Equal(5))
Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
Expect(similar.Track[0].Match).To(Equal(1.0))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
})
It("returns empty list when no similar tracks found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
Expect(err).To(BeNil())
Expect(similar.Track).To(BeEmpty())
})
})
Describe("GetToken", func() {
It("returns a token when the request is successful", func() {
httpClient.Res = http.Response{

View File

@@ -5,7 +5,6 @@ type Response struct {
SimilarArtists SimilarArtists `json:"similarartists"`
TopTracks TopTracks `json:"toptracks"`
Album Album `json:"album"`
SimilarTracks SimilarTracks `json:"similartracks"`
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
@@ -60,28 +59,6 @@ type TopTracks struct {
Attr Attr `json:"@attr"`
}
type SimilarTracks struct {
Track []SimilarTrack `json:"track"`
Attr SimilarAttr `json:"@attr"`
}
type SimilarTrack struct {
Name string `json:"name"`
MBID string `json:"mbid"`
Match float64 `json:"match"`
Artist SimilarTrackArtist `json:"artist"`
}
type SimilarTrackArtist struct {
Name string `json:"name"`
MBID string `json:"mbid"`
}
type SimilarAttr struct {
Artist string `json:"artist"`
Track string `json:"track"`
}
type Session struct {
Name string `json:"name"`
Key string `json:"key"`

View File

@@ -151,7 +151,11 @@ var _ = Describe("Extractor", func() {
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
// Why is the order inconsistent between runs? Nobody knows
Expect(lyrics).To(Or(
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {

View File

@@ -168,7 +168,7 @@ func parseTIPL(tags map[string][]string) {
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
// ignores fs, as taglib extractor only works with local files
return &extractor{baseDir}
})

View File

@@ -80,11 +80,12 @@ var _ = Describe("Extractor", func() {
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TagLib 1.12 returns 18, previous versions return 39.
// TabLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
@@ -105,7 +106,7 @@ var _ = Describe("Extractor", func() {
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(

View File

@@ -1,8 +1,7 @@
package taglib
/*
#cgo !windows pkg-config: --define-prefix taglib
#cgo windows pkg-config: taglib
#cgo pkg-config: taglib
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
#cgo linux darwin CXXFLAGS: -std=c++11
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib

View File

@@ -24,7 +24,6 @@ import (
// Import adapters to register them
_ "github.com/navidrome/navidrome/adapters/deezer"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify"

View File

@@ -33,7 +33,6 @@ import (
import (
_ "github.com/navidrome/navidrome/adapters/deezer"
_ "github.com/navidrome/navidrome/adapters/gotaglib"
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify"

View File

@@ -57,7 +57,6 @@ type configOptions struct {
AutoTranscodeDownload bool
DefaultDownsamplingFormat string
SearchFullString bool
SimilarSongsMatchThreshold int
RecentlyAddedByModTime bool
PreferSortTags bool
IgnoredArticles string
@@ -127,7 +126,6 @@ type configOptions struct {
DevExternalScanner bool
DevScannerThreads uint
DevSelectiveWatcher bool
DevLegacyEmbedImage bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
@@ -154,7 +152,6 @@ type subsonicOptions struct {
AppendSubtitle bool
ArtistParticipations bool
DefaultReportRealPath bool
EnableAverageRating bool
LegacyClients string
MinimalClients string
}
@@ -369,6 +366,10 @@ func Load(noConfigDump bool) {
disableExternalServices()
}
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor))
Server.Scanner.Extractor = consts.DefaultScannerExtractor
}
logDeprecatedOptions("Scanner.GenreSeparators", "")
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
@@ -556,7 +557,6 @@ func setViperDefaults() {
viper.SetDefault("autotranscodedownload", false)
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
viper.SetDefault("searchfullstring", false)
viper.SetDefault("similarsongsmatchthreshold", 85)
viper.SetDefault("recentlyaddedbymodtime", false)
viper.SetDefault("prefersorttags", false)
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
@@ -609,7 +609,6 @@ func setViperDefaults() {
viper.SetDefault("subsonic.appendsubtitle", true)
viper.SetDefault("subsonic.artistparticipations", false)
viper.SetDefault("subsonic.defaultreportrealpath", false)
viper.SetDefault("subsonic.enableaveragerating", true)
viper.SetDefault("subsonic.legacyclients", "DSub,SubMusic")
viper.SetDefault("agents", "lastfm,spotify,deezer")
viper.SetDefault("lastfm.enabled", true)

View File

@@ -22,8 +22,6 @@ type PluginLoader interface {
LoadMediaAgent(name string) (Interface, bool)
}
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
// until one returns valid data.
type Agents struct {
ds model.DataStore
pluginLoader PluginLoader
@@ -131,14 +129,26 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
case consts.VariousArtistsID:
return "", nil
}
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistMBIDRetriever)
if !ok {
return "", ErrNotFound
continue
}
return retriever.GetArtistMBID(ctx, id, name)
})
mbid, err := retriever.GetArtistMBID(ctx, id, name)
if mbid != "" && err == nil {
log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start))
return mbid, nil
}
}
return "", ErrNotFound
}
func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
@@ -148,14 +158,26 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
case consts.VariousArtistsID:
return "", nil
}
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistURLRetriever)
if !ok {
return "", ErrNotFound
continue
}
return retriever.GetArtistURL(ctx, id, name, mbid)
})
url, err := retriever.GetArtistURL(ctx, id, name, mbid)
if url != "" && err == nil {
log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start))
return url, nil
}
}
return "", ErrNotFound
}
func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
@@ -165,14 +187,26 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
case consts.VariousArtistsID:
return "", nil
}
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistBiographyRetriever)
if !ok {
return "", ErrNotFound
continue
}
return retriever.GetArtistBiography(ctx, id, name, mbid)
})
bio, err := retriever.GetArtistBiography(ctx, id, name, mbid)
if err == nil {
log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start))
return bio, nil
}
}
return "", ErrNotFound
}
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
@@ -220,14 +254,26 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
case consts.VariousArtistsID:
return nil, nil
}
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistImageRetriever)
if !ok {
return nil, ErrNotFound
continue
}
return retriever.GetArtistImages(ctx, id, name, mbid)
})
images, err := retriever.GetArtistImages(ctx, id, name, mbid)
if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start))
return images, nil
}
}
return nil, ErrNotFound
}
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
@@ -242,127 +288,80 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(ArtistTopSongsRetriever)
if !ok {
return nil, ErrNotFound
continue
}
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
})
songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
if len(songs) > 0 && err == nil {
log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start))
return songs, nil
}
}
return nil, ErrNotFound
}
func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) {
if name == consts.UnknownAlbum {
return nil, ErrNotFound
}
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(AlbumInfoRetriever)
if !ok {
return nil, ErrNotFound
continue
}
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
})
album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid)
if err == nil {
log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))
return album, nil
}
}
return nil, ErrNotFound
}
func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) {
if name == consts.UnknownAlbum {
return nil, ErrNotFound
}
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
start := time.Now()
for _, enabledAgent := range a.getEnabledAgentNames() {
ag := a.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
retriever, ok := ag.(AlbumImageRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetAlbumImages(ctx, name, artist, mbid)
})
}
// GetSimilarSongsByTrack returns similar songs for a given track.
func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByTrackRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
})
}
// GetSimilarSongsByAlbum returns similar songs for a given album.
func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
})
}
// GetSimilarSongsByArtist returns similar songs for a given artist.
func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error) {
switch id {
case consts.UnknownArtistID:
return nil, ErrNotFound
case consts.VariousArtistsID:
return nil, nil
}
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
retriever, ok := ag.(SimilarSongsByArtistRetriever)
if !ok {
return nil, ErrNotFound
}
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
})
}
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
var zero T
start := time.Now()
for _, enabledAgent := range agents.getEnabledAgentNames() {
ag := agents.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
result, err := fn(ag)
images, err := retriever.GetAlbumImages(ctx, name, artist, mbid)
if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
log.Trace(ctx, "Agent GetAlbumImages failed", "agent", ag.AgentName(), "album", name, "artist", artist, "mbid", mbid, err)
}
if result != zero {
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
return result, nil
}
}
return zero, ErrNotFound
}
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
start := time.Now()
for _, enabledAgent := range agents.getEnabledAgentNames() {
ag := agents.getAgent(enabledAgent)
if ag == nil {
continue
}
if utils.IsCtxDone(ctx) {
break
}
results, err := fn(ag)
if err != nil {
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
continue
}
if len(results) > 0 {
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
return results, nil
if len(images) > 0 && err == nil {
log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist,
"mbid", mbid, "elapsed", time.Since(start))
return images, nil
}
}
return nil, ErrNotFound
@@ -377,6 +376,3 @@ var _ ArtistImageRetriever = (*Agents)(nil)
var _ ArtistTopSongsRetriever = (*Agents)(nil)
var _ AlbumInfoRetriever = (*Agents)(nil)
var _ AlbumImageRetriever = (*Agents)(nil)
var _ SimilarSongsByTrackRetriever = (*Agents)(nil)
var _ SimilarSongsByAlbumRetriever = (*Agents)(nil)
var _ SimilarSongsByArtistRetriever = (*Agents)(nil)

View File

@@ -295,72 +295,6 @@ var _ = Describe("Agents", func() {
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByTrack", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Similar Song",
MBID: "mbid555",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test song", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByTrack(ctx, "123", "test song", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByAlbum", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Album Similar Song",
MBID: "mbid666",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test album", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByAlbum(ctx, "123", "test album", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
Describe("GetSimilarSongsByArtist", func() {
It("returns on first match", func() {
Expect(ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)).To(Equal([]Song{{
Name: "Artist Similar Song",
MBID: "mbid777",
}}))
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
})
It("skips the agent if it returns an error", func() {
mock.Err = errors.New("error")
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(HaveExactElements("123", "test artist", "mb123", 2))
})
It("interrupts if the context is canceled", func() {
cancel()
_, err := ag.GetSimilarSongsByArtist(ctx, "123", "test artist", "mb123", 2)
Expect(err).To(MatchError(ErrNotFound))
Expect(mock.Args).To(BeEmpty())
})
})
})
})
@@ -443,39 +377,6 @@ func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string)
}, nil
}
func (a *mockAgent) GetSimilarSongsByTrack(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Similar Song",
MBID: "mbid555",
}}, nil
}
func (a *mockAgent) GetSimilarSongsByAlbum(_ context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, name, artist, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Album Similar Song",
MBID: "mbid666",
}}, nil
}
func (a *mockAgent) GetSimilarSongsByArtist(_ context.Context, id, name, mbid string, count int) ([]Song, error) {
a.Args = []interface{}{id, name, mbid, count}
if a.Err != nil {
return nil, a.Err
}
return []Song{{
Name: "Artist Similar Song",
MBID: "mbid777",
}}, nil
}
type emptyAgent struct {
Interface
}

View File

@@ -33,14 +33,9 @@ type ExternalImage struct {
}
type Song struct {
ID string
Name string
MBID string
Artist string
ArtistMBID string
Album string
AlbumMBID string
Duration uint32 // Duration in milliseconds, 0 means unknown
ID string
Name string
MBID string
}
var (
@@ -81,41 +76,6 @@ type ArtistTopSongsRetriever interface {
GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error)
}
// SimilarSongsByTrackRetriever provides similar songs based on a specific track
type SimilarSongsByTrackRetriever interface {
// GetSimilarSongsByTrack returns songs similar to the given track.
// Parameters:
// - id: local mediafile ID
// - name: track title
// - artist: artist name
// - mbid: MusicBrainz recording ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
}
// SimilarSongsByAlbumRetriever provides similar songs based on an album
type SimilarSongsByAlbumRetriever interface {
// GetSimilarSongsByAlbum returns songs similar to tracks on the given album.
// Parameters:
// - id: local album ID
// - name: album name
// - artist: album artist name
// - mbid: MusicBrainz release ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error)
}
// SimilarSongsByArtistRetriever provides similar songs based on an artist
type SimilarSongsByArtistRetriever interface {
// GetSimilarSongsByArtist returns songs similar to the artist's catalog.
// Parameters:
// - id: local artist ID
// - name: artist name
// - mbid: MusicBrainz artist ID (may be empty)
// - count: maximum number of results
GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]Song, error)
}
var Map map[string]Constructor
func Register(name string, init Constructor) {

View File

@@ -16,14 +16,12 @@ import (
"time"
"github.com/dhowden/tag"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/resources"
"go.senan.xyz/taglib"
)
func selectImageReader(ctx context.Context, artID model.ArtworkID, extractFuncs ...sourceFunc) (io.ReadCloser, string, error) {
@@ -86,13 +84,6 @@ var picTypeRegexes = []*regexp.Regexp{
}
func fromTag(ctx context.Context, path string) sourceFunc {
if conf.Server.DevLegacyEmbedImage {
return fromTagLegacy(ctx, path)
}
return fromTagGoTaglib(ctx, path)
}
func fromTagLegacy(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
@@ -137,44 +128,6 @@ func fromTagLegacy(ctx context.Context, path string) sourceFunc {
}
}
func fromTagGoTaglib(ctx context.Context, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {
return nil, "", nil
}
f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast))
if err != nil {
return nil, "", err
}
defer f.Close()
images := f.Properties().Images
if len(images) == 0 {
return nil, "", fmt.Errorf("no embedded image found in %s", path)
}
imageIndex := findBestImageIndex(ctx, images, path)
data, err := f.Image(imageIndex)
if err != nil || len(data) == 0 {
return nil, "", fmt.Errorf("could not load embedded image from %s", path)
}
return io.NopCloser(bytes.NewReader(data)), path, nil
}
}
func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path string) int {
for _, regex := range picTypeRegexes {
for i, img := range images {
if regex.MatchString(img.Type) {
log.Trace(ctx, "Found embedded image", "type", img.Type, "path", path)
return i
}
}
}
log.Trace(ctx, "Could not find a front image. Getting the first one", "type", images[0].Type, "path", path)
return 0
}
func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc {
return func() (io.ReadCloser, string, error) {
if path == "" {

View File

@@ -282,27 +282,3 @@ func (m *mockAgents) GetAlbumImages(ctx context.Context, name, artist, mbid stri
}
return nil, args.Error(1)
}
func (m *mockAgents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
args := m.Called(ctx, id, name, artist, mbid, count)
if args.Get(0) != nil {
return args.Get(0).([]agents.Song), args.Error(1)
}
return nil, args.Error(1)
}
func (m *mockAgents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
args := m.Called(ctx, id, name, artist, mbid, count)
if args.Get(0) != nil {
return args.Get(0).([]agents.Song), args.Error(1)
}
return nil, args.Error(1)
}
func (m *mockAgents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
args := m.Called(ctx, id, name, mbid, count)
if args.Get(0) != nil {
return args.Get(0).([]agents.Song), args.Error(1)
}
return nil, args.Error(1)
}

View File

@@ -32,7 +32,7 @@ const (
type Provider interface {
UpdateAlbumInfo(ctx context.Context, id string) (*model.Album, error)
UpdateArtistInfo(ctx context.Context, id string, count int, includeNotPresent bool) (*model.Artist, error)
SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error)
ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error)
TopSongs(ctx context.Context, artist string, count int) (model.MediaFiles, error)
ArtistImage(ctx context.Context, id string) (*url.URL, error)
AlbumImage(ctx context.Context, id string) (*url.URL, error)
@@ -80,9 +80,6 @@ type Agents interface {
agents.ArtistSimilarRetriever
agents.ArtistTopSongsRetriever
agents.ArtistURLRetriever
agents.SimilarSongsByTrackRetriever
agents.SimilarSongsByAlbumRetriever
agents.SimilarSongsByArtistRetriever
}
func NewProvider(ds model.DataStore, agents Agents) Provider {
@@ -259,7 +256,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
g.Go(func() error { e.callGetImage(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetBiography(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetURL(ctx, e.ag, &artist); return nil })
g.Go(func() error { e.callGetSimilarArtists(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
g.Go(func() error { e.callGetSimilar(ctx, e.ag, &artist, maxSimilarArtists, true); return nil })
_ = g.Wait()
if utils.IsCtxDone(ctx) {
@@ -278,54 +275,22 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
return artist, nil
}
func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (model.MediaFiles, error) {
entity, err := model.GetEntityByID(ctx, e.ds, id)
if err != nil {
return nil, err
}
var songs []agents.Song
// Try entity-specific similarity first
switch v := entity.(type) {
case *model.MediaFile:
songs, err = e.ag.GetSimilarSongsByTrack(ctx, v.ID, v.Title, v.Artist, v.MbzRecordingID, count)
case *model.Album:
songs, err = e.ag.GetSimilarSongsByAlbum(ctx, v.ID, v.Name, v.AlbumArtist, v.MbzAlbumID, count)
case *model.Artist:
songs, err = e.ag.GetSimilarSongsByArtist(ctx, v.ID, v.Name, v.MbzArtistID, count)
default:
log.Warn(ctx, "Unknown entity type", "id", id, "type", fmt.Sprintf("%T", entity))
return nil, model.ErrNotFound
}
if err == nil && len(songs) > 0 {
return e.matchSongsToLibrary(ctx, songs, count)
}
// Fallback to existing similar artists + top songs algorithm
return e.similarSongsFallback(ctx, id, count)
}
// similarSongsFallback uses the original similar artists + top songs algorithm. The idea is to
// get the artist of the given entity, retrieve similar artists, get their top songs, and pick
// a weighted random selection of songs to return as similar songs.
func (e *provider) similarSongsFallback(ctx context.Context, id string, count int) (model.MediaFiles, error) {
func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model.MediaFiles, error) {
artist, err := e.getArtist(ctx, id)
if err != nil {
return nil, err
}
e.callGetSimilarArtists(ctx, e.ag, &artist, 15, false)
e.callGetSimilar(ctx, e.ag, &artist, 15, false)
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
return nil, ctx.Err()
}
weightedSongs := random.NewWeightedChooser[model.MediaFile]()
addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error {
if utils.IsCtxDone(ctx) {
log.Warn(ctx, "SimilarSongs call canceled", ctx.Err())
log.Warn(ctx, "ArtistRadio call canceled", ctx.Err())
return ctx.Err()
}
@@ -457,20 +422,21 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
}
// Enrich songs with artist info if not already present (for top songs, we know the artist)
for i := range songs {
if songs[i].Artist == "" {
songs[i].Artist = artistName
}
if songs[i].ArtistMBID == "" {
songs[i].ArtistMBID = artist.MbzArtistID
}
idMatches, err := e.loadTracksByID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
mfs, err := e.matchSongsToLibrary(ctx, songs, count)
if err != nil {
return nil, err
}
log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numIDMatches", len(idMatches), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
mfs := e.selectTopSongs(songs, idMatches, mbidMatches, titleMatches, count)
if len(mfs) == 0 {
log.Debug(ctx, "No matching top songs found", "name", artistName)
@@ -481,6 +447,137 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return mfs, nil
}
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var mbids []string
for _, s := range songs {
if s.MBID != "" {
mbids = append(mbids, s.MBID)
}
}
matches := map[string]model.MediaFile{}
if len(mbids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"mbz_recording_id": mbids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if id := mf.MbzRecordingID; id != "" {
if _, ok := matches[id]; !ok {
matches[id] = mf
}
}
}
return matches, nil
}
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var ids []string
for _, s := range songs {
if s.ID != "" {
ids = append(ids, s.ID)
}
}
matches := map[string]model.MediaFile{}
if len(ids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.id": ids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if _, ok := matches[mf.ID]; !ok {
matches[mf.ID] = mf
}
}
return matches, nil
}
func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
titleMap := map[string]string{}
for _, s := range songs {
// Skip if already matched by ID or MBID
if s.ID != "" && idMatches[s.ID].ID != "" {
continue
}
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
continue
}
sanitized := str.SanitizeFieldForSorting(s.Name)
titleMap[sanitized] = s.Name
}
matches := map[string]model.MediaFile{}
if len(titleMap) == 0 {
return matches, nil
}
titleFilters := squirrel.Or{}
for sanitized := range titleMap {
titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized})
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Or{
squirrel.Eq{"artist_id": artist.ID},
squirrel.Eq{"album_artist_id": artist.ID},
},
titleFilters,
squirrel.Eq{"missing": false},
},
Sort: "starred desc, rating desc, year asc, compilation asc ",
})
if err != nil {
return matches, err
}
for _, mf := range res {
sanitized := str.SanitizeFieldForSorting(mf.Title)
if _, ok := matches[sanitized]; !ok {
matches[sanitized] = mf
}
}
return matches, nil
}
func (e *provider) selectTopSongs(songs []agents.Song, byID, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles {
var mfs model.MediaFiles
for _, t := range songs {
if len(mfs) == count {
break
}
// Try ID match first
if t.ID != "" {
if mf, ok := byID[t.ID]; ok {
mfs = append(mfs, mf)
continue
}
}
// Try MBID match second
if t.MBID != "" {
if mf, ok := byMBID[t.MBID]; ok {
mfs = append(mfs, mf)
continue
}
}
// Fall back to title match
if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok {
mfs = append(mfs, mf)
}
}
return mfs
}
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
@@ -517,7 +614,7 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
}
}
func (e *provider) callGetSimilarArtists(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
artistName := artist.Name()
similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)

View File

@@ -0,0 +1,205 @@
package external_test
import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - ArtistRadio", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockSimilarArtistAgent
var mockTopAgent agents.ArtistTopSongsRetriever
var mockSimilarAgent agents.ArtistSimilarRetriever
var agentsCombined Agents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
}
mockAgent = &mockSimilarArtistAgent{}
mockTopAgent = mockAgent
mockSimilarAgent = mockAgent
agentsCombined = &mockAgents{
topSongsAgent: mockTopAgent,
similarAgent: mockSimilarAgent,
}
provider = NewProvider(ds, agentsCombined)
})
It("returns similar songs from main artist and similar artists", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Once()
similarAgentsResp := []agents.Artist{
{Name: "Similar Artist", MBID: "similar-mbid"},
}
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(similarAgentsResp, nil).Once()
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
// MBID lookup returns empty (no match)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return opt.Max == 0 && ok
})).Return(model.Artists{}, nil).Once()
// Name lookup returns the similar artist
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Or)
return opt.Max == 0 && ok
})).Return(model.Artists{similarArtist}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
Return([]agents.Song{
{Name: "Song Three", MBID: "mbid-3"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
for _, song := range songs {
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
}
})
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
})
It("returns songs from main artist when GetSimilarArtists returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(nil, errors.New("error getting similar artists")).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
It("returns empty list when GetArtistTopSongs returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
It("respects count parameter", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := provider.ArtistRadio(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
})
})

View File

@@ -1,425 +0,0 @@
package external
import (
"context"
"fmt"
"math"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/str"
"github.com/xrash/smetrics"
)
// durationToleranceSec is the maximum allowed difference in seconds when
// matching tracks by duration. A tolerance of 3 seconds accounts for minor
// encoding differences between sources.
const durationToleranceSec = 3
// matchSongsToLibrary matches agent song results to local library tracks using a multi-phase
// matching algorithm that prioritizes accuracy over recall.
//
// # Algorithm Overview
//
// The algorithm matches songs from external agents (Last.fm, Deezer, etc.) to tracks in the
// local music library using three matching strategies in priority order:
//
// 1. Direct ID match: Songs with an ID field are matched directly to MediaFiles by ID
// 2. MusicBrainz Recording ID (MBID) match: Songs with MBID are matched to tracks with
// matching mbz_recording_id
// 3. Title+Artist fuzzy match: Remaining songs are matched using fuzzy string comparison
// with metadata specificity scoring
//
// # Matching Priority
//
// When selecting the final result, matches are prioritized in order: ID > MBID > Title+Artist.
// This ensures that more reliable identifiers take precedence over fuzzy text matching.
//
// # Fuzzy Matching Details
//
// For title+artist matching, the algorithm uses Jaro-Winkler similarity (threshold configurable
// via SimilarSongsMatchThreshold, default 85%). Matches are ranked by:
//
// 1. Title similarity (Jaro-Winkler score, 0.0-1.0)
// 2. Specificity level (0-5, based on metadata precision):
// - Level 5: Title + Artist MBID + Album MBID (most specific)
// - Level 4: Title + Artist MBID + Album name (fuzzy)
// - Level 3: Title + Artist name + Album name (fuzzy)
// - Level 2: Title + Artist MBID
// - Level 1: Title + Artist name
// - Level 0: Title only
// 3. Album similarity (Jaro-Winkler, as final tiebreaker)
//
// # Examples
//
// Example 1 - MBID Priority:
//
// Agent returns: {Name: "Paranoid Android", MBID: "abc-123", Artist: "Radiohead"}
// Library has: [
// {ID: "t1", Title: "Paranoid Android", MbzRecordingID: "abc-123"},
// {ID: "t2", Title: "Paranoid Android", Artist: "Radiohead"},
// ]
// Result: t1 (MBID match takes priority over title+artist)
//
// Example 2 - Specificity Ranking:
//
// Agent returns: {Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}
// Library has: [
// {ID: "t1", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101"}, // Level 1
// {ID: "t2", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"}, // Level 3
// ]
// Result: t2 (Level 3 beats Level 1 due to album match)
//
// Example 3 - Fuzzy Title Matching:
//
// Agent returns: {Name: "Bohemian Rhapsody", Artist: "Queen"}
// Library has: {ID: "t1", Title: "Bohemian Rhapsody - Remastered", Artist: "Queen"}
// With threshold=85%: Match succeeds (similarity ~0.87)
// With threshold=100%: No match (not exact)
//
// # Parameters
//
// - ctx: Context for database operations
// - songs: Slice of agent.Song results from external providers
// - count: Maximum number of matches to return
//
// # Returns
//
// Returns up to 'count' MediaFiles from the library that best match the input songs,
// preserving the original order from the agent. Songs that cannot be matched are skipped.
func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) {
idMatches, err := e.loadTracksByID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by ID: %w", err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by MBID: %w", err)
}
titleMatches, err := e.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches)
if err != nil {
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
return e.selectBestMatchingSongs(songs, idMatches, mbidMatches, titleMatches, count), nil
}
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
// It extracts all non-empty ID fields from the input songs and performs a single
// batch query to the database. Returns a map keyed by MediaFile ID for O(1) lookup.
// Only non-missing files are returned.
func (e *provider) loadTracksByID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var ids []string
for _, s := range songs {
if s.ID != "" {
ids = append(ids, s.ID)
}
}
matches := map[string]model.MediaFile{}
if len(ids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"media_file.id": ids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if _, ok := matches[mf.ID]; !ok {
matches[mf.ID] = mf
}
}
return matches, nil
}
// loadTracksByMBID fetches MediaFiles from the library using MusicBrainz Recording IDs.
// It extracts all non-empty MBID fields from the input songs and performs a single
// batch query against the mbz_recording_id column. Returns a map keyed by MBID for
// O(1) lookup. Only non-missing files are returned.
func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) {
var mbids []string
for _, s := range songs {
if s.MBID != "" {
mbids = append(mbids, s.MBID)
}
}
matches := map[string]model.MediaFile{}
if len(mbids) == 0 {
return matches, nil
}
res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"mbz_recording_id": mbids},
squirrel.Eq{"missing": false},
},
})
if err != nil {
return matches, err
}
for _, mf := range res {
if id := mf.MbzRecordingID; id != "" {
if _, ok := matches[id]; !ok {
matches[id] = mf
}
}
}
return matches, nil
}
// songQuery represents a normalized query for matching a song to library tracks.
// All string fields are sanitized (lowercased, diacritics removed) for comparison.
// This struct is used internally by loadTracksByTitleAndArtist to group queries by artist.
type songQuery struct {
title string // Sanitized song title
artist string // Sanitized artist name (without articles like "The")
artistMBID string // MusicBrainz Artist ID (optional, for higher specificity matching)
album string // Sanitized album name (optional, for specificity scoring)
albumMBID string // MusicBrainz Album ID (optional, for highest specificity matching)
durationMs uint32 // Duration in milliseconds (0 means unknown, skip duration filtering)
}
// matchScore combines title/album similarity with metadata specificity for ranking matches
type matchScore struct {
titleSimilarity float64 // 0.0-1.0 (Jaro-Winkler)
albumSimilarity float64 // 0.0-1.0 (Jaro-Winkler), used as tiebreaker
specificityLevel int // 0-5 (higher = more specific metadata match)
}
// betterThan returns true if this score beats another.
// Comparison order: title similarity > specificity level > album similarity
func (s matchScore) betterThan(other matchScore) bool {
if s.titleSimilarity != other.titleSimilarity {
return s.titleSimilarity > other.titleSimilarity
}
if s.specificityLevel != other.specificityLevel {
return s.specificityLevel > other.specificityLevel
}
return s.albumSimilarity > other.albumSimilarity
}
// computeSpecificityLevel determines how well query metadata matches a track (0-5).
// Higher values indicate more specific matches (MBIDs > names > title only).
// Uses fuzzy matching for album names with the same threshold as title matching.
func computeSpecificityLevel(q songQuery, mf model.MediaFile, albumThreshold float64) int {
title := str.SanitizeFieldForSorting(mf.Title)
artist := str.SanitizeFieldForSortingNoArticle(mf.Artist)
album := str.SanitizeFieldForSorting(mf.Album)
// Level 5: Title + Artist MBID + Album MBID (most specific)
if q.artistMBID != "" && q.albumMBID != "" &&
mf.MbzArtistID == q.artistMBID && mf.MbzAlbumID == q.albumMBID {
return 5
}
// Level 4: Title + Artist MBID + Album name (fuzzy)
if q.artistMBID != "" && q.album != "" &&
mf.MbzArtistID == q.artistMBID && similarityRatio(album, q.album) >= albumThreshold {
return 4
}
// Level 3: Title + Artist name + Album name (fuzzy)
if q.artist != "" && q.album != "" &&
artist == q.artist && similarityRatio(album, q.album) >= albumThreshold {
return 3
}
// Level 2: Title + Artist MBID
if q.artistMBID != "" && mf.MbzArtistID == q.artistMBID {
return 2
}
// Level 1: Title + Artist name
if q.artist != "" && artist == q.artist {
return 1
}
// Level 0: Title only match (but for fuzzy, title matched via similarity)
// Check if at least the title matches exactly
if title == q.title {
return 0
}
return -1 // No exact title match, but could still be a fuzzy match
}
// loadTracksByTitleAndArtist loads tracks matching by title with optional artist/album filtering.
// Uses a unified scoring approach that combines title similarity (Jaro-Winkler) with
// metadata specificity (MBIDs, album names) for both exact and fuzzy matches.
// Returns a map keyed by "title|artist" for compatibility with selectBestMatchingSongs.
func (e *provider) loadTracksByTitleAndArtist(ctx context.Context, songs []agents.Song, idMatches, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) {
queries := e.buildTitleQueries(songs, idMatches, mbidMatches)
if len(queries) == 0 {
return map[string]model.MediaFile{}, nil
}
threshold := float64(conf.Server.SimilarSongsMatchThreshold) / 100.0
// Group queries by artist for efficient DB access
byArtist := map[string][]songQuery{}
for _, q := range queries {
if q.artist != "" {
byArtist[q.artist] = append(byArtist[q.artist], q)
}
}
matches := map[string]model.MediaFile{}
for artist, artistQueries := range byArtist {
// Single DB query per artist - get all their tracks
tracks, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{
Filters: squirrel.And{
squirrel.Eq{"order_artist_name": artist},
squirrel.Eq{"missing": false},
},
Sort: "starred desc, rating desc, year asc, compilation asc",
})
if err != nil {
continue
}
// Find best match for each query using unified scoring
for _, q := range artistQueries {
if mf, found := e.findBestMatch(q, tracks, threshold); found {
key := q.title + "|" + q.artist
if _, exists := matches[key]; !exists {
matches[key] = mf
}
}
}
}
return matches, nil
}
// durationMatches checks if a track's duration is within tolerance of the target duration.
// Returns true if durationMs is 0 (unknown) or if the difference is within durationToleranceSec.
func durationMatches(durationMs uint32, mediaFileDurationSec float32) bool {
if durationMs <= 0 {
return true // Unknown duration matches anything
}
durationSec := float64(durationMs) / 1000.0
diff := math.Abs(durationSec - float64(mediaFileDurationSec))
return diff <= durationToleranceSec
}
// findBestMatch finds the best matching track using combined title/album similarity and specificity scoring.
// When duration is known (durationMs > 0), it acts as a top-priority filter:
// - First, only tracks with matching duration (±3 seconds) are considered
// - If no duration matches exist, falls back to matching all tracks
// A track must meet the threshold for title similarity, then the best match is chosen by:
// 1. Highest title similarity
// 2. Highest specificity level
// 3. Highest album similarity (as final tiebreaker)
func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold float64) (model.MediaFile, bool) {
// If duration is known, try to find matches among duration-filtered tracks first
if q.durationMs > 0 {
var durationFiltered model.MediaFiles
for _, mf := range tracks {
if durationMatches(q.durationMs, mf.Duration) {
durationFiltered = append(durationFiltered, mf)
}
}
// If we have duration-filtered candidates, use only those
if len(durationFiltered) > 0 {
tracks = durationFiltered
}
// Otherwise fall back to all tracks (duration filter didn't match anything)
}
var bestMatch model.MediaFile
bestScore := matchScore{titleSimilarity: -1}
found := false
for _, mf := range tracks {
trackTitle := str.SanitizeFieldForSorting(mf.Title)
titleSim := similarityRatio(q.title, trackTitle)
if titleSim < threshold {
continue
}
// Compute album similarity for tiebreaking (0.0 if no album in query)
var albumSim float64
if q.album != "" {
trackAlbum := str.SanitizeFieldForSorting(mf.Album)
albumSim = similarityRatio(q.album, trackAlbum)
}
score := matchScore{
titleSimilarity: titleSim,
albumSimilarity: albumSim,
specificityLevel: computeSpecificityLevel(q, mf, threshold),
}
if score.betterThan(bestScore) {
bestScore = score
bestMatch = mf
found = true
}
}
return bestMatch, found
}
func (e *provider) buildTitleQueries(songs []agents.Song, idMatches, mbidMatches map[string]model.MediaFile) []songQuery {
var queries []songQuery
for _, s := range songs {
// Skip if already matched by ID or MBID
if s.ID != "" && idMatches[s.ID].ID != "" {
continue
}
if s.MBID != "" && mbidMatches[s.MBID].ID != "" {
continue
}
queries = append(queries, songQuery{
title: str.SanitizeFieldForSorting(s.Name),
artist: str.SanitizeFieldForSortingNoArticle(s.Artist),
artistMBID: s.ArtistMBID,
album: str.SanitizeFieldForSorting(s.Album),
albumMBID: s.AlbumMBID,
durationMs: s.Duration,
})
}
return queries
}
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
var mfs model.MediaFiles
for _, t := range songs {
if len(mfs) == count {
break
}
// Try ID match first
if t.ID != "" {
if mf, ok := byID[t.ID]; ok {
mfs = append(mfs, mf)
continue
}
}
// Try MBID match second
if t.MBID != "" {
if mf, ok := byMBID[t.MBID]; ok {
mfs = append(mfs, mf)
continue
}
}
// Fall back to title+artist match (composite key preserves duplicate titles)
key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
if mf, ok := byTitleArtist[key]; ok {
mfs = append(mfs, mf)
}
}
return mfs
}
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
// Returns a value between 0.0 (completely different) and 1.0 (identical).
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
// when strings share a common prefix (e.g., "Song Title" vs "Song Title - Remastered").
func similarityRatio(a, b string) float64 {
if a == b {
return 1.0
}
if len(a) == 0 || len(b) == 0 {
return 0.0
}
// JaroWinkler params: boostThreshold=0.7, prefixSize=4
return smetrics.JaroWinkler(a, b, 0.7, 4)
}

View File

@@ -1,57 +0,0 @@
package external
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("similarityRatio", func() {
It("returns 1.0 for identical strings", func() {
Expect(similarityRatio("hello", "hello")).To(BeNumerically("==", 1.0))
})
It("returns 0.0 for empty strings", func() {
Expect(similarityRatio("", "test")).To(BeNumerically("==", 0.0))
Expect(similarityRatio("test", "")).To(BeNumerically("==", 0.0))
})
It("returns high similarity for remastered suffix", func() {
// Jaro-Winkler gives ~0.92 for this case
ratio := similarityRatio("paranoid android", "paranoid android remastered")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns high similarity for suffix additions like (Live)", func() {
// Jaro-Winkler gives ~0.96 for this case
ratio := similarityRatio("bohemian rhapsody", "bohemian rhapsody live")
Expect(ratio).To(BeNumerically(">=", 0.90))
})
It("returns high similarity for 'yesterday' variants (common prefix)", func() {
// Jaro-Winkler gives ~0.90 because of common prefix
ratio := similarityRatio("yesterday", "yesterday once more")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns low similarity for same suffix", func() {
// Jaro-Winkler gives ~0.70 for this case
ratio := similarityRatio("postman (live)", "taxman (live)")
Expect(ratio).To(BeNumerically("<", 0.85))
})
It("handles unicode characters", func() {
ratio := similarityRatio("dont stop believin", "don't stop believin'")
Expect(ratio).To(BeNumerically(">=", 0.85))
})
It("returns low similarity for completely different strings", func() {
ratio := similarityRatio("abc", "xyz")
Expect(ratio).To(BeNumerically("<", 0.5))
})
It("is symmetric", func() {
ratio1 := similarityRatio("hello world", "hello")
ratio2 := similarityRatio("hello", "hello world")
Expect(ratio1).To(Equal(ratio2))
})
})

View File

@@ -1,602 +0,0 @@
package external_test
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - Song Matching", func() {
var ds model.DataStore
var provider Provider
var agentsCombined *mockAgents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var albumRepo *mockAlbumRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
albumRepo = newMockAlbumRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
MockedAlbum: albumRepo,
}
agentsCombined = &mockAgents{}
provider = NewProvider(ds, agentsCombined)
})
// Shared helper for tests that only need artist track queries (no ID/MBID matching)
setupSimilarSongsExpectations := func(returnedSongs []agents.Song, artistTracks model.MediaFiles) {
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(returnedSongs, nil).Once()
// loadTracksByTitleAndArtist - queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(artistTracks, nil).Maybe()
}
Describe("matchSongsToLibrary priority matching", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
conf.Server.SimilarSongsMatchThreshold = 100
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist", MbzRecordingID: ""}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
setupExpectations := func(returnedSongs []agents.Song, idMatches, mbidMatches, artistTracks model.MediaFiles) {
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(returnedSongs, nil).Once()
// loadTracksByID
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(idMatches, nil).Once()
// loadTracksByMBID
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasMBID := eq["mbz_recording_id"]
return hasMBID
})).Return(mbidMatches, nil).Once()
// loadTracksByTitleAndArtist - now queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(artistTracks, nil).Maybe()
}
Context("when agent returns artist and album metadata", func() {
It("matches by title + artist MBID + album MBID (highest priority)", func() {
// Song in library with all MBIDs
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Violator",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "album-mbid-456",
}
// Another song with same title but different MBIDs (should NOT match)
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Depeche Mode", Album: "Some Other Album",
MbzArtistID: "artist-mbid-123", MbzAlbumID: "different-album-mbid",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", ArtistMBID: "artist-mbid-123", Album: "Violator", AlbumMBID: "album-mbid-456"},
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist name + album name when MBIDs unavailable", func() {
// Song in library without MBIDs but with matching artist/album names
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "violator",
}
// Another song with same title but different artist (should NOT match)
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode", Album: "Violator"}, // No MBIDs
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("matches by title + artist only when album info unavailable", func() {
// Song in library with matching artist
correctMatch := model.MediaFile{
ID: "correct-match", Title: "Similar Song", Artist: "depeche mode", Album: "Some Album",
}
// Another song with same title but different artist
wrongMatch := model.MediaFile{
ID: "wrong-match", Title: "Similar Song", Artist: "Other Artist", Album: "Other Album",
}
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Depeche Mode"}, // No album info
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct-match"))
})
It("does not match songs without artist info", func() {
// Songs without artist info cannot be matched since we query by artist
returnedSongs := []agents.Song{
{Name: "Similar Song"}, // No artist/album info at all
}
// No artist to query, so no GetAll calls for title matching
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("when matching multiple songs with the same title but different artists", func() {
It("returns distinct matches for each artist's version (covers scenario)", func() {
// Multiple covers of the same song by different artists
cover1 := model.MediaFile{
ID: "cover-1", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
}
cover2 := model.MediaFile{
ID: "cover-2", Title: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits",
}
cover3 := model.MediaFile{
ID: "cover-3", Title: "Yesterday", Artist: "Frank Sinatra", Album: "My Way",
}
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
{Name: "Yesterday", Artist: "Ray Charles", Album: "Greatest Hits"},
{Name: "Yesterday", Artist: "Frank Sinatra", Album: "My Way"},
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{cover1, cover2, cover3})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// All three covers should be returned, not just the first one
Expect(songs).To(HaveLen(3))
// Verify all three different versions are included
ids := []string{songs[0].ID, songs[1].ID, songs[2].ID}
Expect(ids).To(ContainElements("cover-1", "cover-2", "cover-3"))
})
})
Context("when matching multiple songs with different precision levels", func() {
It("prefers more precise matches for each song", func() {
// Library has multiple versions of same song
preciseMatch := model.MediaFile{
ID: "precise", Title: "Song A", Artist: "Artist One", Album: "Album One",
MbzArtistID: "mbid-1", MbzAlbumID: "album-mbid-1",
}
lessAccurateMatch := model.MediaFile{
ID: "less-accurate", Title: "Song A", Artist: "Artist One", Album: "Compilation",
MbzArtistID: "mbid-1",
}
artistTwoMatch := model.MediaFile{
ID: "artist-two", Title: "Song B", Artist: "Artist Two",
}
returnedSongs := []agents.Song{
{Name: "Song A", Artist: "Artist One", ArtistMBID: "mbid-1", Album: "Album One", AlbumMBID: "album-mbid-1"},
{Name: "Song B", Artist: "Artist Two"}, // Different artist
}
setupExpectations(returnedSongs, model.MediaFiles{}, model.MediaFiles{}, model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(2))
// First song should be the precise match (has all MBIDs)
Expect(songs[0].ID).To(Equal("precise"))
// Second song matches by title + artist
Expect(songs[1].ID).To(Equal("artist-two"))
})
})
})
Describe("Fuzzy matching fallback", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
Context("with default threshold (85%)", func() {
It("matches songs with remastered suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
// Agent returns "Paranoid Android" but library has "Paranoid Android - Remastered"
returnedSongs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
// Artist catalog has the remastered version (fuzzy match will find it)
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("remastered"))
})
It("matches songs with live suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen"},
}
artistTracks := model.MediaFiles{
{ID: "live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("live"))
})
It("does not match completely different songs", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Yesterday", Artist: "The Beatles"},
}
// Artist catalog has completely different songs
artistTracks := model.MediaFiles{
{ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"},
{ID: "different2", Title: "Here Comes The Sun", Artist: "The Beatles"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("with threshold set to 100 (exact match only)", func() {
It("only matches exact titles", func() {
conf.Server.SimilarSongsMatchThreshold = 100
returnedSongs := []agents.Song{
{Name: "Paranoid Android", Artist: "Radiohead"},
}
// Artist catalog has only remastered version - no exact match
artistTracks := model.MediaFiles{
{ID: "remastered", Title: "Paranoid Android - Remastered", Artist: "Radiohead"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
})
Context("with lower threshold (75%)", func() {
It("matches more aggressively", func() {
conf.Server.SimilarSongsMatchThreshold = 75
returnedSongs := []agents.Song{
{Name: "Song", Artist: "Artist"},
}
artistTracks := model.MediaFiles{
{ID: "extended", Title: "Song (Extended Mix)", Artist: "Artist"},
}
setupSimilarSongsExpectations(returnedSongs, artistTracks)
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("extended"))
})
})
Context("with fuzzy album matching", func() {
It("matches album with (Remaster) suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
// Agent returns "A Night at the Opera" but library has remastered version
returnedSongs := []agents.Song{
{Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
}
// Library has same album with remaster suffix
correctMatch := model.MediaFile{
ID: "correct", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera (2011 Remaster)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "Greatest Hits",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
// Should prefer the fuzzy album match (Level 3) over title+artist only (Level 1)
Expect(songs[0].ID).To(Equal("correct"))
})
It("matches album with (Deluxe Edition) suffix", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
correctMatch := model.MediaFile{
ID: "correct", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
wrongMatch := model.MediaFile{
ID: "wrong", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "101",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongMatch, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct"))
})
It("prefers exact album match over fuzzy album match", func() {
conf.Server.SimilarSongsMatchThreshold = 85
returnedSongs := []agents.Song{
{Name: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator"},
}
exactMatch := model.MediaFile{
ID: "exact", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator",
}
fuzzyMatch := model.MediaFile{
ID: "fuzzy", Title: "Enjoy the Silence", Artist: "Depeche Mode", Album: "Violator (Deluxe Edition)",
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{fuzzyMatch, exactMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
// Both have same title similarity (1.0), so should prefer exact album match (higher specificity via higher album similarity)
Expect(songs[0].ID).To(Equal("exact"))
})
})
})
Describe("Duration filtering", func() {
var track model.MediaFile
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
conf.Server.SimilarSongsMatchThreshold = 100 // Exact title match for predictable tests
track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
// Setup for GetEntityByID to return the track
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
})
Context("when agent provides duration", func() {
It("prefers tracks with matching duration", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has two versions: one matching duration, one not
correctMatch := model.MediaFile{
ID: "correct", Title: "Similar Song", Artist: "Test Artist", Duration: 180.0,
}
wrongDuration := model.MediaFile{
ID: "wrong", Title: "Similar Song", Artist: "Test Artist", Duration: 240.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{wrongDuration, correctMatch})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("correct"))
})
It("matches within 3-second tolerance", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has track with 182 seconds (within tolerance)
withinTolerance := model.MediaFile{
ID: "within-tolerance", Title: "Similar Song", Artist: "Test Artist", Duration: 182.5,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{withinTolerance})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("within-tolerance"))
})
It("excludes tracks outside 3-second tolerance when other matches exist", func() {
// Agent returns song with duration 180000ms (180 seconds)
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library has one within tolerance, one outside
withinTolerance := model.MediaFile{
ID: "within", Title: "Similar Song", Artist: "Test Artist", Duration: 181.0,
}
outsideTolerance := model.MediaFile{
ID: "outside", Title: "Similar Song", Artist: "Test Artist", Duration: 190.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{outsideTolerance, withinTolerance})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("within"))
})
It("falls back to normal matching when no duration matches", func() {
// Agent returns song with duration 180000ms
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 180000},
}
// Library only has tracks with very different duration
differentDuration := model.MediaFile{
ID: "different", Title: "Similar Song", Artist: "Test Artist", Duration: 300.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{differentDuration})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
// Should fall back and return the track despite duration mismatch
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("different"))
})
})
Context("when agent does not provide duration", func() {
It("matches without duration filtering (duration=0)", func() {
// Agent returns song without duration
returnedSongs := []agents.Song{
{Name: "Similar Song", Artist: "Test Artist", Duration: 0},
}
// Library tracks with various durations should all be candidates
anyTrack := model.MediaFile{
ID: "any", Title: "Similar Song", Artist: "Test Artist", Duration: 999.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{anyTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("any"))
})
})
Context("edge cases", func() {
It("handles very short songs with duration tolerance", func() {
// 30-second song with 1-second difference (within 3-second tolerance)
returnedSongs := []agents.Song{
{Name: "Short Song", Artist: "Test Artist", Duration: 30000},
}
shortTrack := model.MediaFile{
ID: "short", Title: "Short Song", Artist: "Test Artist", Duration: 31.0,
}
setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{shortTrack})
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("short"))
})
})
})
})

View File

@@ -1,443 +0,0 @@
package external_test
import (
"context"
"errors"
"github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/stretchr/testify/mock"
)
var _ = Describe("Provider - SimilarSongs", func() {
var ds model.DataStore
var provider Provider
var mockAgent *mockSimilarArtistAgent
var mockTopAgent agents.ArtistTopSongsRetriever
var mockSimilarAgent agents.ArtistSimilarRetriever
var agentsCombined *mockAgents
var artistRepo *mockArtistRepo
var mediaFileRepo *mockMediaFileRepo
var albumRepo *mockAlbumRepo
var ctx context.Context
BeforeEach(func() {
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo()
mediaFileRepo = newMockMediaFileRepo()
albumRepo = newMockAlbumRepo()
ds = &tests.MockDataStore{
MockedArtist: artistRepo,
MockedMediaFile: mediaFileRepo,
MockedAlbum: albumRepo,
}
mockAgent = &mockSimilarArtistAgent{}
mockTopAgent = mockAgent
mockSimilarAgent = mockAgent
agentsCombined = &mockAgents{
topSongsAgent: mockTopAgent,
similarAgent: mockSimilarAgent,
}
provider = NewProvider(ds, agentsCombined)
})
Describe("dispatch by entity type", func() {
Context("when ID is a MediaFile (track)", func() {
It("calls GetSimilarSongsByTrack and returns matched songs", func() {
track := model.MediaFile{ID: "track-1", Title: "Just Can't Get Enough", Artist: "Depeche Mode", MbzRecordingID: "track-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "Dreaming of Me", Artist: "Depeche Mode"}
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Just Can't Get Enough", "Depeche Mode", "track-mbid", 5).
Return([]agents.Song{
{Name: "Dreaming of Me", MBID: "", Artist: "Depeche Mode", ArtistMBID: "artist-mbid"},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - no MBID matches (empty MBID means this won't be called)
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasMBID := eq["mbz_recording_id"]
return hasMBID
})).Return(model.MediaFiles{}, nil).Maybe()
// Mock loadTracksByTitleAndArtist - queries by artist name
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 2 {
return false
}
eq, hasEq := and[0].(squirrel.Eq)
if !hasEq {
return false
}
_, hasArtist := eq["order_artist_name"]
return hasArtist
})).Return(model.MediaFiles{matchedSong}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
It("falls back to artist-based algorithm when GetSimilarSongsByTrack returns empty", func() {
track := model.MediaFile{ID: "track-1", Title: "Track", Artist: "Artist", ArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
agentsCombined.On("GetSimilarSongsByTrack", mock.Anything, "track-1", "Track", "Artist", "", mock.Anything).
Return([]agents.Song{}, nil).Once()
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the mediafile
// and recursively calls getArtist(v.ArtistID)
artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
// Then it recurses with the artist-1 ID
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "track-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
})
Context("when ID is an Album", func() {
It("calls GetSimilarSongsByAlbum and returns matched songs", func() {
album := model.Album{ID: "album-1", Name: "Speak & Spell", AlbumArtist: "Depeche Mode", MbzAlbumID: "album-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "New Life", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
// GetEntityByID tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Speak & Spell", "Depeche Mode", "album-mbid", 5).
Return([]agents.Song{
{Name: "New Life", MBID: "song-mbid", Artist: "Depeche Mode"},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - MBID match
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
_, hasEq := and[0].(squirrel.Eq)
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
It("falls back when GetSimilarSongsByAlbum returns ErrNotFound", func() {
album := model.Album{ID: "album-1", Name: "Album", AlbumArtist: "Artist", AlbumArtistID: "artist-1"}
artist := model.Artist{ID: "artist-1", Name: "Artist"}
song := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
// GetEntityByID for the initial call tries Artist, Album, Playlist, then MediaFile
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
agentsCombined.On("GetSimilarSongsByAlbum", mock.Anything, "album-1", "Album", "Artist", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
// Fallback calls getArtist(id) which calls GetEntityByID again - this time it finds the album
// and recursively calls getArtist(v.AlbumArtistID)
artistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once()
albumRepo.On("Get", "album-1").Return(&album, nil).Once()
// Then it recurses with the artist-1 ID
artistRepo.On("Get", "artist-1").Return(&artist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist}, nil).Maybe()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist", "", mock.Anything).
Return([]agents.Song{{Name: "Song One", MBID: "mbid-1"}}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "album-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
})
Context("when ID is an Artist", func() {
It("calls GetSimilarSongsByArtist and returns matched songs", func() {
artist := model.Artist{ID: "artist-1", Name: "Depeche Mode", MbzArtistID: "artist-mbid"}
matchedSong := model.MediaFile{ID: "matched-1", Title: "Enjoy the Silence", Artist: "Depeche Mode", MbzRecordingID: "song-mbid"}
artistRepo.On("Get", "artist-1").Return(&artist, nil).Once()
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Depeche Mode", "artist-mbid", 5).
Return([]agents.Song{
{Name: "Enjoy the Silence", MBID: "song-mbid", Artist: "Depeche Mode"},
}, nil).Once()
// Mock loadTracksByID - no ID matches
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return ok
})).Return(model.MediaFiles{}, nil).Once()
// Mock loadTracksByMBID - MBID match
mediaFileRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
and, ok := opt.Filters.(squirrel.And)
if !ok || len(and) < 1 {
return false
}
_, hasEq := and[0].(squirrel.Eq)
return hasEq
})).Return(model.MediaFiles{matchedSong}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("matched-1"))
})
})
})
It("returns similar songs from main artist and similar artists", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Once()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
similarAgentsResp := []agents.Artist{
{Name: "Similar Artist", MBID: "similar-mbid"},
}
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(similarAgentsResp, nil).Once()
// Mock the three-phase artist lookup: ID (skipped - no IDs), MBID, then Name
// MBID lookup returns empty (no match)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Eq)
return opt.Max == 0 && ok
})).Return(model.Artists{}, nil).Once()
// Name lookup returns the similar artist
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
_, ok := opt.Filters.(squirrel.Or)
return opt.Max == 0 && ok
})).Return(model.Artists{similarArtist}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything).
Return([]agents.Song{
{Name: "Song Three", MBID: "mbid-3"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
for _, song := range songs {
Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3"))
}
})
It("returns ErrNotFound when artist is not found", func() {
artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
albumRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound)
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{}, nil).Maybe()
songs, err := provider.SimilarSongs(ctx, "artist-unknown-artist", 5)
Expect(err).To(Equal(model.ErrNotFound))
Expect(songs).To(BeNil())
})
It("returns songs from main artist when GetSimilarArtists returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return(nil, errors.New("error getting similar artists")).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(Equal("song-1"))
})
It("returns empty list when GetArtistTopSongs returns error", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, errors.New("error getting top songs")).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 5)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(BeEmpty())
})
It("respects count parameter", func() {
artist1 := model.Artist{ID: "artist-1", Name: "Artist One"}
song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"}
song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"}
artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 1 && opt.Filters != nil
})).Return(model.Artists{artist1}, nil).Maybe()
// New similar songs by artist returns ErrNotFound to trigger fallback
agentsCombined.On("GetSimilarSongsByArtist", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return(nil, agents.ErrNotFound).Once()
mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15).
Return([]agents.Artist{}, nil).Once()
artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool {
return opt.Max == 0 && opt.Filters != nil
})).Return(model.Artists{}, nil).Once()
mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything).
Return([]agents.Song{
{Name: "Song One", MBID: "mbid-1"},
{Name: "Song Two", MBID: "mbid-2"},
}, nil).Once()
mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once()
songs, err := provider.SimilarSongs(ctx, "artist-1", 1)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(1))
Expect(songs[0].ID).To(BeElementOf("song-1", "song-2"))
})
})

View File

@@ -7,8 +7,6 @@ import (
_ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
_ "github.com/navidrome/navidrome/adapters/spotify"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
. "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/model"
@@ -28,10 +26,6 @@ var _ = Describe("Provider - TopSongs", func() {
)
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls
conf.Server.SimilarSongsMatchThreshold = 100
ctx = GinkgoT().Context()
artistRepo = newMockArtistRepo() // Use helper mock

View File

@@ -9,7 +9,7 @@ import (
"sync"
"github.com/deluan/rest"
_ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
_ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core"
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage

View File

@@ -265,10 +265,6 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
if err != nil {
log.Trace(ctx, "Error reading active users count", err)
}
data.Library.FileSuffixes, err = c.ds.MediaFile(ctx).CountBySuffix()
if err != nil {
log.Trace(ctx, "Error reading file suffixes count", err)
}
// Check for smart playlists
data.Config.HasSmartPlaylists, err = c.hasSmartPlaylists(ctx)

View File

@@ -40,7 +40,6 @@ type Data struct {
Libraries int64 `json:"libraries"`
ActiveUsers int64 `json:"activeUsers"`
ActivePlayers map[string]int64 `json:"activePlayers,omitempty"`
FileSuffixes map[string]int64 `json:"fileSuffixes,omitempty"`
} `json:"library"`
Config struct {
LogLevel string `json:"logLevel,omitempty"`

View File

@@ -179,9 +179,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error {
mediaFileRepository := s.ds.MediaFile(ctx)
var mfs model.MediaFiles
// Chunk size of 100 lines, as each line can generate up to 4 lookup candidates
// (NFC/NFD × raw/lowercase), and SQLite has a max expression tree depth of 1000.
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 100) {
for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) {
filteredLines := make([]string, 0, len(lines))
for _, line := range lines {
line := strings.TrimSpace(line)
@@ -208,66 +206,33 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
continue
}
// SQLite comparisons do not perform Unicode normalization, and filesystem normalization
// differs across platforms (macOS often yields NFD, while Linux/Windows typically use NFC).
// Generate lookup candidates for both forms so playlist entries match DB paths regardless
// of the original normalization. See https://github.com/navidrome/navidrome/issues/4884
//
// We also include the original (non-lowercased) paths because SQLite's COLLATE NOCASE
// only handles ASCII case-insensitivity. Non-ASCII characters like fullwidth letters
// (e.g., vs ) are not matched case-insensitively by NOCASE.
lookupCandidates := make([]string, 0, len(resolvedPaths)*4)
seen := make(map[string]struct{}, len(resolvedPaths)*4)
for _, path := range resolvedPaths {
// Add original paths first (for exact matching of non-ASCII characters)
nfcRaw := norm.NFC.String(path)
if _, ok := seen[nfcRaw]; !ok {
seen[nfcRaw] = struct{}{}
lookupCandidates = append(lookupCandidates, nfcRaw)
}
nfdRaw := norm.NFD.String(path)
if _, ok := seen[nfdRaw]; !ok {
seen[nfdRaw] = struct{}{}
lookupCandidates = append(lookupCandidates, nfdRaw)
}
// Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
// See https://github.com/navidrome/navidrome/issues/4663
resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
return strings.ToLower(norm.NFD.String(path))
})
// Add lowercased paths (for ASCII case-insensitive matching via NOCASE)
nfc := strings.ToLower(nfcRaw)
if _, ok := seen[nfc]; !ok {
seen[nfc] = struct{}{}
lookupCandidates = append(lookupCandidates, nfc)
}
nfd := strings.ToLower(nfdRaw)
if _, ok := seen[nfd]; !ok {
seen[nfd] = struct{}{}
lookupCandidates = append(lookupCandidates, nfd)
}
}
found, err := mediaFileRepository.FindByPaths(lookupCandidates)
found, err := mediaFileRepository.FindByPaths(resolvedPaths)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
// Build lookup map with library-qualified keys, normalized for comparison.
// Canonicalize to NFC so NFD/NFC become comparable.
// Build lookup map with library-qualified keys, normalized for comparison
existing := make(map[string]int, len(found))
for idx := range found {
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(norm.NFC.String(found[idx].Path)))
// Normalize to lowercase for case-insensitive comparison
// Key format: "libraryID:path"
key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
existing[key] = idx
}
// Find media files in the order of the resolved paths, to keep playlist order
for _, path := range resolvedPaths {
key := strings.ToLower(norm.NFC.String(path))
idx, ok := existing[key]
idx, ok := existing[path]
if ok {
mfs = append(mfs, found[idx])
} else {
// Prefer logging a composed representation when possible to avoid confusing output
// with decomposed combining marks.
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", norm.NFC.String(path))
log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", path)
}
}
}
@@ -429,20 +394,7 @@ func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, line
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
owner, _ := request.UserFrom(ctx)
// Try to find existing playlist by path. Since filesystem normalization differs across
// platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match
// playlists that may have been imported on a different platform.
pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
if errors.Is(err, model.ErrNotFound) {
// Try alternate normalization form
altPath := norm.NFD.String(newPls.Path)
if altPath == newPls.Path {
altPath = norm.NFC.String(newPls.Path)
}
if altPath != newPls.Path {
pls, err = s.ds.Playlist(ctx).FindByPath(altPath)
}
}
if err != nil && !errors.Is(err, model.ErrNotFound) {
return err
}

View File

@@ -135,55 +135,6 @@ var _ = Describe("Playlists", func() {
})
})
DescribeTable("Playlist filename Unicode normalization (regression fix-playlist-filename-normalization)",
func(storedForm, filesystemForm string) {
// Use Polish characters that decompose: ó (U+00F3) -> o + combining acute (U+006F + U+0301)
plsNameNFC := "Piosenki_Polskie_zółć" // NFC form (composed)
plsNameNFD := norm.NFD.String(plsNameNFC)
Expect(plsNameNFD).ToNot(Equal(plsNameNFC)) // Verify they differ
nameByForm := map[string]string{"NFC": plsNameNFC, "NFD": plsNameNFD}
storedName := nameByForm[storedForm]
filesystemName := nameByForm[filesystemForm]
tmpDir := GinkgoT().TempDir()
mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}})
ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{}}
ps = core.NewPlaylists(ds)
// Create the playlist file on disk with the filesystem's normalization form
plsFile := tmpDir + "/" + filesystemName + ".m3u"
Expect(os.WriteFile(plsFile, []byte("#PLAYLIST:Test\n"), 0600)).To(Succeed())
// Pre-populate mock repo with the stored normalization form
storedPath := tmpDir + "/" + storedName + ".m3u"
existingPls := &model.Playlist{
ID: "existing-id",
Name: "Existing Playlist",
Path: storedPath,
Sync: true,
}
mockPlsRepo.data = map[string]*model.Playlist{storedPath: existingPls}
// Import using the filesystem's normalization form
plsFolder := &model.Folder{
ID: "1",
LibraryID: 1,
LibraryPath: tmpDir,
Path: "",
Name: "",
}
pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u")
Expect(err).ToNot(HaveOccurred())
// Should update existing playlist, not create new one
Expect(pls.ID).To(Equal("existing-id"))
Expect(pls.Name).To(Equal("Existing Playlist"))
},
Entry("finds NFD-stored playlist when filesystem provides NFC path", "NFD", "NFC"),
Entry("finds NFC-stored playlist when filesystem provides NFD path", "NFC", "NFD"),
)
Describe("Cross-library relative paths", func() {
var tmpDir, plsDir, songsDir string
@@ -495,79 +446,23 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
// Fullwidth characters (e.g., ) are not handled by SQLite's NOCASE collation,
// so we need exact matching for non-ASCII characters.
It("matches fullwidth characters exactly (SQLite NOCASE limitation)", func() {
// Fullwidth uppercase (U+FF21, U+FF23, U+FF32, U+FF2F, U+FF33, U+FF33)
repo.data = []string{
"plex/02 - .flac",
}
m3u := "/music/plex/02 - .flac\n"
It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
// Simulate macOS filesystem: stores paths in NFD (decomposed) form
// "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
repo.data = []string{nfdPath}
// Simulate Apple Music M3U: uses NFC (composed) form
nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
m3u := nfcPath + "\n"
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal("plex/02 - .flac"))
// Should match despite different Unicode normalization forms
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
})
// Unicode normalization tests: NFC (composed) vs NFD (decomposed) forms
// macOS stores paths in NFD, Linux/Windows use NFC. Playlists may use either form.
DescribeTable("matches paths across Unicode NFC/NFD normalization",
func(description, pathNFC string, dbForm, playlistForm norm.Form) {
pathNFD := norm.NFD.String(pathNFC)
Expect(pathNFD).ToNot(Equal(pathNFC), "test path should have decomposable characters")
// Set up DB with specified normalization form
var dbPath string
if dbForm == norm.NFC {
dbPath = pathNFC
} else {
dbPath = pathNFD
}
repo.data = []string{dbPath}
// Set up playlist with specified normalization form
var playlistPath string
if playlistForm == norm.NFC {
playlistPath = pathNFC
} else {
playlistPath = pathNFD
}
m3u := "/music/" + playlistPath + "\n"
f := strings.NewReader(m3u)
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
Expect(pls.Tracks).To(HaveLen(1))
Expect(pls.Tracks[0].Path).To(Equal(dbPath))
},
// French: è (U+00E8) decomposes to e + combining grave (U+0065 + U+0300)
Entry("French diacritics - DB:NFD, playlist:NFC",
"macOS DB with Apple Music playlist",
"artist/Michèle/song.mp3", norm.NFD, norm.NFC),
// Japanese Katakana: ド (U+30C9) decomposes to ト (U+30C8) + combining dakuten (U+3099)
Entry("Japanese Katakana with dakuten - DB:NFC, playlist:NFC (#4884)",
"Linux/Windows DB with NFC playlist",
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFC, norm.NFC),
Entry("Japanese Katakana with dakuten - DB:NFD, playlist:NFC (#4884)",
"macOS DB with NFC playlist",
"artist/\u30a2\u30a4\u30c9\u30eb/\u30c9\u30ea\u30fc\u30e0\u30bd\u30f3\u30b0.mp3", norm.NFD, norm.NFC),
// Cyrillic: й (U+0439) decomposes to и (U+0438) + combining breve (U+0306)
Entry("Cyrillic characters - DB:NFD, playlist:NFC (#4791)",
"macOS DB with NFC playlist",
"Жуки/Батарейка/01 - Разлюбила.mp3", norm.NFD, norm.NFC),
// Polish: ó (U+00F3) decomposes to o + combining acute (U+0301)
Entry("Polish diacritics - DB:NFD, playlist:NFC (#4663)",
"macOS DB with NFC playlist",
"Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFD, norm.NFC),
Entry("Polish diacritics - DB:NFC, playlist:NFD",
"Linux/Windows DB with macOS-exported playlist",
"Zespół/Człowiek/Piosenka o miłości.mp3", norm.NFC, norm.NFD),
)
})
Describe("InPlaylistsPath", func() {
@@ -668,6 +563,9 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
var mfs model.MediaFiles
for idx, dataPath := range r.data {
// Normalize the data path to NFD (simulates macOS filesystem storage)
normalizedDataPath := norm.NFD.String(dataPath)
for _, requestPath := range paths {
// Strip library qualifier if present (format: "libraryID:path")
actualPath := requestPath
@@ -679,9 +577,12 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
}
}
// Case-insensitive comparison (like SQL's "collate nocase"), but with no
// implicit Unicode normalization (SQLite does not normalize NFC/NFD).
if strings.EqualFold(actualPath, dataPath) {
// The request path should already be normalized to NFD by production code
// before calling FindByPaths (to match DB storage)
normalizedRequestPath := norm.NFD.String(actualPath)
// Case-insensitive comparison (like SQL's "collate nocase")
if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
mfs = append(mfs, model.MediaFile{
ID: strconv.Itoa(idx),
Path: dataPath, // Return original path from DB
@@ -696,16 +597,10 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi
type mockedPlaylistRepo struct {
last *model.Playlist
data map[string]*model.Playlist // keyed by path
model.PlaylistRepository
}
func (r *mockedPlaylistRepo) FindByPath(path string) (*model.Playlist, error) {
if r.data != nil {
if pls, ok := r.data[path]; ok {
return pls, nil
}
}
func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) {
return nil, model.ErrNotFound
}

View File

@@ -1,23 +0,0 @@
-- +goose Up
ALTER TABLE album ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
ALTER TABLE media_file ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
ALTER TABLE artist ADD COLUMN average_rating REAL NOT NULL DEFAULT 0;
-- Populate average_rating from existing ratings
UPDATE album SET average_rating = coalesce(
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = album.id AND item_type = 'album' AND rating > 0),
0
);
UPDATE media_file SET average_rating = coalesce(
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = media_file.id AND item_type = 'media_file' AND rating > 0),
0
);
UPDATE artist SET average_rating = coalesce(
(SELECT round(avg(rating), 2) FROM annotation WHERE item_id = artist.id AND item_type = 'artist' AND rating > 0),
0
);
-- +goose Down
ALTER TABLE artist DROP COLUMN average_rating;
ALTER TABLE media_file DROP COLUMN average_rating;
ALTER TABLE album DROP COLUMN average_rating;

15
go.mod
View File

@@ -2,13 +2,8 @@ module github.com/navidrome/navidrome
go 1.25
replace (
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
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
)
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
require (
github.com/Masterminds/squirrel v1.5.4
@@ -58,15 +53,13 @@ require (
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
github.com/sirupsen/logrus v1.9.4
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.11.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.senan.xyz/taglib v0.11.1
go.uber.org/goleak v1.3.0
golang.org/x/image v0.35.0
golang.org/x/net v0.49.0
@@ -98,7 +91,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 // indirect
github.com/google/subcommands v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect

16
go.sum
View File

@@ -36,8 +36,6 @@ 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/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=
@@ -56,8 +54,6 @@ github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE
github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
@@ -110,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw=
github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440 h1:oKBqR+eQXiIM7X8K1JEg9aoTEePLq/c6Awe484abOuA=
github.com/google/pprof v0.0.0-20260111202518-71be6bfdd440/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -238,15 +234,13 @@ github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88ee
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@@ -275,6 +269,7 @@ github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRci
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -363,6 +358,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -3,13 +3,12 @@ package model
import "time"
type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" `
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
AverageRating float64 `structs:"average_rating" json:"averageRating,omitempty"`
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" `
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
}
type AnnotatedRepository interface {

View File

@@ -353,7 +353,6 @@ type MediaFileCursor iter.Seq2[MediaFile, error]
type MediaFileRepository interface {
CountAll(options ...QueryOptions) (int64, error)
CountBySuffix(options ...QueryOptions) (map[string]int64, error)
Exists(id string) (bool, error)
Put(m *MediaFile) error
Get(id string) (*MediaFile, error)

View File

@@ -8,7 +8,6 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
@@ -27,14 +26,10 @@ type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibI
func createGetPID(hash hashFunc) getPIDFunc {
var getPID getPIDFunc
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
if spec == conf.Server.PID.Album {
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
return ""
}
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder":
return filepath.Dir(mf.Path)
@@ -54,7 +49,7 @@ func createGetPID(hash hashFunc) getPIDFunc {
attributes := strings.Split(field, ",")
hasValue := false
values := slice.Map(attributes, func(attr string) string {
v := getAttr(mf, md, attr, prependLibId, spec)
v := getAttr(mf, md, attr, prependLibId)
if v != "" {
hasValue = true
}

View File

@@ -114,24 +114,6 @@ var _ = Describe("getPID", func() {
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
})
})
When("albumid configuration refers to albumid recursively", func() {
It("should avoid infinite recursion", func() {
// Reproduce the issue from #4920
conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
spec := conf.Server.PID.Album
md.tags = map[model.TagName][]string{
"album": {"Album Name"},
"albumversion": {"Version"},
"releasedate": {"2022"},
}
// Should not panic and return a valid PID ignoring the recursive "albumid"
Expect(func() {
pid := getPID(mf, md, spec, false)
Expect(pid).To(Equal("(\\album name\\Version\\2022)"))
}).To(Not(Panic()))
})
})
})
Context("edge cases", func() {

View File

@@ -119,8 +119,8 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"artist_id": artistFilter,
"year": yearFilter,
"recently_played": recentlyPlayedFilter,
"starred": annotationBoolFilter("starred"),
"has_rating": annotationBoolFilter("rating"),
"starred": booleanFilter,
"has_rating": hasRatingFilter,
"missing": booleanFilter,
"genre_id": tagIDFilter,
"role_total_id": allRolesFilter,
@@ -149,6 +149,10 @@ func recentlyPlayedFilter(string, interface{}) Sqlizer {
return Gt{"play_count": 0}
}
func hasRatingFilter(string, interface{}) Sqlizer {
return Gt{"rating": 0}
}
func yearFilter(_ string, value interface{}) Sqlizer {
return Or{
And{

View File

@@ -5,7 +5,6 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
@@ -78,82 +77,6 @@ var _ = Describe("AlbumRepository", func() {
})
})
Context("Filters", func() {
var albumWithoutAnnotation model.Album
BeforeEach(func() {
// Create album without any annotation (no star, no rating)
albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
})
AfterEach(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
})
Describe("starred", func() {
It("false includes items without annotations", func() {
res, err := albumRepo.ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "false"},
})
Expect(err).ToNot(HaveOccurred())
albums := res.(model.Albums)
var found bool
for _, a := range albums {
if a.ID == albumWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Album without annotation should be included in starred=false filter")
})
It("true excludes items without annotations", func() {
res, err := albumRepo.ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "true"},
})
Expect(err).ToNot(HaveOccurred())
albums := res.(model.Albums)
for _, a := range albums {
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
}
})
})
Describe("has_rating", func() {
It("false includes items without annotations", func() {
res, err := albumRepo.ReadAll(rest.QueryOptions{
Filters: map[string]any{"has_rating": "false"},
})
Expect(err).ToNot(HaveOccurred())
albums := res.(model.Albums)
var found bool
for _, a := range albums {
if a.ID == albumWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Album without annotation should be included in has_rating=false filter")
})
It("true excludes items without annotations", func() {
res, err := albumRepo.ReadAll(rest.QueryOptions{
Filters: map[string]any{"has_rating": "true"},
})
Expect(err).ToNot(HaveOccurred())
albums := res.(model.Albums)
for _, a := range albums {
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
}
})
})
})
Describe("Album.PlayCount", func() {
// Implementation is in withAnnotation() method
DescribeTable("normalizes play count when AlbumPlayCountMode is absolute",
@@ -203,89 +126,6 @@ var _ = Describe("AlbumRepository", func() {
)
})
Describe("Album.AverageRating", func() {
It("returns 0 when no ratings exist", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "no ratings album"})).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(0.0))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("returns the user's rating as average when only one user rated", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "single rating album"})).To(Succeed())
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(4.0))
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("calculates average across multiple users", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "multi rating album"})).To(Succeed())
Expect(albumRepo.SetRating(4, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(4.5))
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("excludes zero ratings from average calculation", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "zero rating excluded album"})).To(Succeed())
Expect(albumRepo.SetRating(3, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(3.0))
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
It("rounds to 2 decimal places", func() {
newID := id.NewRandom()
Expect(albumRepo.Put(&model.Album{LibraryID: 1, ID: newID, Name: "rounding test album"})).To(Succeed())
Expect(albumRepo.SetRating(5, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewAlbumRepository(user2Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user2Repo.SetRating(4, newID)).To(Succeed())
user3Ctx := request.WithUser(GinkgoT().Context(), thirdUser)
user3Repo := NewAlbumRepository(user3Ctx, GetDBXBuilder()).(*albumRepository)
Expect(user3Repo.SetRating(4, newID)).To(Succeed())
album, err := albumRepo.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(album.AverageRating).To(Equal(4.33)) // (5 + 4 + 4) / 3 = 4.333...
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": newID}))
})
})
Describe("dbAlbum mapping", func() {
var (
a model.Album

View File

@@ -133,7 +133,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.registerModel(&model.Artist{}, map[string]filterFunc{
"id": idFilter(r.tableName),
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
"starred": annotationBoolFilter("starred"),
"starred": booleanFilter,
"role": roleFilter,
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
@@ -387,54 +386,6 @@ var _ = Describe("ArtistRepository", func() {
})
})
Describe("Filters", func() {
var artistWithoutAnnotation model.Artist
BeforeEach(func() {
// Create artist without any annotation
artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"}
err := createArtistWithLibrary(repo, &artistWithoutAnnotation, 1)
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
if raw, ok := repo.(*artistRepository); ok {
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID}))
}
})
Describe("starred", func() {
It("false includes items without annotations", func() {
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "false"},
})
Expect(err).ToNot(HaveOccurred())
artists := res.(model.Artists)
var found bool
for _, a := range artists {
if a.ID == artistWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Artist without annotation should be included in starred=false filter")
})
It("true excludes items without annotations", func() {
res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "true"},
})
Expect(err).ToNot(HaveOccurred())
artists := res.(model.Artists)
for _, a := range artists {
Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID))
}
})
})
})
Describe("MBID and Text Search", func() {
var lib2 model.Library
var lr model.LibraryRepository

View File

@@ -95,7 +95,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
filters := map[string]filterFunc{
"id": idFilter("media_file"),
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
"starred": annotationBoolFilter("starred"),
"starred": booleanFilter,
"genre_id": tagIDFilter,
"missing": booleanFilter,
"artists_id": artistFilter,
@@ -124,25 +124,6 @@ func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, er
return r.count(query, options...)
}
func (r *mediaFileRepository) CountBySuffix(options ...model.QueryOptions) (map[string]int64, error) {
sel := r.newSelect(options...).
Columns("lower(suffix) as suffix", "count(*) as count").
GroupBy("lower(suffix)")
var res []struct {
Suffix string
Count int64
}
err := r.queryAll(sel, &res)
if err != nil {
return nil, err
}
counts := make(map[string]int64, len(res))
for _, c := range res {
counts[c.Suffix] = c.Count
}
return counts, nil
}
func (r *mediaFileRepository) Exists(id string) (bool, error) {
return r.exists(Eq{"media_file.id": id})
}

View File

@@ -5,7 +5,6 @@ import (
"time"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
@@ -42,44 +41,6 @@ var _ = Describe("MediaRepository", func() {
Expect(mr.CountAll()).To(Equal(int64(10)))
})
Describe("CountBySuffix", func() {
var mp3File, flacFile1, flacFile2, flacUpperFile model.MediaFile
BeforeEach(func() {
mp3File = model.MediaFile{ID: "suffix-mp3", LibraryID: 1, Suffix: "mp3", Path: "/test/file.mp3"}
flacFile1 = model.MediaFile{ID: "suffix-flac1", LibraryID: 1, Suffix: "flac", Path: "/test/file1.flac"}
flacFile2 = model.MediaFile{ID: "suffix-flac2", LibraryID: 1, Suffix: "flac", Path: "/test/file2.flac"}
flacUpperFile = model.MediaFile{ID: "suffix-FLAC", LibraryID: 1, Suffix: "FLAC", Path: "/test/file.FLAC"}
Expect(mr.Put(&mp3File)).To(Succeed())
Expect(mr.Put(&flacFile1)).To(Succeed())
Expect(mr.Put(&flacFile2)).To(Succeed())
Expect(mr.Put(&flacUpperFile)).To(Succeed())
})
AfterEach(func() {
_ = mr.Delete(mp3File.ID)
_ = mr.Delete(flacFile1.ID)
_ = mr.Delete(flacFile2.ID)
_ = mr.Delete(flacUpperFile.ID)
})
It("counts media files grouped by suffix with lowercase normalization", func() {
counts, err := mr.CountBySuffix()
Expect(err).ToNot(HaveOccurred())
// Should have lowercase keys only
Expect(counts).To(HaveKey("mp3"))
Expect(counts).To(HaveKey("flac"))
Expect(counts).ToNot(HaveKey("FLAC"))
// mp3: 1 file
Expect(counts["mp3"]).To(Equal(int64(1)))
// flac: 3 files (2 lowercase + 1 uppercase normalized)
Expect(counts["flac"]).To(Equal(int64(3)))
})
})
It("returns songs ordered by lyrics with a specific title/artist", func() {
// attempt to mimic filters.SongsByArtistTitleWithLyricsFirst, except we want all items
results, err := mr.GetAll(model.QueryOptions{
@@ -158,74 +119,6 @@ var _ = Describe("MediaRepository", func() {
Expect(mf.PlayCount).To(Equal(int64(1)))
})
Describe("AverageRating", func() {
var raw *mediaFileRepository
BeforeEach(func() {
raw = mr.(*mediaFileRepository)
})
It("returns 0 when no ratings exist", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/no-rating.mp3"})).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(0.0))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("returns the user's rating as average when only one user rated", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/single-rating.mp3"})).To(Succeed())
Expect(mr.SetRating(5, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(5.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("calculates average across multiple users", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/multi-rating.mp3"})).To(Succeed())
Expect(mr.SetRating(3, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
Expect(user2Repo.SetRating(5, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(4.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
It("excludes zero ratings from average calculation", func() {
newID := id.NewRandom()
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID, Path: "/test/zero-excluded.mp3"})).To(Succeed())
Expect(mr.SetRating(4, newID)).To(Succeed())
user2Ctx := request.WithUser(GinkgoT().Context(), regularUser)
user2Repo := NewMediaFileRepository(user2Ctx, GetDBXBuilder())
Expect(user2Repo.SetRating(0, newID)).To(Succeed())
mf, err := mr.Get(newID)
Expect(err).ToNot(HaveOccurred())
Expect(mf.AverageRating).To(Equal(4.0))
_, _ = raw.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": newID}))
_, _ = raw.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": newID}))
})
})
It("preserves play date if and only if provided date is older", func() {
id := "incplay.playdate"
Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil())
@@ -418,50 +311,6 @@ var _ = Describe("MediaRepository", func() {
})
})
Context("Filters", func() {
var mfWithoutAnnotation model.MediaFile
BeforeEach(func() {
mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"}
Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed())
})
AfterEach(func() {
_ = mr.Delete(mfWithoutAnnotation.ID)
})
Describe("starred", func() {
It("false includes items without annotations", func() {
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "false"},
})
Expect(err).ToNot(HaveOccurred())
files := res.(model.MediaFiles)
var found bool
for _, f := range files {
if f.ID == mfWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter")
})
It("true excludes items without annotations", func() {
res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": "true"},
})
Expect(err).ToNot(HaveOccurred())
files := res.(model.MediaFiles)
for _, f := range files {
Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID))
}
})
})
})
Describe("Search", func() {
Context("text search", func() {
It("finds media files by title", func() {
@@ -561,92 +410,4 @@ var _ = Describe("MediaRepository", func() {
})
})
})
Describe("FindByPaths", func() {
// Test fixtures for Unicode and case-sensitivity tests
var testFiles []model.MediaFile
BeforeEach(func() {
testFiles = []model.MediaFile{
{ID: "findpath-1", LibraryID: 1, Path: "artist/Album/track.mp3", Title: "Track"},
{ID: "findpath-2", LibraryID: 1, Path: "artist/Album/UPPER.mp3", Title: "Upper"},
// Fullwidth uppercase: (U+FF21 U+FF23 U+FF32 U+FF2F U+FF33 U+FF33)
{ID: "findpath-3", LibraryID: 1, Path: "plex/02 - .flac", Title: "Fullwidth"},
// French diacritic: è (U+00E8, can decompose to e + combining grave)
{ID: "findpath-4", LibraryID: 1, Path: "artist/Michèle/song.mp3", Title: "French"},
}
for _, mf := range testFiles {
Expect(mr.Put(&mf)).To(Succeed())
}
})
AfterEach(func() {
for _, mf := range testFiles {
_ = mr.Delete(mf.ID)
}
})
It("finds files by exact path", func() {
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("findpath-1"))
})
It("finds files case-insensitively for ASCII characters (NOCASE)", func() {
// SQLite's COLLATE NOCASE handles ASCII case-insensitivity
results, err := mr.FindByPaths([]string{"1:ARTIST/ALBUM/TRACK.MP3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("findpath-1"))
})
It("finds fullwidth characters only with exact case match (SQLite NOCASE limitation)", func() {
// SQLite's NOCASE does NOT handle fullwidth uppercase/lowercase equivalence
// The DB has fullwidth uppercase , searching with exact match should work
results, err := mr.FindByPaths([]string{"1:plex/02 - .flac"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
Expect(results[0].ID).To(Equal("findpath-3"))
// Searching with fullwidth lowercase should NOT match
// (this is the SQLite limitation that requires exact matching for non-ASCII)
results, err = mr.FindByPaths([]string{"1:plex/02 - .flac"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("returns multiple files when querying multiple paths", func() {
results, err := mr.FindByPaths([]string{
"1:artist/Album/track.mp3",
"1:artist/Album/UPPER.mp3",
})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(2))
})
It("returns empty slice for non-existent paths", func() {
results, err := mr.FindByPaths([]string{"1:nonexistent/path.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("returns empty slice for empty input", func() {
results, err := mr.FindByPaths([]string{})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
It("handles library-qualified paths correctly", func() {
// Library 1 should find the file
results, err := mr.FindByPaths([]string{"1:artist/Album/track.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(HaveLen(1))
// Library 2 should NOT find it (file is in library 1)
results, err = mr.FindByPaths([]string{"2:artist/Album/track.mp3"})
Expect(err).ToNot(HaveOccurred())
Expect(results).To(BeEmpty())
})
})
})

View File

@@ -130,8 +130,7 @@ var (
var (
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
testUsers = model.Users{adminUser, regularUser, thirdUser}
testUsers = model.Users{adminUser, regularUser}
)
func p(path string) string {

View File

@@ -4,7 +4,6 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -18,7 +17,7 @@ const annotationTable = "annotation"
func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder {
userID := loggedUser(r.ctx).ID
if userID == invalidUserId {
return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
return query
}
query = query.
LeftJoin("annotation on ("+
@@ -39,24 +38,9 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
query = query.Columns("coalesce(play_count, 0) as play_count")
}
query = query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
return query
}
func annotationBoolFilter(field string) func(string, any) Sqlizer {
return func(_ string, value any) Sqlizer {
v, ok := value.(string)
if !ok {
return nil
}
if strings.ToLower(v) == "true" {
return Expr(fmt.Sprintf("COALESCE(%s, 0) > 0", field))
}
return Expr(fmt.Sprintf("COALESCE(%s, 0) = 0", field))
}
}
func (r sqlRepository) annId(itemID ...string) And {
userID := loggedUser(r.ctx).ID
return And{
@@ -95,22 +79,7 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
func (r sqlRepository) SetRating(rating int, itemID string) error {
ratedAt := time.Now()
err := r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
if err != nil {
return err
}
return r.updateAvgRating(itemID)
}
func (r sqlRepository) updateAvgRating(itemID string) error {
upd := Update(r.tableName).
Where(Eq{"id": itemID}).
Set("average_rating", Expr(
"coalesce((select round(avg(rating), 2) from annotation where item_id = ? and item_type = ? and rating > 0), 0)",
itemID, r.tableName,
))
_, err := r.executeSQL(upd)
return err
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {

View File

@@ -1,153 +0,0 @@
package persistence
import (
"context"
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Annotation Filters", func() {
var (
albumRepo *albumRepository
albumWithoutAnnotation model.Album
)
BeforeEach(func() {
ctx := request.WithUser(context.Background(), model.User{ID: "userid", UserName: "johndoe"})
albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
// Create album without any annotation (no star, no rating)
albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
})
AfterEach(func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
})
Describe("annotationBoolFilter", func() {
DescribeTable("creates correct SQL expressions",
func(field, value string, expectedSQL string, expectedArgs []interface{}) {
sqlizer := annotationBoolFilter(field)(field, value)
sql, args, err := sqlizer.ToSql()
Expect(err).ToNot(HaveOccurred())
Expect(sql).To(Equal(expectedSQL))
Expect(args).To(Equal(expectedArgs))
},
Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []interface{}(nil)),
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)),
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)),
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(nil)),
)
It("returns nil if value is not a string", func() {
sqlizer := annotationBoolFilter("starred")("starred", 123)
Expect(sqlizer).To(BeNil())
})
})
Describe("starredFilter", func() {
It("false includes items without annotations", func() {
albums, err := albumRepo.GetAll(model.QueryOptions{
Filters: annotationBoolFilter("starred")("starred", "false"),
})
Expect(err).ToNot(HaveOccurred())
var found bool
for _, a := range albums {
if a.ID == albumWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter")
})
It("true excludes items without annotations", func() {
albums, err := albumRepo.GetAll(model.QueryOptions{
Filters: annotationBoolFilter("starred")("starred", "true"),
})
Expect(err).ToNot(HaveOccurred())
for _, a := range albums {
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
}
})
})
Describe("hasRatingFilter", func() {
It("false includes items without annotations", func() {
albums, err := albumRepo.GetAll(model.QueryOptions{
Filters: annotationBoolFilter("rating")("rating", "false"),
})
Expect(err).ToNot(HaveOccurred())
var found bool
for _, a := range albums {
if a.ID == albumWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter")
})
It("true excludes items without annotations", func() {
albums, err := albumRepo.GetAll(model.QueryOptions{
Filters: annotationBoolFilter("rating")("rating", "true"),
})
Expect(err).ToNot(HaveOccurred())
for _, a := range albums {
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
}
})
It("true includes items with rating > 0", func() {
// Create album with rating 1
ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1}
Expect(albumRepo.Put(&ratedAlbum)).To(Succeed())
Expect(albumRepo.SetRating(1, ratedAlbum.ID)).To(Succeed())
defer func() {
_, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID}))
_, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID}))
}()
albums, err := albumRepo.GetAll(model.QueryOptions{
Filters: annotationBoolFilter("rating")("rating", "true"),
})
Expect(err).ToNot(HaveOccurred())
var found bool
for _, a := range albums {
if a.ID == ratedAlbum.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter")
})
})
It("ignores invalid filter values (not strings)", func() {
res, err := albumRepo.ReadAll(rest.QueryOptions{
Filters: map[string]any{"starred": 123},
})
Expect(err).ToNot(HaveOccurred())
albums := res.(model.Albums)
var found bool
for _, a := range albums {
if a.ID == albumWithoutAnnotation.ID {
found = true
break
}
}
Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored")
})
})

View File

@@ -40,18 +40,6 @@ type MetadataAgent interface {
// GetAlbumImages retrieves images for an album.
//nd:export name=nd_get_album_images
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
// GetSimilarSongsByTrack retrieves songs similar to a specific track.
//nd:export name=nd_get_similar_songs_by_track
GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
//nd:export name=nd_get_similar_songs_by_album
GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
//nd:export name=nd_get_similar_songs_by_artist
GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
}
// ArtistMBIDRequest is the request for GetArtistMBID.
@@ -134,7 +122,7 @@ type TopSongsRequest struct {
Count int32 `json:"count"`
}
// SongRef is a reference to a song with metadata for matching.
// SongRef is a reference to a song with name and optional MBID.
type SongRef struct {
// ID is the internal Navidrome mediafile ID (if known).
ID string `json:"id,omitempty"`
@@ -142,16 +130,6 @@ type SongRef struct {
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
// Artist is the artist name.
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.
AlbumMBID string `json:"albumMbid,omitempty"`
// Duration is the song duration in seconds.
Duration float32 `json:"duration,omitempty"`
}
// TopSongsResponse is the response for GetArtistTopSongs.
@@ -187,49 +165,3 @@ type AlbumImagesResponse struct {
// Images is the list of album images.
Images []ImageInfo `json:"images"`
}
// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
type SimilarSongsByTrackRequest struct {
// ID is the internal Navidrome mediafile ID.
ID string `json:"id"`
// Name is the track title.
Name string `json:"name"`
// Artist is the artist name.
Artist string `json:"artist"`
// MBID is the MusicBrainz recording ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
type SimilarSongsByAlbumRequest struct {
// ID is the internal Navidrome album ID.
ID string `json:"id"`
// Name is the album name.
Name string `json:"name"`
// Artist is the album artist name.
Artist string `json:"artist"`
// MBID is the MusicBrainz release ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
type SimilarSongsByArtistRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz artist ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
type SimilarSongsResponse struct {
// Songs is the list of similar songs.
Songs []SongRef `json:"songs"`
}

View File

@@ -64,30 +64,6 @@ exports:
output:
$ref: '#/components/schemas/AlbumImagesResponse'
contentType: application/json
nd_get_similar_songs_by_track:
description: GetSimilarSongsByTrack retrieves songs similar to a specific track.
input:
$ref: '#/components/schemas/SimilarSongsByTrackRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SimilarSongsResponse'
contentType: application/json
nd_get_similar_songs_by_album:
description: GetSimilarSongsByAlbum retrieves songs similar to tracks on an album.
input:
$ref: '#/components/schemas/SimilarSongsByAlbumRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SimilarSongsResponse'
contentType: application/json
nd_get_similar_songs_by_artist:
description: GetSimilarSongsByArtist retrieves songs similar to an artist's catalog.
input:
$ref: '#/components/schemas/SimilarSongsByArtistRequest'
contentType: application/json
output:
$ref: '#/components/schemas/SimilarSongsResponse'
contentType: application/json
components:
schemas:
AlbumImagesResponse:
@@ -253,86 +229,8 @@ components:
$ref: '#/components/schemas/ArtistRef'
required:
- artists
SimilarSongsByAlbumRequest:
description: SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
properties:
id:
type: string
description: ID is the internal Navidrome album ID.
name:
type: string
description: Name is the album name.
artist:
type: string
description: Artist is the album artist name.
mbid:
type: string
description: MBID is the MusicBrainz release ID (if known).
count:
type: integer
format: int32
description: Count is the maximum number of similar songs to return.
required:
- id
- name
- artist
- count
SimilarSongsByArtistRequest:
description: SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
properties:
id:
type: string
description: ID is the internal Navidrome artist ID.
name:
type: string
description: Name is the artist name.
mbid:
type: string
description: MBID is the MusicBrainz artist ID (if known).
count:
type: integer
format: int32
description: Count is the maximum number of similar songs to return.
required:
- id
- name
- count
SimilarSongsByTrackRequest:
description: SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
properties:
id:
type: string
description: ID is the internal Navidrome mediafile ID.
name:
type: string
description: Name is the track title.
artist:
type: string
description: Artist is the artist name.
mbid:
type: string
description: MBID is the MusicBrainz recording ID (if known).
count:
type: integer
format: int32
description: Count is the maximum number of similar songs to return.
required:
- id
- name
- artist
- count
SimilarSongsResponse:
description: SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
properties:
songs:
type: array
description: Songs is the list of similar songs.
items:
$ref: '#/components/schemas/SongRef'
required:
- songs
SongRef:
description: SongRef is a reference to a song with metadata for matching.
description: SongRef is a reference to a song with name and optional MBID.
properties:
id:
type: string
@@ -343,22 +241,6 @@ components:
mbid:
type: string
description: MBID is the MusicBrainz ID for the song.
artist:
type: string
description: Artist is the artist name.
artistMbid:
type: string
description: ArtistMBID is the MusicBrainz artist ID.
album:
type: string
description: Album is the album name.
albumMbid:
type: string
description: AlbumMBID is the MusicBrainz release ID.
duration:
type: number
format: float
description: Duration is the song duration in seconds.
required:
- name
TopSongsRequest:

View File

@@ -4,10 +4,10 @@ go 1.25
require (
github.com/extism/go-pdk v1.1.3
github.com/onsi/ginkgo/v2 v2.27.5
github.com/onsi/gomega v1.39.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/tools v0.41.0
github.com/onsi/ginkgo/v2 v2.27.3
github.com/onsi/gomega v1.38.3
github.com/xeipuuv/gojsonschema v1.2.0
golang.org/x/tools v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -16,11 +16,13 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

View File

@@ -1,9 +1,8 @@
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
@@ -20,8 +19,8 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -32,16 +31,16 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE=
github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q=
github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/onsi/ginkgo/v2 v2.27.3 h1:ICsZJ8JoYafeXFFlFAG75a7CxMsJHwgKwtO+82SE9L8=
github.com/onsi/ginkgo/v2 v2.27.3/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
@@ -52,20 +51,26 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -568,18 +568,6 @@ func skipSerializingFunc(goType string) string {
return "String::is_empty"
case "bool":
return "std::ops::Not::not"
case "int32":
return "is_zero_i32"
case "uint32":
return "is_zero_u32"
case "int64":
return "is_zero_i64"
case "uint64":
return "is_zero_u64"
case "float32":
return "is_zero_f32"
case "float64":
return "is_zero_f64"
default:
return "Option::is_none"
}

View File

@@ -1234,37 +1234,6 @@ type OnInitOutput struct {
})
var _ = Describe("Rust Generation", func() {
Describe("skipSerializingFunc", func() {
It("should return Option::is_none for pointer, slice, and map types", func() {
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("[]string")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("[]int32")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("map[string]int")).To(Equal("Option::is_none"))
})
It("should return String::is_empty for string type", func() {
Expect(skipSerializingFunc("string")).To(Equal("String::is_empty"))
})
It("should return std::ops::Not::not for bool type", func() {
Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not"))
})
It("should return is_zero_* functions for numeric types", func() {
Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32"))
Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32"))
Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64"))
Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64"))
Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32"))
Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64"))
})
It("should return Option::is_none for unknown types", func() {
Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none"))
})
})
Describe("rustOutputType", func() {
It("should convert Go primitives to Rust primitives", func() {
Expect(rustOutputType("bool")).To(Equal("bool"))

View File

@@ -7,20 +7,6 @@ use serde::{Deserialize, Serialize};
{{- if hasHashMap .Capability}}
use std::collections::HashMap;
{{- end}}
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
{{- end}}
{{- /* Generate type alias definitions */ -}}

View File

@@ -466,7 +466,9 @@ func RustDefaultValue(goType string) string {
switch goType {
case "string":
return `String::new()`
case "int", "int32", "int64", "uint", "uint32", "uint64":
case "int", "int32":
return "0"
case "int64":
return "0"
case "float32", "float64":
return "0.0"
@@ -600,10 +602,6 @@ func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
return "i32"
case "int64":
return "i64"
case "uint", "uint32":
return "u32"
case "uint64":
return "u64"
case "float32":
return "f32"
case "float64":

View File

@@ -106,7 +106,7 @@ func buildExport(export Export) xtpExport {
// isPrimitiveGoType returns true if the Go type is a primitive type.
func isPrimitiveGoType(goType string) bool {
switch goType {
case "bool", "string", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "[]byte":
case "bool", "string", "int", "int32", "int64", "float32", "float64", "[]byte":
return true
}
return false
@@ -302,12 +302,6 @@ func goTypeToXTPTypeAndFormat(goType string) (typ, format string) {
return "integer", "int32"
case "int64":
return "integer", "int64"
case "uint", "uint32":
// XTP schema doesn't support unsigned formats; use int64 to hold full uint32 range
return "integer", "int64"
case "uint64":
// XTP schema doesn't support unsigned formats; use int64 (may lose precision for large values)
return "integer", "int64"
case "float32":
return "number", "float"
case "float64":

View File

@@ -3,11 +3,10 @@ package internal
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/xeipuuv/gojsonschema"
"gopkg.in/yaml.v3"
)
@@ -26,61 +25,27 @@ func ValidateXTPSchema(generatedSchema []byte) error {
return fmt.Errorf("failed to parse generated schema as YAML: %w", err)
}
// Parse the XTP schema JSON
var xtpSchema any
if err := json.Unmarshal([]byte(xtpSchemaJSON), &xtpSchema); err != nil {
return fmt.Errorf("failed to parse XTP schema: %w", err)
}
// Compile the XTP schema
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("xtp-schema.json", xtpSchema); err != nil {
return fmt.Errorf("failed to add XTP schema resource: %w", err)
}
schema, err := compiler.Compile("xtp-schema.json")
// Convert to JSON for the validator
jsonBytes, err := json.Marshal(schemaDoc)
if err != nil {
return fmt.Errorf("failed to compile XTP schema: %w", err)
return fmt.Errorf("failed to convert schema to JSON: %w", err)
}
// Validate the generated schema against XTP schema
if err := schema.Validate(schemaDoc); err != nil {
return fmt.Errorf("schema validation errors:\n%s", formatValidationErrors(err))
schemaLoader := gojsonschema.NewStringLoader(xtpSchemaJSON)
documentLoader := gojsonschema.NewBytesLoader(jsonBytes)
result, err := gojsonschema.Validate(schemaLoader, documentLoader)
if err != nil {
return fmt.Errorf("schema validation failed: %w", err)
}
if !result.Valid() {
var errs []string
for _, desc := range result.Errors() {
errs = append(errs, fmt.Sprintf("- %s", desc))
}
return fmt.Errorf("schema validation errors:\n%s", strings.Join(errs, "\n"))
}
return nil
}
// formatValidationErrors formats jsonschema validation errors into readable strings.
func formatValidationErrors(err error) string {
var validationErr *jsonschema.ValidationError
if !errors.As(err, &validationErr) {
return fmt.Sprintf("- %s", err.Error())
}
var errs []string
collectValidationErrors(validationErr, &errs)
if len(errs) == 0 {
return fmt.Sprintf("- %s", validationErr.Error())
}
return strings.Join(errs, "\n")
}
// collectValidationErrors recursively collects leaf validation errors.
func collectValidationErrors(err *jsonschema.ValidationError, errs *[]string) {
if len(err.Causes) > 0 {
for _, cause := range err.Causes {
collectValidationErrors(cause, errs)
}
return
}
// Leaf error - format with location if available
msg := err.Error()
if len(err.InstanceLocation) > 0 {
location := strings.Join(err.InstanceLocation, "/")
msg = fmt.Sprintf("%s: %s", location, msg)
}
*errs = append(*errs, fmt.Sprintf("- %s", msg))
}

View File

@@ -1,129 +0,0 @@
package plugins
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
)
// ConfigValidationError represents a validation error with field path and message.
type ConfigValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
// ConfigValidationErrors is a collection of validation errors.
type ConfigValidationErrors struct {
Errors []ConfigValidationError `json:"errors"`
}
func (e *ConfigValidationErrors) Error() string {
if len(e.Errors) == 0 {
return "validation failed"
}
var msgs []string
for _, err := range e.Errors {
if err.Field != "" {
msgs = append(msgs, fmt.Sprintf("%s: %s", err.Field, err.Message))
} else {
msgs = append(msgs, err.Message)
}
}
return strings.Join(msgs, "; ")
}
// ValidateConfig validates a config JSON string against a plugin's config schema.
// If the manifest has no config schema, it returns an error indicating the plugin
// has no configurable options.
// Returns nil if validation passes, ConfigValidationErrors if validation fails.
func ValidateConfig(manifest *Manifest, configJSON string) error {
// If no config schema defined, plugin has no configurable options
if !manifest.HasConfigSchema() {
return fmt.Errorf("plugin has no configurable options")
}
// Parse the config JSON (empty string treated as empty object)
var configData any
if configJSON == "" {
configData = map[string]any{}
} else {
if err := json.Unmarshal([]byte(configJSON), &configData); err != nil {
return &ConfigValidationErrors{
Errors: []ConfigValidationError{{
Message: fmt.Sprintf("invalid JSON: %v", err),
}},
}
}
}
// Compile the schema
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", manifest.Config.Schema); err != nil {
return fmt.Errorf("adding schema resource: %w", err)
}
schema, err := compiler.Compile("schema.json")
if err != nil {
return fmt.Errorf("compiling schema: %w", err)
}
// Validate config against schema
if err := schema.Validate(configData); err != nil {
return convertValidationError(err)
}
return nil
}
// convertValidationError converts jsonschema validation errors to our format.
func convertValidationError(err error) *ConfigValidationErrors {
var validationErr *jsonschema.ValidationError
if !errors.As(err, &validationErr) {
return &ConfigValidationErrors{
Errors: []ConfigValidationError{{
Message: err.Error(),
}},
}
}
var configErrors []ConfigValidationError
collectErrors(validationErr, &configErrors)
if len(configErrors) == 0 {
configErrors = append(configErrors, ConfigValidationError{
Message: validationErr.Error(),
})
}
return &ConfigValidationErrors{Errors: configErrors}
}
// collectErrors recursively collects validation errors from the error tree.
func collectErrors(err *jsonschema.ValidationError, errors *[]ConfigValidationError) {
// If there are child errors, collect from them
if len(err.Causes) > 0 {
for _, cause := range err.Causes {
collectErrors(cause, errors)
}
return
}
// Leaf error - add it
field := ""
if len(err.InstanceLocation) > 0 {
field = strings.Join(err.InstanceLocation, "/")
}
*errors = append(*errors, ConfigValidationError{
Field: field,
Message: err.Error(),
})
}
// HasConfigSchema returns true if the manifest defines a config schema.
func (m *Manifest) HasConfigSchema() bool {
return m.Config != nil && m.Config.Schema != nil
}

View File

@@ -1,186 +0,0 @@
//go:build !windows
package plugins
import (
"errors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Config Validation", func() {
Describe("ValidateConfig", func() {
Context("when manifest has no config schema", func() {
It("returns an error", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
}
err := ValidateConfig(manifest, `{"key": "value"}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no configurable options"))
})
})
Context("when manifest has config schema", func() {
var manifest *Manifest
BeforeEach(func() {
manifest = &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"apiKey": map[string]any{
"type": "string",
"description": "API key for the service",
"minLength": float64(1),
},
"timeout": map[string]any{
"type": "integer",
"minimum": float64(1),
"maximum": float64(300),
},
"enabled": map[string]any{
"type": "boolean",
},
},
"required": []any{"apiKey"},
},
},
}
})
It("accepts valid config", func() {
err := ValidateConfig(manifest, `{"apiKey": "secret123", "timeout": 30}`)
Expect(err).ToNot(HaveOccurred())
})
It("rejects empty config when required fields are missing", func() {
err := ValidateConfig(manifest, "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
err = ValidateConfig(manifest, "{}")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
})
It("rejects config missing required field", func() {
err := ValidateConfig(manifest, `{"timeout": 30}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
})
It("rejects config with wrong type", func() {
err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": "not a number"}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("timeout"))
})
It("rejects config with value out of range", func() {
err := ValidateConfig(manifest, `{"apiKey": "secret", "timeout": 500}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("timeout"))
})
It("rejects config with empty required string", func() {
err := ValidateConfig(manifest, `{"apiKey": ""}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("apiKey"))
})
It("rejects invalid JSON", func() {
err := ValidateConfig(manifest, `{invalid json}`)
Expect(err).To(HaveOccurred())
var validationErr *ConfigValidationErrors
Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors[0].Message).To(ContainSubstring("invalid JSON"))
})
})
Context("with enum values", func() {
It("accepts valid enum value", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"logLevel": map[string]any{
"type": "string",
"enum": []any{"debug", "info", "warn", "error"},
},
},
},
},
}
err := ValidateConfig(manifest, `{"logLevel": "info"}`)
Expect(err).ToNot(HaveOccurred())
})
It("rejects invalid enum value", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"logLevel": map[string]any{
"type": "string",
"enum": []any{"debug", "info", "warn", "error"},
},
},
},
},
}
err := ValidateConfig(manifest, `{"logLevel": "verbose"}`)
Expect(err).To(HaveOccurred())
})
})
})
Describe("HasConfigSchema", func() {
It("returns false when config is nil", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
}
Expect(manifest.HasConfigSchema()).To(BeFalse())
})
It("returns false when schema is nil", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{},
}
Expect(manifest.HasConfigSchema()).To(BeFalse())
})
It("returns true when schema is present", func() {
manifest := &Manifest{
Name: "test",
Author: "test",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
},
},
}
Expect(manifest.HasConfigSchema()).To(BeTrue())
})
})
})

View File

@@ -25,14 +25,6 @@ const (
// ID for the reconnection schedule
reconnectScheduleID = "crypto-ticker-reconnect"
// Config keys (must match manifest.json schema property names)
symbolsKey = "symbols"
reconnectDelayKey = "reconnectDelay"
logPricesKey = "logPrices"
// Default values
defaultReconnectDelay = 5
)
// CoinbaseSubscription message structure
@@ -82,67 +74,36 @@ var (
func (p *cryptoTickerPlugin) OnInit() error {
pdk.Log(pdk.LogInfo, "Crypto Ticker Plugin initializing...")
// Get ticker configuration from JSON schema config
symbols := getSymbols()
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured symbols: %v", symbols))
// Get ticker configuration
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH" // Default tickers
}
tickers := parseTickerSymbols(tickerConfig)
pdk.Log(pdk.LogInfo, fmt.Sprintf("Configured tickers: %v", tickers))
// Connect to WebSocket
// Errors won't fail init - reconnect logic will handle it
return connectAndSubscribe(symbols)
return connectAndSubscribe(tickers)
}
// getSymbols reads the symbols array from config
func getSymbols() []string {
defaultSymbols := []string{"BTC-USD"}
symbolsJSON, ok := pdk.GetConfig(symbolsKey)
if !ok || symbolsJSON == "" {
return defaultSymbols
}
var symbols []string
if err := json.Unmarshal([]byte(symbolsJSON), &symbols); err != nil {
pdk.Log(pdk.LogWarn, fmt.Sprintf("failed to parse symbols config: %v, using defaults", err))
return defaultSymbols
}
if len(symbols) == 0 {
return defaultSymbols
}
// Normalize symbols - add -USD suffix if not present
for i, s := range symbols {
s = strings.TrimSpace(s)
if !strings.Contains(s, "-") {
symbols[i] = s + "-USD"
} else {
symbols[i] = s
// parseTickerSymbols parses a comma-separated list of ticker symbols
func parseTickerSymbols(tickerConfig string) []string {
parts := strings.Split(tickerConfig, ",")
tickers := make([]string, 0, len(parts))
for _, ticker := range parts {
ticker = strings.TrimSpace(ticker)
if ticker == "" {
continue
}
// Add -USD suffix if not present
if !strings.Contains(ticker, "-") {
ticker = ticker + "-USD"
}
tickers = append(tickers, ticker)
}
return symbols
}
// getReconnectDelay reads the reconnect delay from config
func getReconnectDelay() int32 {
delayStr, ok := pdk.GetConfig(reconnectDelayKey)
if !ok || delayStr == "" {
return defaultReconnectDelay
}
var delay int
if _, err := fmt.Sscanf(delayStr, "%d", &delay); err != nil || delay < 1 {
return defaultReconnectDelay
}
return int32(delay)
}
// shouldLogPrices reads the logPrices setting from config
func shouldLogPrices() bool {
logStr, ok := pdk.GetConfig(logPricesKey)
if !ok || logStr == "" {
return false
}
return logStr == "true"
return tickers
}
// connectAndSubscribe connects to Coinbase WebSocket and subscribes to tickers
@@ -203,16 +164,14 @@ func (p *cryptoTickerPlugin) OnTextMessage(input websocket.OnTextMessageRequest)
// Calculate 24h change percentage
change := calculatePercentChange(ticker.Open24h, ticker.Price)
// Log ticker information (only if enabled in config)
if shouldLogPrices() {
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
}
// Log ticker information
pdk.Log(pdk.LogInfo, fmt.Sprintf("💰 %s: $%s (24h: %s%%) Bid: $%s Ask: $%s",
ticker.ProductID,
ticker.Price,
change,
ticker.BestBid,
ticker.BestAsk,
))
return nil
}
@@ -237,11 +196,10 @@ func (p *cryptoTickerPlugin) OnClose(input websocket.OnCloseRequest) error {
// Only attempt reconnect for our connection
if input.ConnectionID == connectionID {
delay := getReconnectDelay()
pdk.Log(pdk.LogInfo, fmt.Sprintf("Scheduling reconnection attempt in %d seconds...", delay))
pdk.Log(pdk.LogInfo, "Scheduling reconnection attempt in 5 seconds...")
// Schedule a one-time reconnection attempt
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(5, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule reconnection: %v", err))
}
@@ -260,16 +218,20 @@ func (p *cryptoTickerPlugin) OnCallback(input scheduler.SchedulerCallbackRequest
pdk.Log(pdk.LogInfo, "Attempting to reconnect to Coinbase WebSocket API...")
// Get ticker configuration
symbols := getSymbols()
tickerConfig, ok := pdk.GetConfig("tickers")
if !ok || tickerConfig == "" {
tickerConfig = "BTC,ETH"
}
tickers := parseTickerSymbols(tickerConfig)
// Try to connect and subscribe
err := connectAndSubscribe(symbols)
err := connectAndSubscribe(tickers)
if err != nil {
delay := getReconnectDelay() * 2 // Double delay on failure
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in %d seconds", err, delay))
pdk.Log(pdk.LogError, fmt.Sprintf("Reconnection failed: %v - will retry in 10 seconds", err))
// Schedule another attempt
_, err := host.SchedulerScheduleOneTime(delay, "reconnect", reconnectScheduleID)
_, err := host.SchedulerScheduleOneTime(10, "reconnect", reconnectScheduleID)
if err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("Failed to schedule retry: %v", err))
}

View File

@@ -4,61 +4,6 @@
"version": "1.0.0",
"description": "Real-time cryptocurrency price ticker using Coinbase WebSocket API",
"website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker",
"config": {
"schema": {
"type": "object",
"properties": {
"symbols": {
"type": "array",
"title": "Trading Pairs",
"description": "Cryptocurrency trading pairs to track (default: BTC-USD)",
"items": {
"type": "string",
"title": "Trading Pair",
"pattern": "^[A-Z]{3,5}-[A-Z]{3,5}$",
"description": "Trading pair in the format BASE-QUOTE (e.g., BTC-USD, ETH-USD)"
},
"default": ["BTC-USD"]
},
"reconnectDelay": {
"type": "integer",
"title": "Reconnect Delay",
"description": "Delay in seconds before attempting to reconnect after connection loss",
"default": 5,
"minimum": 1,
"maximum": 60
},
"logPrices": {
"type": "boolean",
"title": "Log Prices",
"description": "Whether to log price updates to the server log",
"default": false
}
}
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/symbols"
},
{
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/reconnectDelay"
},
{
"type": "Control",
"scope": "#/properties/logPrices"
}
]
}
]
}
},
"permissions": {
"config": {
"reason": "To read ticker symbols configuration"

View File

@@ -25,74 +25,5 @@
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
},
"config": {
"schema": {
"type": "object",
"properties": {
"clientid": {
"type": "string",
"title": "Discord Application Client ID",
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
"minLength": 17,
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"users": {
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Navidrome Username",
"description": "The Navidrome username to associate with this Discord token",
"minLength": 1
},
"token": {
"type": "string",
"title": "Discord Token",
"description": "The user's Discord token (keep this secret!)",
"minLength": 1
}
},
"required": ["username", "token"]
}
}
},
"required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/users",
"options": {
"elementLabelProp": "username",
"detail": {
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/username"
},
{
"type": "Control",
"scope": "#/properties/token"
}
]
}
}
}
]
}
}
}

View File

@@ -8,9 +8,12 @@
//!
//! ## Configuration
//!
//! Configure this plugin through the Navidrome UI with:
//! - Discord Application Client ID
//! - User tokens array mapping Navidrome usernames to Discord tokens
//! ```toml
//! [PluginConfig.discord-rich-presence-rs]
//! clientid = "YOUR_DISCORD_APPLICATION_ID"
//! "user.username1" = "discord_token1"
//! "user.username2" = "discord_token2"
//! ```
//!
//! **WARNING**: This plugin is for demonstration purposes only. Storing Discord tokens
//! in configuration files is not secure and may violate Discord's terms of service.
@@ -29,7 +32,6 @@ use nd_pdk::websocket::{
OnBinaryMessageRequest, OnCloseRequest, OnErrorRequest, OnTextMessageRequest,
TextMessageProvider,
};
use serde::Deserialize;
mod rpc;
@@ -46,7 +48,7 @@ nd_pdk::register_websocket_close!(DiscordPlugin);
// ============================================================================
const CLIENT_ID_KEY: &str = "clientid";
const USERS_KEY: &str = "users";
const USER_KEY_PREFIX: &str = "user.";
const PAYLOAD_HEARTBEAT: &str = "heartbeat";
const PAYLOAD_CLEAR_ACTIVITY: &str = "clear-activity";
@@ -62,31 +64,19 @@ struct DiscordPlugin;
// Configuration
// ============================================================================
/// User token entry from the config schema
#[derive(Debug, Deserialize)]
struct UserToken {
username: String,
token: String,
}
fn get_config() -> Result<(String, std::collections::HashMap<String, String>), Error> {
let client_id = config::get(CLIENT_ID_KEY)?
.filter(|s| !s.is_empty())
.ok_or_else(|| Error::msg("missing clientid in configuration"))?;
// Get users array from config (JSON format)
let users_json = config::get(USERS_KEY)?.unwrap_or_default();
// Get all user keys with the "user." prefix
let user_keys = config::keys(USER_KEY_PREFIX)?;
let mut users = std::collections::HashMap::new();
if !users_json.is_empty() {
// Parse JSON array of user tokens
let user_tokens: Vec<UserToken> = serde_json::from_str(&users_json)
.map_err(|e| Error::msg(format!("failed to parse users config: {}", e)))?;
for user_token in user_tokens {
if !user_token.username.is_empty() && !user_token.token.is_empty() {
users.insert(user_token.username, user_token.token);
}
for key in user_keys {
let username = key.strip_prefix(USER_KEY_PREFIX).unwrap_or(&key);
if let Some(token) = config::get(&key)?.filter(|s| !s.is_empty()) {
users.insert(username.to_string(), token);
}
}

View File

@@ -11,7 +11,6 @@
package main
import (
"encoding/json"
"fmt"
"strings"
"time"
@@ -25,16 +24,10 @@ import (
// Configuration keys
const (
clientIDKey = "clientid"
usersKey = "users"
clientIDKey = "clientid"
userKeyPrefix = "user."
)
// userToken represents a user-token mapping from the config
type userToken struct {
Username string `json:"username"`
Token string `json:"token"`
}
// discordPlugin implements the scrobbler and scheduler interfaces.
type discordPlugin struct{}
@@ -56,35 +49,24 @@ func getConfig() (clientID string, users map[string]string, err error) {
return "", nil, nil
}
// Get the users array from config
usersJSON, ok := pdk.GetConfig(usersKey)
if !ok || usersJSON == "" {
// Get all user keys with the "user." prefix
userKeys := host.ConfigKeys(userKeyPrefix)
if len(userKeys) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Parse the JSON array
var userTokens []userToken
if err := json.Unmarshal([]byte(usersJSON), &userTokens); err != nil {
pdk.Log(pdk.LogError, fmt.Sprintf("failed to parse users config: %v", err))
return clientID, nil, nil
}
if len(userTokens) == 0 {
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}
// Build the users map
users = make(map[string]string)
for _, ut := range userTokens {
if ut.Username != "" && ut.Token != "" {
users[ut.Username] = ut.Token
for _, key := range userKeys {
username := strings.TrimPrefix(key, userKeyPrefix)
token, exists := host.ConfigGet(key)
if exists && token != "" {
users[username] = token
}
}
if len(users) == 0 {
pdk.Log(pdk.LogWarn, "no valid users configured")
pdk.Log(pdk.LogWarn, "no users configured")
return clientID, nil, nil
}

View File

@@ -29,74 +29,5 @@
"artwork": {
"reason": "To get track artwork URLs for rich presence display"
}
},
"config": {
"schema": {
"type": "object",
"properties": {
"clientid": {
"type": "string",
"title": "Discord Application Client ID",
"description": "The Client ID from your Discord Developer Application. Create one at https://discord.com/developers/applications",
"minLength": 17,
"maxLength": 20,
"pattern": "^[0-9]+$"
},
"users": {
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Navidrome Username",
"description": "The Navidrome username to associate with this Discord token",
"minLength": 1
},
"token": {
"type": "string",
"title": "Discord Token",
"description": "The user's Discord token (keep this secret!)",
"minLength": 1
}
},
"required": ["username", "token"]
}
}
},
"required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/clientid"
},
{
"type": "Control",
"scope": "#/properties/users",
"options": {
"elementLabelProp": "username",
"detail": {
"type": "HorizontalLayout",
"elements": [
{
"type": "Control",
"scope": "#/properties/username"
},
{
"type": "Control",
"scope": "#/properties/token"
}
]
}
}
}
]
}
}
}

View File

@@ -20,105 +20,6 @@ import (
. "github.com/onsi/gomega"
)
// testConfigInput is the input for nd_test_config callback.
type testConfigInput struct {
Operation string `json:"operation"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
// testConfigOutput is the output from nd_test_config callback.
type testConfigOutput struct {
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
Keys []string `json:"keys,omitempty"`
Exists bool `json:"exists,omitempty"`
Error *string `json:"error,omitempty"`
}
// setupTestConfigPlugin sets up a test environment with the test-config plugin loaded.
// Returns a cleanup function and a helper to call the plugin's nd_test_config function.
func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, testConfigInput) (*testConfigOutput, error)) {
tmpDir, err := os.MkdirTemp("", "config-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-config plugin
srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-config"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Setup mock DataStore
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-config",
Path: destPath,
SHA256: hashHex,
Enabled: true,
AllUsers: true,
Config: configJSON,
}})
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
// Create and start manager
manager := &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
// Helper to call test plugin's exported function
callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-config"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_config", inputBytes)
if err != nil {
return nil, err
}
var output testConfigOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
return manager, callTestConfig
}
var _ = Describe("ConfigService", func() {
var service *configServiceImpl
var ctx context.Context
@@ -243,12 +144,59 @@ var _ = Describe("ConfigService", func() {
var _ = Describe("ConfigService Integration", Ordered, func() {
var (
manager *Manager
callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
manager *Manager
tmpDir string
)
BeforeAll(func() {
manager, callTestConfig = setupTestConfigPlugin(`{"api_key":"test_secret","max_retries":"5","timeout":"30"}`)
var err error
tmpDir, err = os.MkdirTemp("", "config-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-config plugin
srcPath := filepath.Join(testdataDir, "test-config"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-config"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Setup mock DataStore with pre-enabled plugin and config
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-config",
Path: destPath,
SHA256: hashHex,
Enabled: true,
Config: `{"api_key":"test_secret","max_retries":"5","timeout":"30"}`,
}})
dataStore := &tests.MockDataStore{MockedPlugin: mockPluginRepo}
// Create and start manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
Describe("Plugin Loading", func() {
@@ -257,11 +205,54 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
p, ok := manager.plugins["test-config"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
// Config service doesn't require permission, so Permissions can be nil
// Just verify the plugin loaded
Expect(p.manifest.Name).To(Equal("Test Config Plugin"))
})
})
Describe("Config Operations via Plugin", func() {
type testConfigInput struct {
Operation string `json:"operation"`
Key string `json:"key,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
type testConfigOutput struct {
StringVal string `json:"string_val,omitempty"`
IntVal int64 `json:"int_val,omitempty"`
Keys []string `json:"keys,omitempty"`
Exists bool `json:"exists,omitempty"`
Error *string `json:"error,omitempty"`
}
// Helper to call test plugin's exported function
callTestConfig := func(ctx context.Context, input testConfigInput) (*testConfigOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-config"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_config", inputBytes)
if err != nil {
return nil, err
}
var output testConfigOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
It("should get string value", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
@@ -294,7 +285,7 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
It("should return not exists for non-integer value", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get_int",
Key: "api_key",
Key: "api_key", // This is a string, not an integer
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeFalse())
@@ -319,64 +310,3 @@ var _ = Describe("ConfigService Integration", Ordered, func() {
})
})
})
var _ = Describe("Complex Config Values Integration", Ordered, func() {
var callTestConfig func(context.Context, testConfigInput) (*testConfigOutput, error)
BeforeAll(func() {
// Config with arrays and objects - these should be properly serialized as JSON strings
_, callTestConfig = setupTestConfigPlugin(`{"api_key":"secret123","users":[{"username":"admin","token":"tok1"},{"username":"user2","token":"tok2"}],"settings":{"enabled":true,"count":5}}`)
})
Describe("Config Serialization", func() {
It("should make simple string config values accessible to plugin", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
Key: "api_key",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
Expect(output.StringVal).To(Equal("secret123"))
})
It("should serialize array config values as JSON strings", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
Key: "users",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
// Array values are serialized as JSON strings - parse to verify structure
var users []map[string]string
Expect(json.Unmarshal([]byte(output.StringVal), &users)).To(Succeed())
Expect(users).To(HaveLen(2))
Expect(users[0]).To(HaveKeyWithValue("username", "admin"))
Expect(users[0]).To(HaveKeyWithValue("token", "tok1"))
Expect(users[1]).To(HaveKeyWithValue("username", "user2"))
Expect(users[1]).To(HaveKeyWithValue("token", "tok2"))
})
It("should serialize object config values as JSON strings", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "get",
Key: "settings",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Exists).To(BeTrue())
// Object values are serialized as JSON strings - parse to verify structure
var settings map[string]any
Expect(json.Unmarshal([]byte(output.StringVal), &settings)).To(Succeed())
Expect(settings).To(HaveKeyWithValue("enabled", true))
Expect(settings).To(HaveKeyWithValue("count", float64(5)))
})
It("should list all config keys including complex values", func() {
output, err := callTestConfig(GinkgoT().Context(), testConfigInput{
Operation: "list",
Prefix: "",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Keys).To(ConsistOf("api_key", "users", "settings"))
})
})
})

View File

@@ -381,30 +381,6 @@ func (m *Manager) DisablePlugin(ctx context.Context, id string) error {
return nil
}
// ValidatePluginConfig validates a config JSON string against the plugin's config schema.
// If the plugin has no config schema defined, it returns an error.
// Returns nil if validation passes, or an error describing the validation failure.
func (m *Manager) ValidatePluginConfig(ctx context.Context, id, configJSON string) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugin, err := repo.Get(id)
if err != nil {
return fmt.Errorf("getting plugin from DB: %w", err)
}
manifest, err := readManifest(plugin.Path)
if err != nil {
return fmt.Errorf("reading manifest: %w", err)
}
return ValidateConfig(manifest, configJSON)
}
// UpdatePluginConfig updates the configuration for a plugin.
// If the plugin is enabled, it will be reloaded with the new config.
func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string) error {

View File

@@ -230,9 +230,11 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
// Parse config from JSON
pluginConfig, err := parsePluginConfig(p.Config)
if err != nil {
return err
var pluginConfig map[string]string
if p.Config != "" {
if err := json.Unmarshal([]byte(p.Config), &pluginConfig); err != nil {
return fmt.Errorf("parsing plugin config: %w", err)
}
}
// Parse users from JSON
@@ -334,9 +336,8 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
}
extismConfig := extism.PluginConfig{
EnableWasi: true,
RuntimeConfig: runtimeConfig,
EnableHttpResponseHeaders: true,
EnableWasi: true,
RuntimeConfig: runtimeConfig,
}
compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, hostFunctions)
if err != nil {
@@ -378,30 +379,3 @@ func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
return nil
}
// parsePluginConfig parses a JSON config string into a map of string values.
// For Extism, all config values must be strings, so non-string values are serialized as JSON.
func parsePluginConfig(configJSON string) (map[string]string, error) {
if configJSON == "" {
return nil, nil
}
var rawConfig map[string]any
if err := json.Unmarshal([]byte(configJSON), &rawConfig); err != nil {
return nil, fmt.Errorf("parsing plugin config: %w", err)
}
pluginConfig := make(map[string]string)
for key, value := range rawConfig {
switch v := value.(type) {
case string:
pluginConfig[key] = v
default:
// Serialize non-string values as JSON
jsonBytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("serializing config value %q: %w", key, err)
}
pluginConfig[key] = string(jsonBytes)
}
}
return pluginConfig, nil
}

View File

@@ -1,60 +0,0 @@
//go:build !windows
package plugins
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("parsePluginConfig", func() {
It("returns nil for empty string", func() {
result, err := parsePluginConfig("")
Expect(err).ToNot(HaveOccurred())
Expect(result).To(BeNil())
})
It("serializes object values as JSON strings", func() {
result, err := parsePluginConfig(`{"settings": {"enabled": true, "count": 5}}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result["settings"]).To(Equal(`{"count":5,"enabled":true}`))
})
It("handles mixed value types", func() {
result, err := parsePluginConfig(`{"api_key": "secret", "timeout": 30, "rate": 1.5, "enabled": true, "tags": ["a", "b"]}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(5))
Expect(result["api_key"]).To(Equal("secret"))
Expect(result["timeout"]).To(Equal("30"))
Expect(result["rate"]).To(Equal("1.5"))
Expect(result["enabled"]).To(Equal("true"))
Expect(result["tags"]).To(Equal(`["a","b"]`))
})
It("returns error for invalid JSON", func() {
_, err := parsePluginConfig(`{invalid json}`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
})
It("returns error for non-object JSON", func() {
_, err := parsePluginConfig(`["array", "not", "object"]`)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("parsing plugin config"))
})
It("handles null values", func() {
result, err := parsePluginConfig(`{"key": null}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(1))
Expect(result["key"]).To(Equal("null"))
})
It("handles empty object", func() {
result, err := parsePluginConfig(`{}`)
Expect(err).ToNot(HaveOccurred())
Expect(result).To(HaveLen(0))
Expect(result).ToNot(BeNil())
})
})

View File

@@ -36,28 +36,9 @@
},
"experimental": {
"$ref": "#/$defs/Experimental"
},
"config": {
"$ref": "#/$defs/ConfigDefinition"
}
},
"$defs": {
"ConfigDefinition": {
"type": "object",
"description": "Configuration schema for the plugin using JSON Schema (draft-07) and optional JSONForms UI Schema",
"additionalProperties": false,
"required": ["schema"],
"properties": {
"schema": {
"type": "object",
"description": "JSON Schema (draft-07) defining the plugin's configuration options"
},
"uiSchema": {
"type": "object",
"description": "Optional JSONForms UI Schema for customizing form layout"
}
}
},
"Experimental": {
"type": "object",
"description": "Experimental features that may change or be removed in future versions",

View File

@@ -3,8 +3,6 @@ package plugins
import (
"encoding/json"
"fmt"
"github.com/santhosh-tekuri/jsonschema/v6"
)
//go:generate go tool go-jsonschema -p plugins --struct-name-from-title -o manifest_gen.go manifest-schema.json
@@ -31,26 +29,6 @@ func (m *Manifest) Validate() error {
return fmt.Errorf("'subsonicapi' permission requires 'users' permission to be declared")
}
}
// Validate config schema if present
if m.Config != nil && m.Config.Schema != nil {
if err := validateConfigSchema(m.Config.Schema); err != nil {
return fmt.Errorf("invalid config schema: %w", err)
}
}
return nil
}
// validateConfigSchema validates that the schema is a valid JSON Schema that can be compiled.
func validateConfigSchema(schema map[string]any) error {
compiler := jsonschema.NewCompiler()
if err := compiler.AddResource("schema.json", schema); err != nil {
return fmt.Errorf("invalid schema structure: %w", err)
}
if _, err := compiler.Compile("schema.json"); err != nil {
return err
}
return nil
}

View File

@@ -17,34 +17,6 @@ type CachePermission struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Configuration schema for the plugin using JSON Schema (draft-07) and optional
// JSONForms UI Schema
type ConfigDefinition struct {
// JSON Schema (draft-07) defining the plugin's configuration options
Schema map[string]interface{} `json:"schema" yaml:"schema" mapstructure:"schema"`
// Optional JSONForms UI Schema for customizing form layout
UiSchema map[string]interface{} `json:"uiSchema,omitempty" yaml:"uiSchema,omitempty" mapstructure:"uiSchema,omitempty"`
}
// UnmarshalJSON implements json.Unmarshaler.
func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
var raw map[string]interface{}
if err := json.Unmarshal(value, &raw); err != nil {
return err
}
if _, ok := raw["schema"]; raw != nil && !ok {
return fmt.Errorf("field schema in ConfigDefinition: required")
}
type Plain ConfigDefinition
var plain Plain
if err := json.Unmarshal(value, &plain); err != nil {
return err
}
*j = ConfigDefinition(plain)
return nil
}
// Configuration access permissions for a plugin
type ConfigPermission struct {
// Explanation for why config access is needed
@@ -109,9 +81,6 @@ type Manifest struct {
// The author of the plugin
Author string `json:"author" yaml:"author" mapstructure:"author"`
// Config corresponds to the JSON schema field "config".
Config *ConfigDefinition `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"`
// A brief description of what the plugin does
Description *string `json:"description,omitempty" yaml:"description,omitempty" mapstructure:"description,omitempty"`

View File

@@ -286,107 +286,6 @@ var _ = Describe("Manifest", func() {
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest with valid config schema", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"api_key": map[string]any{
"type": "string",
},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("validates manifest with complex config schema", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"users": map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"username": map[string]any{"type": "string"},
"token": map[string]any{"type": "string"},
},
"required": []any{"username", "token"},
},
},
},
},
},
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
It("returns error for invalid config schema - bad type", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "invalid_type",
},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("config schema"))
})
It("returns error for invalid config schema - bad minLength", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
Config: &ConfigDefinition{
Schema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"minLength": "not_a_number",
},
},
},
},
}
err := m.Validate()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("config schema"))
})
It("validates manifest without config", func() {
m := &Manifest{
Name: "Test",
Author: "Author",
Version: "1.0.0",
}
err := m.Validate()
Expect(err).ToNot(HaveOccurred())
})
})
Describe("ValidateWithCapabilities", func() {

View File

@@ -14,17 +14,14 @@ const CapabilityMetadataAgent Capability = "MetadataAgent"
// Export function names (snake_case as per design)
const (
FuncGetArtistMBID = "nd_get_artist_mbid"
FuncGetArtistURL = "nd_get_artist_url"
FuncGetArtistBiography = "nd_get_artist_biography"
FuncGetSimilarArtists = "nd_get_similar_artists"
FuncGetArtistImages = "nd_get_artist_images"
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
FuncGetAlbumInfo = "nd_get_album_info"
FuncGetAlbumImages = "nd_get_album_images"
FuncGetSimilarSongsByTrack = "nd_get_similar_songs_by_track"
FuncGetSimilarSongsByAlbum = "nd_get_similar_songs_by_album"
FuncGetSimilarSongsByArtist = "nd_get_similar_songs_by_artist"
FuncGetArtistMBID = "nd_get_artist_mbid"
FuncGetArtistURL = "nd_get_artist_url"
FuncGetArtistBiography = "nd_get_artist_biography"
FuncGetSimilarArtists = "nd_get_similar_artists"
FuncGetArtistImages = "nd_get_artist_images"
FuncGetArtistTopSongs = "nd_get_artist_top_songs"
FuncGetAlbumInfo = "nd_get_album_info"
FuncGetAlbumImages = "nd_get_album_images"
)
func init() {
@@ -38,9 +35,6 @@ func init() {
FuncGetArtistTopSongs,
FuncGetAlbumInfo,
FuncGetAlbumImages,
FuncGetSimilarSongsByTrack,
FuncGetSimilarSongsByAlbum,
FuncGetSimilarSongsByArtist,
)
}
@@ -153,7 +147,12 @@ func (a *MetadataAgent) GetArtistTopSongs(ctx context.Context, id, artistName, m
return nil, agents.ErrNotFound
}
return songRefsToAgentSongs(result.Songs), nil
songs := make([]agents.Song, len(result.Songs))
for i, s := range result.Songs {
songs[i] = agents.Song{ID: s.ID, Name: s.Name, MBID: s.MBID}
}
return songs, nil
}
// GetAlbumInfo retrieves album information
@@ -196,62 +195,15 @@ func (a *MetadataAgent) GetAlbumImages(ctx context.Context, name, artist, mbid s
return images, nil
}
func callSimilarSongsPluginFunction[T any](ctx context.Context, plugin *plugin, funcName string, input T) ([]agents.Song, error) {
result, err := callPluginFunction[T, *capabilities.SimilarSongsResponse](ctx, plugin, funcName, input)
if err != nil {
return nil, err
}
if result == nil || len(result.Songs) == 0 {
return nil, agents.ErrNotFound
}
return songRefsToAgentSongs(result.Songs), nil
}
// GetSimilarSongsByTrack retrieves songs similar to a specific track
func (a *MetadataAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByTrackRequest](ctx, a.plugin, FuncGetSimilarSongsByTrack, capabilities.SimilarSongsByTrackRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
}
// GetSimilarSongsByAlbum retrieves songs similar to tracks on an album
func (a *MetadataAgent) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByAlbumRequest](ctx, a.plugin, FuncGetSimilarSongsByAlbum, capabilities.SimilarSongsByAlbumRequest{ID: id, Name: name, Artist: artist, MBID: mbid, Count: int32(count)})
}
// GetSimilarSongsByArtist retrieves songs similar to an artist's catalog
func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid string, count int) ([]agents.Song, error) {
return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)})
}
// songRefsToAgentSongs converts a slice of SongRef to agents.Song
func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song {
songs := make([]agents.Song, len(refs))
for i, s := range refs {
songs[i] = agents.Song{
ID: s.ID,
Name: s.Name,
MBID: s.MBID,
Artist: s.Artist,
ArtistMBID: s.ArtistMBID,
Album: s.Album,
AlbumMBID: s.AlbumMBID,
Duration: uint32(s.Duration * 1000),
}
}
return songs
}
// Verify interface implementations at compile time
var (
_ agents.Interface = (*MetadataAgent)(nil)
_ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil)
_ agents.ArtistURLRetriever = (*MetadataAgent)(nil)
_ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil)
_ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil)
_ agents.ArtistImageRetriever = (*MetadataAgent)(nil)
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
_ agents.SimilarSongsByTrackRetriever = (*MetadataAgent)(nil)
_ agents.SimilarSongsByAlbumRetriever = (*MetadataAgent)(nil)
_ agents.SimilarSongsByArtistRetriever = (*MetadataAgent)(nil)
_ agents.Interface = (*MetadataAgent)(nil)
_ agents.ArtistMBIDRetriever = (*MetadataAgent)(nil)
_ agents.ArtistURLRetriever = (*MetadataAgent)(nil)
_ agents.ArtistBiographyRetriever = (*MetadataAgent)(nil)
_ agents.ArtistSimilarRetriever = (*MetadataAgent)(nil)
_ agents.ArtistImageRetriever = (*MetadataAgent)(nil)
_ agents.ArtistTopSongsRetriever = (*MetadataAgent)(nil)
_ agents.AlbumInfoRetriever = (*MetadataAgent)(nil)
_ agents.AlbumImageRetriever = (*MetadataAgent)(nil)
)

View File

@@ -108,37 +108,6 @@ var _ = Describe("MetadataAgent", Ordered, func() {
Expect(images[0].Size).To(Equal(500))
})
})
Describe("GetSimilarSongsByTrack", func() {
It("returns similar songs from the plugin", func() {
retriever := agent.(agents.SimilarSongsByTrackRetriever)
songs, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Yesterday", "The Beatles", "some-mbid", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
Expect(songs[0].Name).To(Equal("Similar to Yesterday #1"))
Expect(songs[0].Artist).To(Equal("The Beatles"))
})
})
Describe("GetSimilarSongsByAlbum", func() {
It("returns similar songs from the plugin", func() {
retriever := agent.(agents.SimilarSongsByAlbumRetriever)
songs, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Abbey Road", "The Beatles", "album-mbid", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
Expect(songs[0].Album).To(Equal("Abbey Road"))
})
})
Describe("GetSimilarSongsByArtist", func() {
It("returns similar songs from the plugin", func() {
retriever := agent.(agents.SimilarSongsByArtistRetriever)
songs, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "The Beatles", "some-mbid", 3)
Expect(err).ToNot(HaveOccurred())
Expect(songs).To(HaveLen(3))
Expect(songs[0].Name).To(ContainSubstring("The Beatles Style Song"))
})
})
})
var _ = Describe("MetadataAgent error handling", Ordered, func() {
@@ -217,27 +186,6 @@ var _ = Describe("MetadataAgent error handling", Ordered, func() {
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetSimilarSongsByTrack", func() {
retriever := errorAgent.(agents.SimilarSongsByTrackRetriever)
_, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetSimilarSongsByAlbum", func() {
retriever := errorAgent.(agents.SimilarSongsByAlbumRetriever)
_, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
It("returns error from GetSimilarSongsByArtist", func() {
retriever := errorAgent.(agents.SimilarSongsByArtistRetriever)
_, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("simulated plugin error"))
})
})
var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
@@ -307,23 +255,6 @@ var _ = Describe("MetadataAgent partial implementation", Ordered, func() {
retriever := partialAgent.(agents.AlbumImageRetriever)
_, err := retriever.GetAlbumImages(GinkgoT().Context(), "Album", "Artist", "mbid")
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByTrack)", func() {
retriever := partialAgent.(agents.SimilarSongsByTrackRetriever)
_, err := retriever.GetSimilarSongsByTrack(GinkgoT().Context(), "track-1", "Test", "Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByAlbum)", func() {
retriever := partialAgent.(agents.SimilarSongsByAlbumRetriever)
_, err := retriever.GetSimilarSongsByAlbum(GinkgoT().Context(), "album-1", "Album", "Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
It("returns ErrNotFound for unimplemented method (GetSimilarSongsByArtist)", func() {
retriever := partialAgent.(agents.SimilarSongsByArtistRetriever)
_, err := retriever.GetSimilarSongsByArtist(GinkgoT().Context(), "artist-1", "Artist", "mbid", 5)
Expect(err).To(MatchError(errNotImplemented))
})
})

View File

@@ -117,53 +117,7 @@ type SimilarArtistsResponse struct {
Artists []ArtistRef `json:"artists"`
}
// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
type SimilarSongsByAlbumRequest struct {
// ID is the internal Navidrome album ID.
ID string `json:"id"`
// Name is the album name.
Name string `json:"name"`
// Artist is the album artist name.
Artist string `json:"artist"`
// MBID is the MusicBrainz release ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
type SimilarSongsByArtistRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz artist ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
type SimilarSongsByTrackRequest struct {
// ID is the internal Navidrome mediafile ID.
ID string `json:"id"`
// Name is the track title.
Name string `json:"name"`
// Artist is the artist name.
Artist string `json:"artist"`
// MBID is the MusicBrainz recording ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
type SimilarSongsResponse struct {
// Songs is the list of similar songs.
Songs []SongRef `json:"songs"`
}
// SongRef is a reference to a song with metadata for matching.
// SongRef is a reference to a song with name and optional MBID.
type SongRef struct {
// ID is the internal Navidrome mediafile ID (if known).
ID string `json:"id,omitempty"`
@@ -171,16 +125,6 @@ type SongRef struct {
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
// Artist is the artist name.
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.
AlbumMBID string `json:"albumMbid,omitempty"`
// Duration is the song duration in seconds.
Duration float32 `json:"duration,omitempty"`
}
// TopSongsRequest is the request for GetArtistTopSongs.
@@ -249,34 +193,16 @@ type AlbumInfoProvider interface {
// AlbumImagesProvider provides the GetAlbumImages function.
type AlbumImagesProvider interface {
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
}
// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function.
type SimilarSongsByTrackProvider interface {
GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
}
// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function.
type SimilarSongsByAlbumProvider interface {
GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
}
// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function.
type SimilarSongsByArtistProvider interface {
GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
} // Internal implementation holders
var (
artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
artistURLImpl func(ArtistRequest) (*ArtistURLResponse, error)
artistBiographyImpl func(ArtistRequest) (*ArtistBiographyResponse, error)
similarArtistsImpl func(SimilarArtistsRequest) (*SimilarArtistsResponse, error)
artistImagesImpl func(ArtistRequest) (*ArtistImagesResponse, error)
artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error)
albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error)
albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error)
similarSongsByTrackImpl func(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
similarSongsByAlbumImpl func(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
similarSongsByArtistImpl func(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
artistMBIDImpl func(ArtistMBIDRequest) (*ArtistMBIDResponse, error)
artistURLImpl func(ArtistRequest) (*ArtistURLResponse, error)
artistBiographyImpl func(ArtistRequest) (*ArtistBiographyResponse, error)
similarArtistsImpl func(SimilarArtistsRequest) (*SimilarArtistsResponse, error)
artistImagesImpl func(ArtistRequest) (*ArtistImagesResponse, error)
artistTopSongsImpl func(TopSongsRequest) (*TopSongsResponse, error)
albumInfoImpl func(AlbumRequest) (*AlbumInfoResponse, error)
albumImagesImpl func(AlbumRequest) (*AlbumImagesResponse, error)
)
// Register registers a metadata implementation.
@@ -306,15 +232,6 @@ func Register(impl Metadata) {
if p, ok := impl.(AlbumImagesProvider); ok {
albumImagesImpl = p.GetAlbumImages
}
if p, ok := impl.(SimilarSongsByTrackProvider); ok {
similarSongsByTrackImpl = p.GetSimilarSongsByTrack
}
if p, ok := impl.(SimilarSongsByAlbumProvider); ok {
similarSongsByAlbumImpl = p.GetSimilarSongsByAlbum
}
if p, ok := impl.(SimilarSongsByArtistProvider); ok {
similarSongsByArtistImpl = p.GetSimilarSongsByArtist
}
}
// NotImplementedCode is the standard return code for unimplemented functions.
@@ -536,84 +453,3 @@ func _NdGetAlbumImages() int32 {
return 0
}
//go:wasmexport nd_get_similar_songs_by_track
func _NdGetSimilarSongsByTrack() int32 {
if similarSongsByTrackImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input SimilarSongsByTrackRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := similarSongsByTrackImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}
//go:wasmexport nd_get_similar_songs_by_album
func _NdGetSimilarSongsByAlbum() int32 {
if similarSongsByAlbumImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input SimilarSongsByAlbumRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := similarSongsByAlbumImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}
//go:wasmexport nd_get_similar_songs_by_artist
func _NdGetSimilarSongsByArtist() int32 {
if similarSongsByArtistImpl == nil {
// Return standard code - host will skip this plugin gracefully
return NotImplementedCode
}
var input SimilarSongsByArtistRequest
if err := pdk.InputJSON(&input); err != nil {
pdk.SetError(err)
return -1
}
output, err := similarSongsByArtistImpl(input)
if err != nil {
pdk.SetError(err)
return -1
}
if err := pdk.OutputJSON(output); err != nil {
pdk.SetError(err)
return -1
}
return 0
}

View File

@@ -114,53 +114,7 @@ type SimilarArtistsResponse struct {
Artists []ArtistRef `json:"artists"`
}
// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
type SimilarSongsByAlbumRequest struct {
// ID is the internal Navidrome album ID.
ID string `json:"id"`
// Name is the album name.
Name string `json:"name"`
// Artist is the album artist name.
Artist string `json:"artist"`
// MBID is the MusicBrainz release ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
type SimilarSongsByArtistRequest struct {
// ID is the internal Navidrome artist ID.
ID string `json:"id"`
// Name is the artist name.
Name string `json:"name"`
// MBID is the MusicBrainz artist ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
type SimilarSongsByTrackRequest struct {
// ID is the internal Navidrome mediafile ID.
ID string `json:"id"`
// Name is the track title.
Name string `json:"name"`
// Artist is the artist name.
Artist string `json:"artist"`
// MBID is the MusicBrainz recording ID (if known).
MBID string `json:"mbid,omitempty"`
// Count is the maximum number of similar songs to return.
Count int32 `json:"count"`
}
// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
type SimilarSongsResponse struct {
// Songs is the list of similar songs.
Songs []SongRef `json:"songs"`
}
// SongRef is a reference to a song with metadata for matching.
// SongRef is a reference to a song with name and optional MBID.
type SongRef struct {
// ID is the internal Navidrome mediafile ID (if known).
ID string `json:"id,omitempty"`
@@ -168,16 +122,6 @@ type SongRef struct {
Name string `json:"name"`
// MBID is the MusicBrainz ID for the song.
MBID string `json:"mbid,omitempty"`
// Artist is the artist name.
Artist string `json:"artist,omitempty"`
// ArtistMBID is the MusicBrainz artist ID.
ArtistMBID string `json:"artistMbid,omitempty"`
// Album is the album name.
Album string `json:"album,omitempty"`
// AlbumMBID is the MusicBrainz release ID.
AlbumMBID string `json:"albumMbid,omitempty"`
// Duration is the song duration in seconds.
Duration float32 `json:"duration,omitempty"`
}
// TopSongsRequest is the request for GetArtistTopSongs.
@@ -248,21 +192,6 @@ type AlbumImagesProvider interface {
GetAlbumImages(AlbumRequest) (*AlbumImagesResponse, error)
}
// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function.
type SimilarSongsByTrackProvider interface {
GetSimilarSongsByTrack(SimilarSongsByTrackRequest) (*SimilarSongsResponse, error)
}
// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function.
type SimilarSongsByAlbumProvider interface {
GetSimilarSongsByAlbum(SimilarSongsByAlbumRequest) (*SimilarSongsResponse, error)
}
// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function.
type SimilarSongsByArtistProvider interface {
GetSimilarSongsByArtist(SimilarSongsByArtistRequest) (*SimilarSongsResponse, error)
}
// NotImplementedCode is the standard return code for unimplemented functions.
const NotImplementedCode int32 = -2

View File

@@ -4,20 +4,6 @@
// It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize};
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
/// AlbumImagesResponse is the response for GetAlbumImages.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -164,72 +150,7 @@ pub struct SimilarArtistsResponse {
#[serde(default)]
pub artists: Vec<ArtistRef>,
}
/// SimilarSongsByAlbumRequest is the request for GetSimilarSongsByAlbum.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimilarSongsByAlbumRequest {
/// ID is the internal Navidrome album ID.
#[serde(default)]
pub id: String,
/// Name is the album name.
#[serde(default)]
pub name: String,
/// Artist is the album artist name.
#[serde(default)]
pub artist: String,
/// MBID is the MusicBrainz release ID (if known).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbid: String,
/// Count is the maximum number of similar songs to return.
#[serde(default)]
pub count: i32,
}
/// SimilarSongsByArtistRequest is the request for GetSimilarSongsByArtist.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimilarSongsByArtistRequest {
/// ID is the internal Navidrome artist ID.
#[serde(default)]
pub id: String,
/// Name is the artist name.
#[serde(default)]
pub name: String,
/// MBID is the MusicBrainz artist ID (if known).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbid: String,
/// Count is the maximum number of similar songs to return.
#[serde(default)]
pub count: i32,
}
/// SimilarSongsByTrackRequest is the request for GetSimilarSongsByTrack.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimilarSongsByTrackRequest {
/// ID is the internal Navidrome mediafile ID.
#[serde(default)]
pub id: String,
/// Name is the track title.
#[serde(default)]
pub name: String,
/// Artist is the artist name.
#[serde(default)]
pub artist: String,
/// MBID is the MusicBrainz recording ID (if known).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbid: String,
/// Count is the maximum number of similar songs to return.
#[serde(default)]
pub count: i32,
}
/// SimilarSongsResponse is the response for GetSimilarSongsBy* functions.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SimilarSongsResponse {
/// Songs is the list of similar songs.
#[serde(default)]
pub songs: Vec<SongRef>,
}
/// SongRef is a reference to a song with metadata for matching.
/// SongRef is a reference to a song with name and optional MBID.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SongRef {
@@ -242,21 +163,6 @@ pub struct SongRef {
/// MBID is the MusicBrainz ID for the song.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub mbid: String,
/// Artist is the artist name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist: String,
/// ArtistMBID is the MusicBrainz artist ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub artist_mbid: String,
/// Album is the album name.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album: String,
/// AlbumMBID is the MusicBrainz release ID.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub album_mbid: String,
/// Duration is the song duration in seconds.
#[serde(default, skip_serializing_if = "is_zero_f32")]
pub duration: f32,
}
/// TopSongsRequest is the request for GetArtistTopSongs.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -471,66 +377,3 @@ macro_rules! register_metadata_album_images {
}
};
}
/// SimilarSongsByTrackProvider provides the GetSimilarSongsByTrack function.
pub trait SimilarSongsByTrackProvider {
fn get_similar_songs_by_track(&self, req: SimilarSongsByTrackRequest) -> Result<SimilarSongsResponse, Error>;
}
/// Register the get_similar_songs_by_track export.
/// This macro generates the WASM export function for this method.
#[macro_export]
macro_rules! register_metadata_similar_songs_by_track {
($plugin_type:ty) => {
#[extism_pdk::plugin_fn]
pub fn nd_get_similar_songs_by_track(
req: extism_pdk::Json<$crate::metadata::SimilarSongsByTrackRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::metadata::SimilarSongsByTrackProvider::get_similar_songs_by_track(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
};
}
/// SimilarSongsByAlbumProvider provides the GetSimilarSongsByAlbum function.
pub trait SimilarSongsByAlbumProvider {
fn get_similar_songs_by_album(&self, req: SimilarSongsByAlbumRequest) -> Result<SimilarSongsResponse, Error>;
}
/// Register the get_similar_songs_by_album export.
/// This macro generates the WASM export function for this method.
#[macro_export]
macro_rules! register_metadata_similar_songs_by_album {
($plugin_type:ty) => {
#[extism_pdk::plugin_fn]
pub fn nd_get_similar_songs_by_album(
req: extism_pdk::Json<$crate::metadata::SimilarSongsByAlbumRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::metadata::SimilarSongsByAlbumProvider::get_similar_songs_by_album(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
};
}
/// SimilarSongsByArtistProvider provides the GetSimilarSongsByArtist function.
pub trait SimilarSongsByArtistProvider {
fn get_similar_songs_by_artist(&self, req: SimilarSongsByArtistRequest) -> Result<SimilarSongsResponse, Error>;
}
/// Register the get_similar_songs_by_artist export.
/// This macro generates the WASM export function for this method.
#[macro_export]
macro_rules! register_metadata_similar_songs_by_artist {
($plugin_type:ty) => {
#[extism_pdk::plugin_fn]
pub fn nd_get_similar_songs_by_artist(
req: extism_pdk::Json<$crate::metadata::SimilarSongsByArtistRequest>
) -> extism_pdk::FnResult<extism_pdk::Json<$crate::metadata::SimilarSongsResponse>> {
let plugin = <$plugin_type>::default();
let result = $crate::metadata::SimilarSongsByArtistProvider::get_similar_songs_by_artist(&plugin, req.into_inner())?;
Ok(extism_pdk::Json(result))
}
};
}

View File

@@ -4,20 +4,6 @@
// It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize};
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
/// SchedulerCallbackRequest is the request provided when a scheduled task fires.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@@ -4,20 +4,6 @@
// It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize};
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
/// ScrobblerError represents an error type for scrobbling operations.
pub type ScrobblerError = &'static str;
/// ScrobblerErrorNotAuthorized indicates the user is not authorized.

View File

@@ -4,20 +4,6 @@
// It is intended for use in Navidrome plugins built with extism-pdk.
use serde::{Deserialize, Serialize};
// Helper functions for skip_serializing_if with numeric types
#[allow(dead_code)]
fn is_zero_i32(value: &i32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u32(value: &u32) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_i64(value: &i64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_u64(value: &u64) -> bool { *value == 0 }
#[allow(dead_code)]
fn is_zero_f32(value: &f32) -> bool { *value == 0.0 }
#[allow(dead_code)]
fn is_zero_f64(value: &f64) -> bool { *value == 0.0 }
/// OnBinaryMessageRequest is the request provided when a binary message is received.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@@ -2,60 +2,5 @@
"name": "Test Config Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test plugin for config service integration testing",
"config": {
"schema": {
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"minLength": 1
},
"max_retries": {
"type": "string",
"title": "Max Retries"
},
"timeout": {
"type": "string",
"title": "Timeout"
},
"users": {
"type": "array",
"title": "Users",
"items": {
"type": "object",
"properties": {
"username": {
"type": "string",
"title": "Username",
"minLength": 1
},
"token": {
"type": "string",
"title": "Token",
"minLength": 1
}
},
"required": ["username", "token"]
}
},
"settings": {
"type": "object",
"title": "Settings",
"properties": {
"enabled": {
"type": "boolean",
"title": "Enabled"
},
"count": {
"type": "integer",
"title": "Count"
}
}
}
},
"required": ["api_key"]
}
}
"description": "A test plugin for config service integration testing"
}

View File

@@ -120,64 +120,4 @@ func (t *testMetadataAgent) GetAlbumImages(input metadata.AlbumRequest) (*metada
}, nil
}
func (t *testMetadataAgent) GetSimilarSongsByTrack(input metadata.SimilarSongsByTrackRequest) (*metadata.SimilarSongsResponse, error) {
if err := checkConfigError(); err != nil {
return nil, err
}
count := int(input.Count)
if count == 0 {
count = 5
}
songs := make([]metadata.SongRef, 0, count)
for i := range count {
songs = append(songs, metadata.SongRef{
ID: "similar-track-id-" + strconv.Itoa(i+1),
Name: "Similar to " + input.Name + " #" + strconv.Itoa(i+1),
MBID: "similar-mbid-" + strconv.Itoa(i+1),
Artist: input.Artist,
ArtistMBID: "artist-mbid-" + strconv.Itoa(i+1),
})
}
return &metadata.SimilarSongsResponse{Songs: songs}, nil
}
func (t *testMetadataAgent) GetSimilarSongsByAlbum(input metadata.SimilarSongsByAlbumRequest) (*metadata.SimilarSongsResponse, error) {
if err := checkConfigError(); err != nil {
return nil, err
}
count := int(input.Count)
if count == 0 {
count = 5
}
songs := make([]metadata.SongRef, 0, count)
for i := range count {
songs = append(songs, metadata.SongRef{
ID: "album-similar-id-" + strconv.Itoa(i+1),
Name: "Album Similar #" + strconv.Itoa(i+1),
Artist: input.Artist,
Album: input.Name,
})
}
return &metadata.SimilarSongsResponse{Songs: songs}, nil
}
func (t *testMetadataAgent) GetSimilarSongsByArtist(input metadata.SimilarSongsByArtistRequest) (*metadata.SimilarSongsResponse, error) {
if err := checkConfigError(); err != nil {
return nil, err
}
count := int(input.Count)
if count == 0 {
count = 5
}
songs := make([]metadata.SongRef, 0, count)
for i := range count {
songs = append(songs, metadata.SongRef{
ID: "artist-similar-id-" + strconv.Itoa(i+1),
Name: input.Name + " Style Song #" + strconv.Itoa(i+1),
Artist: input.Name + " Similar Artist",
})
}
return &metadata.SimilarSongsResponse{Songs: songs}, nil
}
func main() {}

View File

@@ -12,16 +12,12 @@
"artist": "Artista",
"album": "Álbum",
"path": "Ruta del archivo",
"libraryName": "Biblioteca",
"genre": "Género",
"compilation": "Compilación",
"year": "Año",
"size": "Tamaño del archivo",
"updatedAt": "Actualizado el",
"bitRate": "Tasa de bits",
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
"channels": "Canales",
"discSubtitle": "Subtítulo del disco",
"starred": "Favorito",
"comment": "Comentario",
@@ -29,6 +25,7 @@
"quality": "Calidad",
"bpm": "BPM",
"playDate": "Últimas reproducciones",
"channels": "Canales",
"createdAt": "Creado el",
"grouping": "Agrupación",
"mood": "Estado de ánimo",
@@ -36,17 +33,20 @@
"tags": "Etiquetas",
"mappedTags": "Etiquetas asignadas",
"rawTags": "Etiquetas sin procesar",
"missing": "Faltante"
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
"missing": "Faltante",
"libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Reproducir después",
"playNow": "Reproducir ahora",
"addToPlaylist": "Agregar a la playlist",
"showInPlaylist": "Mostrar en la lista de reproducción",
"shuffleAll": "Todas aleatorias",
"download": "Descarga",
"playNext": "Siguiente",
"info": "Obtener información"
"info": "Obtener información",
"showInPlaylist": "Mostrar en la lista de reproducción"
}
},
"album": {
@@ -57,38 +57,38 @@
"duration": "Duración",
"songCount": "Canciones",
"playCount": "Reproducciones",
"size": "Tamaño del archivo",
"name": "Nombre",
"libraryName": "Biblioteca",
"genre": "Género",
"compilation": "Compilación",
"year": "Año",
"date": "Fecha de grabación",
"originalDate": "Original",
"releaseDate": "Publicado",
"releases": "Lanzamiento |||| Lanzamientos",
"released": "Publicado",
"updatedAt": "Actualizado el",
"comment": "Comentario",
"rating": "Calificación",
"createdAt": "Creado el",
"size": "Tamaño del archivo",
"originalDate": "Original",
"releaseDate": "Publicado",
"releases": "Lanzamiento |||| Lanzamientos",
"released": "Publicado",
"recordLabel": "Discográfica",
"catalogNum": "Número de catálogo",
"releaseType": "Tipo de lanzamiento",
"grouping": "Agrupación",
"media": "Medios",
"mood": "Estado de ánimo",
"missing": "Faltante"
"date": "Fecha de grabación",
"missing": "Faltante",
"libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
"playNext": "Reproducir siguiente",
"addToQueue": "Reproducir después",
"share": "Compartir",
"shuffle": "Aleatorio",
"addToPlaylist": "Agregar a la lista",
"download": "Descargar",
"info": "Obtener información"
"info": "Obtener información",
"share": "Compartir"
},
"lists": {
"all": "Todos",
@@ -106,33 +106,33 @@
"name": "Nombre",
"albumCount": "Número de álbumes",
"songCount": "Número de canciones",
"size": "Tamaño",
"playCount": "Reproducciones",
"rating": "Calificación",
"genre": "Género",
"size": "Tamaño",
"role": "Rol",
"missing": "Faltante"
},
"roles": {
"albumartist": "Artista del álbum |||| Artistas del álbum",
"artist": "Artista |||| Artistas",
"composer": "Compositor |||| Compositores",
"conductor": "Director de orquesta |||| Directores de orquesta",
"lyricist": "Letrista |||| Letristas",
"arranger": "Arreglista |||| Arreglistas",
"producer": "Productor |||| Productores",
"director": "Director |||| Directores",
"engineer": "Ingeniero de sonido |||| Ingenieros de sonido",
"mixer": "Mezclador |||| Mezcladores",
"remixer": "Remezclador |||| Remezcladores",
"djmixer": "DJ Mezclador |||| DJ Mezcladores",
"performer": "Intérprete |||| Intérpretes",
"albumartist": "Artista del álbum",
"artist": "Artista",
"composer": "Compositor",
"conductor": "Director de orquesta",
"lyricist": "Letrista",
"arranger": "Arreglista",
"producer": "Productor",
"director": "Director",
"engineer": "Ingeniero de sonido",
"mixer": "Mezclador",
"remixer": "Remixer",
"djmixer": "DJ Mixer",
"performer": "Intérprete",
"maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
},
"actions": {
"topSongs": "Más destacadas",
"shuffle": "Aleatorio",
"radio": "Radio"
"radio": "Radio",
"topSongs": "Más destacadas"
}
},
"user": {
@@ -141,7 +141,6 @@
"userName": "Nombre de usuario",
"isAdmin": "Es administrador",
"lastLoginAt": "Último inicio de sesión",
"lastAccessAt": "Último acceso",
"updatedAt": "Actualizado el",
"name": "Nombre",
"password": "Contraseña",
@@ -150,6 +149,7 @@
"currentPassword": "Contraseña actual",
"newPassword": "Nueva contraseña",
"token": "Token",
"lastAccessAt": "Último acceso",
"libraries": "Bibliotecas"
},
"helperTexts": {
@@ -189,7 +189,7 @@
"fields": {
"name": "Nombre",
"targetFormat": "Formato de destino",
"defaultBitRate": "Tasa de bits por defecto",
"defaultBitRate": "Tasa de bits default",
"command": "Comando"
}
},
@@ -211,9 +211,9 @@
"selectPlaylist": "Seleccione una lista:",
"addNewPlaylist": "Creada \"%{name}\"",
"export": "Exportar",
"saveQueue": "Guardar la fila de reproducción en una playlist",
"makePublic": "Hazla pública",
"makePrivate": "Hazla privada",
"saveQueue": "Guardar la fila de reproducción en una playlist",
"searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…",
"pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción",
"removeFromSelection": "Quitar de la selección"
@@ -239,12 +239,11 @@
}
},
"share": {
"name": "Compartir |||| Compartidos",
"name": "Compartir",
"fields": {
"username": "Compartido por",
"username": "Nombre de usuario",
"url": "URL",
"description": "Descripción",
"downloadable": "¿Permitir descargas?",
"contents": "Contenido",
"expiresAt": "Caduca el",
"lastVisitedAt": "Visitado por última vez el",
@@ -252,14 +251,12 @@
"format": "Formato",
"maxBitRate": "Tasa de bits Máx.",
"updatedAt": "Actualizado el",
"createdAt": "Creado el"
},
"notifications": {},
"actions": {}
"createdAt": "Creado el",
"downloadable": "¿Permitir descargas?"
}
},
"missing": {
"name": "Fichero faltante |||| Ficheros faltantes",
"empty": "No faltan archivos",
"name": "Faltante",
"fields": {
"path": "Ruta",
"size": "Tamaño",
@@ -272,7 +269,8 @@
},
"notifications": {
"removed": "Eliminado"
}
},
"empty": "No hay archivos perdidos"
},
"library": {
"name": "Biblioteca |||| Bibliotecas",
@@ -292,7 +290,7 @@
"totalMissingFiles": "Archivos faltantes",
"totalSize": "Tamaño total",
"totalDuration": "Duración",
"defaultNewUsers": "Por defecto para nuevos usuarios",
"defaultNewUsers": "Valor por defecto para los nuevos usuarios",
"createdAt": "Creado",
"updatedAt": "Actualizado"
},
@@ -302,20 +300,20 @@
},
"actions": {
"scan": "Escanear biblioteca",
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"manageUsers": "Gestionar el acceso de usarios",
"viewDetails": "Ver detalles"
"viewDetails": "Ver detalles",
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo"
},
"notifications": {
"created": "La biblioteca se creó correctamente",
"updated": "La biblioteca se actualizó correctamente",
"deleted": "La biblioteca se eliminó correctamente",
"scanStarted": "El escaneo de la biblioteca ha comenzado",
"scanCompleted": "El escaneo de la biblioteca se completó",
"quickScanStarted": "Escaneo rápido ha comenzado",
"fullScanStarted": "Escaneo completo ha comenzado",
"scanError": "Error al iniciar el escaneo. Revisa los registros",
"scanCompleted": "El escaneo de la biblioteca se completó"
"scanError": "Error al iniciar el escaneo. Revisa los registros"
},
"validation": {
"nameRequired": "El nombre de la biblioteca es obligatorio",
@@ -330,78 +328,6 @@
"scanInProgress": "Escaneo en curso...",
"noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario"
}
},
"plugin": {
"name": "Plugin |||| Plugins",
"fields": {
"id": "ID",
"name": "Nombre",
"description": "Descripción",
"version": "Versión",
"author": "Autor",
"website": "Web",
"permissions": "Permisos",
"enabled": "Activado",
"status": "Estado",
"path": "Ruta",
"lastError": "Error",
"hasError": "Error",
"updatedAt": "Actualizado",
"createdAt": "Instalado",
"configKey": "Clave",
"configValue": "Valor",
"allUsers": "Permitir todos los usuarios",
"selectedUsers": "Usuarios seleccionados",
"allLibraries": "Permitir todas las bibliotecas",
"selectedLibraries": "Bibliotecas seleccionadas"
},
"sections": {
"status": "Estado",
"info": "Información del Plugin",
"configuration": "Configuración",
"manifest": "Manifiesto",
"usersPermission": "Permiso del usuario",
"libraryPermission": "Permiso de la biblioteca"
},
"status": {
"enabled": "Activado",
"disabled": "Deshabilitado"
},
"actions": {
"enable": "Activar",
"disable": "Desactivar",
"disabledDueToError": "Corrige el error antes de activar",
"disabledUsersRequired": "Selecciona usuarios antes de activar",
"disabledLibrariesRequired": "Selecciona bibliotecas antes de activar",
"addConfig": "Añadir configuración",
"rescan": "Reescanear"
},
"notifications": {
"enabled": "Plugin activado",
"disabled": "Plugin deshabilitado",
"updated": "Plugin actualizado",
"error": "Error al actualizar el plugin"
},
"validation": {
"invalidJson": "La configuración debe ser un JSON válido"
},
"messages": {
"configHelp": "Configura el plugin utilizando pares de clave-valor. Déjalo en blanco si el plugin no requiere configuración.",
"clickPermissions": "Haz clic en un permiso para ver los detalles",
"noConfig": "No hay configuración establecida",
"allUsersHelp": "Cuando se active, el plugin tendrá acceso a todos los usuarios, incluidos los que se creen en el futuro.",
"noUsers": "Ningún usuario seleccionado",
"permissionReason": "Razón",
"usersRequired": "Este plugin requiere acceso a la información de los usuarios. Selecciona a qué usuarios puede acceder el plugin, o activa 'Permitir todos los usuarios'.",
"allLibrariesHelp": "Cuando se active, el plugin tendrá acceso a todas las bibliotecas, incluidas las que se creen en el futuro.",
"noLibraries": "Ninguna biblioteca seleccionada",
"librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.",
"requiredHosts": "Hosts requeridos"
},
"placeholders": {
"configKey": "clave",
"configValue": "valor"
}
}
},
"ra": {
@@ -439,7 +365,6 @@
"add": "Añadir",
"back": "Ir atrás",
"bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Cancelar",
"clear_input_value": "Limpiar valor",
"clone": "Duplicar",
@@ -463,6 +388,7 @@
"close_menu": "Cerrar menú",
"unselect": "Deseleccionado",
"skip": "Omitir",
"bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Compartir",
"download": "Descargar"
},
@@ -554,47 +480,41 @@
"transcodingDisabled": "Cambiar la configuración de la transcodificación a través de la interfaz web esta deshabilitado por motivos de seguridad. Si quieres cambiar (editar o agregar) opciones de transcodificación, reinicia el servidor con la %{config} opción de configuración.",
"transcodingEnabled": "Navidrom se esta ejecutando con %{config}, lo que hace posible ejecutar comandos de sistema desde el apartado de transcodificación en la interfaz web. Recomendamos deshabilitarlo por motivos de seguridad y solo habilitarlo cuando se este configurando opciones de transcodificación.",
"songsAddedToPlaylist": "1 canción agregada a la lista |||| %{smart_count} canciones agregadas a la lista",
"noSimilarSongsFound": "No se encontraron canciones similares",
"noTopSongsFound": "No se encontraron canciones destacadas",
"noPlaylistsAvailable": "Ninguna lista disponible",
"delete_user_title": "Eliminar usuario '%{name}'",
"delete_user_content": "¿Esta seguro de eliminar a este usuario y todos sus datos (incluyendo listas y preferencias)?",
"remove_missing_title": "Eliminar archivos faltantes",
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"remove_all_missing_title": "Eliminar todos los archivos faltantes",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"notifications_blocked": "Las notificaciones de este sitio están bloqueadas en tu navegador",
"notifications_not_available": "Este navegador no soporta notificaciones o no ingresaste a Navidrome usando https",
"lastfmLinkSuccess": "Last.fm esta conectado y el scrobbling esta activado",
"lastfmLinkFailure": "No se pudo conectar con Last.fm",
"lastfmUnlinkSuccess": "Last.fm se ha desconectado y el scrobbling se desactivo",
"lastfmUnlinkFailure": "No se pudo desconectar Last.fm",
"listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}",
"listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling",
"listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz",
"openIn": {
"lastfm": "Ver en Last.fm",
"musicbrainz": "Ver en MusicBrainz"
},
"lastfmLink": "Leer más...",
"listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activo el scrobbling como el usuario: %{user}",
"listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}",
"listenBrainzUnlinkSuccess": "Se desconecto ListenBrainz y se desactivo el scrobbling",
"listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz",
"downloadOriginalFormat": "Descargar formato original",
"shareOriginalFormat": "Compartir formato original",
"shareDialogTitle": "Compartir %{resource} '%{name}'",
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
"shareSuccess": "URL copiada al portapapeles: %{url}",
"shareFailure": "Error al copiar la URL %{url} al portapapeles",
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"downloadOriginalFormat": "Descargar formato original"
"shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
"remove_missing_title": "Eliminar elemento faltante",
"remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"noSimilarSongsFound": "No se encontraron canciones similares",
"noTopSongsFound": "No se encontraron canciones destacadas"
},
"menu": {
"library": "Biblioteca",
"librarySelector": {
"allLibraries": "Todas las bibliotecas (%{count})",
"multipleLibraries": "%{selected} de %{total} bibliotecas",
"selectLibraries": "Seleccionar bibliotecas",
"none": "Ninguno"
},
"settings": "Ajustes",
"version": "Versión",
"theme": "Tema",
@@ -605,22 +525,28 @@
"language": "Idioma",
"defaultView": "Vista por defecto",
"desktop_notifications": "Notificaciones de escritorio",
"lastfmNotConfigured": "La clave API de Last.fm no está configurada",
"lastfmScrobbling": "Scrobble a Last.fm",
"listenBrainzScrobbling": "Scrobble a ListenBrainz",
"replaygain": "Modo de ReplayGain",
"preAmp": "ReplayGain PreAmp (dB)",
"gain": {
"none": "Desactivado",
"album": "Ganancia del álbum",
"track": "Ganancia de pista"
}
"none": "Ninguno",
"album": "Álbum",
"track": "Pista"
},
"lastfmNotConfigured": "La clave API de Last.fm no está configurada"
}
},
"albumList": "Álbumes",
"about": "Acerca de",
"playlists": "Playlists",
"sharedPlaylists": "Playlists Compartidas",
"about": "Acerca de"
"librarySelector": {
"allLibraries": "Todas las bibliotecas (%{count})",
"multipleLibraries": "%{selected} de %{total} bibliotecas",
"selectLibraries": "Seleccionar bibliotecas",
"none": "Ninguno"
}
},
"player": {
"playListsText": "Fila de reproducción",
@@ -679,17 +605,12 @@
"totalScanned": "Total de carpetas escaneadas",
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"selectiveScan": "Selectivo",
"serverUptime": "Uptime del servidor",
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
"elapsedTime": "Tiempo transcurrido"
},
"nowPlaying": {
"title": "En reproducción",
"empty": "Nada en reproducción",
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
"elapsedTime": "Tiempo transcurrido",
"selectiveScan": "Selectivo"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -699,10 +620,15 @@
"toggle_play": "Reproducir / Pausar",
"prev_song": "Canción anterior",
"next_song": "Siguiente canción",
"current_song": "Canción actual",
"vol_up": "Subir volumen",
"vol_down": "Bajar volumen",
"toggle_love": "Marca esta canción como favorita"
"toggle_love": "Marca esta canción como favorita",
"current_song": "Canción actual"
}
},
"nowPlaying": {
"title": "En reproducción",
"empty": "Nada en reproducción",
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
}
}

View File

@@ -46,8 +46,7 @@
"download": "Baixar",
"playNext": "Toca a seguir",
"info": "Detalhes",
"showInPlaylist": "Ir para playlist",
"instantMix": "Mix Instantâneo"
"showInPlaylist": "Ir para playlist"
}
},
"album": {
@@ -388,8 +387,6 @@
},
"messages": {
"configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.",
"configValidationError": "Falha na validação da configuração:",
"schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.",
"clickPermissions": "Clique em uma permissão para ver detalhes",
"noConfig": "Nenhuma configuração definida",
"allUsersHelp": "Quando habilitado, o plugin terá acesso a todos os usuários, incluindo os criados no futuro.",
@@ -558,7 +555,6 @@
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
"songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist",
"noSimilarSongsFound": "Nenhuma música semelhante encontrada",
"startingInstantMix": "Carregando Mix Instantâneo...",
"noTopSongsFound": "Nenhuma música mais tocada encontrada",
"noPlaylistsAvailable": "Nenhuma playlist",
"delete_user_title": "Excluir usuário '%{name}'",

View File

@@ -10,7 +10,6 @@
"playCount": "Spelningar",
"title": "Titel",
"artist": "Artist",
"composer": "Kompositör",
"album": "Album",
"path": "Sökväg",
"genre": "Genre",

View File

@@ -14,8 +14,8 @@ import (
)
func TestScanner(t *testing.T) {
// Only run goleak checks when the GOLEAK env var is set
if os.Getenv("GOLEAK") != "" {
// Only run goleak checks when not in CI environment
if os.Getenv("CI") == "" {
// Detect any goroutine leaks in the scanner code under test
defer goleak.VerifyNone(t,
goleak.IgnoreTopFunction("github.com/onsi/ginkgo/v2/internal/interrupt_handler.(*InterruptHandler).registerForInterrupts.func2"),

View File

@@ -25,7 +25,6 @@ import (
type PluginManager interface {
EnablePlugin(ctx context.Context, id string) error
DisablePlugin(ctx context.Context, id string) error
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries bool) error

View File

@@ -171,26 +171,13 @@ func isValidJSON(s string) bool {
return json.Unmarshal([]byte(s), &js) == nil
}
// validateAndUpdateConfig validates the config JSON against the plugin's schema and updates the plugin.
// validateAndUpdateConfig validates the config JSON and updates the plugin.
// Returns an error if validation or update fails (error response already written).
func validateAndUpdateConfig(ctx context.Context, pm PluginManager, id, configJSON string, w http.ResponseWriter) error {
// Basic JSON syntax check
if configJSON != "" && !isValidJSON(configJSON) {
http.Error(w, "Invalid JSON in config field", http.StatusBadRequest)
return errors.New("invalid JSON")
}
// Validate against plugin's config schema
if err := pm.ValidatePluginConfig(ctx, id, configJSON); err != nil {
log.Warn(ctx, "Config validation failed", "id", id, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
// Try to return structured validation errors if available
response := map[string]any{"message": err.Error()}
_ = json.NewEncoder(w).Encode(response)
return err
}
if err := pm.UpdatePluginConfig(ctx, id, configJSON); err != nil {
log.Error(ctx, "Error updating plugin config", "id", id, err)
http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError)

View File

@@ -354,7 +354,7 @@ func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error)
}
count := p.IntOr("count", 50)
songs, err := api.provider.SimilarSongs(ctx, id, count)
songs, err := api.provider.ArtistRadio(ctx, id, count)
if err != nil {
return nil, err
}
@@ -410,9 +410,6 @@ func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artis
}
dir.AlbumCount = getArtistAlbumCount(artist)
dir.UserRating = int32(artist.Rating)
if conf.Server.Subsonic.EnableAverageRating {
dir.AverageRating = artist.AverageRating
}
if artist.Starred {
dir.Starred = artist.StarredAt
}
@@ -450,9 +447,6 @@ func (api *Router) buildAlbumDirectory(ctx context.Context, album *model.Album)
dir.Played = album.PlayDate
}
dir.UserRating = int32(album.Rating)
if conf.Server.Subsonic.EnableAverageRating {
dir.AverageRating = album.AverageRating
}
dir.SongCount = int32(album.SongCount)
dir.CoverArt = album.CoverArtID().String()
if album.Starred {

View File

@@ -101,9 +101,6 @@ func toArtist(r *http.Request, a model.Artist) responses.Artist {
CoverArt: a.CoverArtID().String(),
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
}
if conf.Server.Subsonic.EnableAverageRating {
artist.AverageRating = a.AverageRating
}
if a.Starred {
artist.Starred = a.StarredAt
}
@@ -119,9 +116,6 @@ func toArtistID3(r *http.Request, a model.Artist) responses.ArtistID3 {
ArtistImageUrl: publicurl.ImageURL(r, a.CoverArtID(), 600),
UserRating: int32(a.Rating),
}
if conf.Server.Subsonic.EnableAverageRating {
artist.AverageRating = a.AverageRating
}
if a.Starred {
artist.Starred = a.StarredAt
}
@@ -224,9 +218,6 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
child.Starred = mf.StarredAt
}
child.UserRating = int32(mf.Rating)
if conf.Server.Subsonic.EnableAverageRating {
child.AverageRating = mf.AverageRating
}
format, _ := getTranscoding(ctx)
if mf.Suffix != "" && format != "" && mf.Suffix != format {
@@ -240,7 +231,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild {
player, ok := request.PlayerFrom(ctx)
if ok && isClientInList(conf.Server.Subsonic.LegacyClients, player.Client) {
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
return nil
}
child := responses.OpenSubsonicChild{}
@@ -338,9 +329,6 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child {
}
child.PlayCount = al.PlayCount
child.UserRating = int32(al.Rating)
if conf.Server.Subsonic.EnableAverageRating {
child.AverageRating = al.AverageRating
}
child.OpenSubsonicChild = osChildFromAlbum(ctx, al)
return child
}
@@ -434,9 +422,6 @@ func buildOSAlbumID3(ctx context.Context, album model.Album) *responses.OpenSubs
dir.Played = album.PlayDate
}
dir.UserRating = int32(album.Rating)
if conf.Server.Subsonic.EnableAverageRating {
dir.AverageRating = album.AverageRating
}
dir.RecordLabels = slice.Map(album.Tags.Values(model.TagRecordLabel), func(s string) responses.RecordLabel {
return responses.RecordLabel{Name: s}
})

Some files were not shown because too many files have changed in this diff Show More