mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-18 03:38:03 -05:00
Compare commits
17 Commits
fix-playli
...
go-taglib
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30e7b9d5d7 | ||
|
|
8b0fda8bbb | ||
|
|
91eebd16f7 | ||
|
|
175a3a2a2b | ||
|
|
f4cb7cd00b | ||
|
|
4e354e02a1 | ||
|
|
f46331734d | ||
|
|
9263453a30 | ||
|
|
db7047f744 | ||
|
|
91c6f98b3f | ||
|
|
8836a86d0b | ||
|
|
d71e408792 | ||
|
|
c5447a637a | ||
|
|
b9247ba34e | ||
|
|
510acde3db | ||
|
|
13be8e6dfb | ||
|
|
9ab0c2dc67 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -35,4 +35,5 @@ AGENTS.md
|
||||
*.test
|
||||
*.wasm
|
||||
*.ndp
|
||||
openspec/
|
||||
openspec/
|
||||
go.work*
|
||||
274
adapters/gotaglib/end_to_end_test.go
Normal file
274
adapters/gotaglib/end_to_end_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
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{}
|
||||
})
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
255
adapters/gotaglib/gotaglib.go
Normal file
255
adapters/gotaglib/gotaglib.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// 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.
|
||||
package gotaglib
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/storage/local"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/metadata"
|
||||
"go.senan.xyz/taglib"
|
||||
)
|
||||
|
||||
type extractor struct {
|
||||
baseDir string
|
||||
}
|
||||
|
||||
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) {
|
||||
fullPath := filepath.Join(e.baseDir, filePath)
|
||||
|
||||
// Open the file once and read all data
|
||||
f, err := taglib.OpenReadOnly(fullPath)
|
||||
if err != nil {
|
||||
// Check if file doesn't exist
|
||||
if _, statErr := os.Stat(fullPath); os.IsNotExist(statErr) {
|
||||
return nil, fs.ErrNotExist
|
||||
}
|
||||
// Check if permission denied
|
||||
if errors.Is(err, taglib.ErrInvalidFile) {
|
||||
// Try to open the file to check for permission errors
|
||||
if osFile, openErr := os.Open(fullPath); openErr != nil {
|
||||
if os.IsPermission(openErr) {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
} else {
|
||||
osFile.Close()
|
||||
}
|
||||
}
|
||||
log.Warn("gotaglib extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
|
||||
return nil, err
|
||||
}
|
||||
defer f.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)
|
||||
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
|
||||
}
|
||||
|
||||
// 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("gotaglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||
return &extractor{baseDir}
|
||||
})
|
||||
}
|
||||
17
adapters/gotaglib/gotaglib_suite_test.go
Normal file
17
adapters/gotaglib/gotaglib_suite_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
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")
|
||||
}
|
||||
295
adapters/gotaglib/gotaglib_test.go
Normal file
295
adapters/gotaglib/gotaglib_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
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{}
|
||||
})
|
||||
|
||||
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() {
|
||||
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() {
|
||||
_, err := e.extractMetadata(accessForbiddenFile)
|
||||
Expect(err).To(MatchError(os.ErrPermission))
|
||||
})
|
||||
|
||||
It("skips the file if it cannot be read", func() {
|
||||
files := []string{
|
||||
"tests/fixtures/test.mp3",
|
||||
"tests/fixtures/test.ogg",
|
||||
accessForbiddenFile,
|
||||
}
|
||||
mds, err := e.Parse(files...)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(mds).To(HaveLen(2))
|
||||
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -151,11 +151,7 @@ var _ = Describe("Extractor", func() {
|
||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||
unsUslt := makeLyrics("xxx", "unspecified")
|
||||
|
||||
// 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}),
|
||||
))
|
||||
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||
})
|
||||
|
||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||
|
||||
@@ -80,12 +80,11 @@ var _ = Describe("Extractor", func() {
|
||||
Expect(err).To(BeNil())
|
||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||
|
||||
// TabLib 1.12 returns 18, previous versions return 39.
|
||||
// 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.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||
Expect(m.HasPicture).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -106,7 +105,7 @@ var _ = Describe("Extractor", func() {
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
|
||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||
))
|
||||
|
||||
Expect(m.Tags).To(Or(
|
||||
|
||||
@@ -24,6 +24,7 @@ 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"
|
||||
|
||||
@@ -33,6 +33,7 @@ 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"
|
||||
|
||||
@@ -153,6 +153,7 @@ type subsonicOptions struct {
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
LegacyClients string
|
||||
MinimalClients string
|
||||
}
|
||||
|
||||
type TagConf struct {
|
||||
@@ -365,10 +366,6 @@ 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
|
||||
|
||||
@@ -168,6 +168,11 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R
|
||||
if nsp.Comment != "" {
|
||||
pls.Comment = nsp.Comment
|
||||
}
|
||||
if nsp.Public != nil {
|
||||
pls.Public = *nsp.Public
|
||||
} else {
|
||||
pls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -201,49 +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
|
||||
lookupCandidates := make([]string, 0, len(resolvedPaths)*2)
|
||||
seen := make(map[string]struct{}, len(resolvedPaths)*2)
|
||||
for _, path := range resolvedPaths {
|
||||
nfc := strings.ToLower(norm.NFC.String(path))
|
||||
if _, ok := seen[nfc]; !ok {
|
||||
seen[nfc] = struct{}{}
|
||||
lookupCandidates = append(lookupCandidates, nfc)
|
||||
}
|
||||
nfd := strings.ToLower(norm.NFD.String(path))
|
||||
if _, ok := seen[nfd]; !ok {
|
||||
seen[nfd] = struct{}{}
|
||||
lookupCandidates = append(lookupCandidates, nfd)
|
||||
}
|
||||
}
|
||||
// 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))
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -405,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
|
||||
}
|
||||
@@ -438,7 +414,10 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
|
||||
} else {
|
||||
log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
|
||||
newPls.OwnerID = owner.ID
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
// For NSP files, Public may already be set from the file; for M3U, use server default
|
||||
if !newPls.IsSmartPlaylist() {
|
||||
newPls.Public = conf.Server.DefaultPlaylistPublicVisibility
|
||||
}
|
||||
}
|
||||
return s.ds.Playlist(ctx).Put(newPls)
|
||||
}
|
||||
@@ -502,6 +481,7 @@ type nspFile struct {
|
||||
criteria.Criteria
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Public *bool `json:"public"`
|
||||
}
|
||||
|
||||
func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
@@ -512,5 +492,8 @@ func (i *nspFile) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
i.Name, _ = m["name"].(string)
|
||||
i.Comment, _ = m["comment"].(string)
|
||||
if public, ok := m["public"].(bool); ok {
|
||||
i.Public = &public
|
||||
}
|
||||
return json.Unmarshal(data, &i.Criteria)
|
||||
}
|
||||
|
||||
@@ -112,56 +112,28 @@ var _ = Describe("Playlists", func() {
|
||||
_, err := ps.ImportFile(ctx, folder, "invalid_json.nsp")
|
||||
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
|
||||
})
|
||||
})
|
||||
|
||||
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")
|
||||
It("parses NSP with public: true and creates public playlist", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Public Playlist"))
|
||||
Expect(pls.Public).To(BeTrue())
|
||||
})
|
||||
It("parses NSP with public: false and creates private playlist", func() {
|
||||
pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Private Playlist"))
|
||||
Expect(pls.Public).To(BeFalse())
|
||||
})
|
||||
It("uses server default when public field is absent", func() {
|
||||
DeferCleanup(configtest.SetupConfig())
|
||||
conf.Server.DefaultPlaylistPublicVisibility = true
|
||||
|
||||
// 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"),
|
||||
)
|
||||
pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(pls.Name).To(Equal("Recently Played"))
|
||||
Expect(pls.Public).To(BeTrue()) // Should be true since server default is true
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Cross-library relative paths", func() {
|
||||
var tmpDir, plsDir, songsDir string
|
||||
@@ -474,63 +446,22 @@ var _ = Describe("Playlists", func() {
|
||||
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
|
||||
})
|
||||
|
||||
// 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")
|
||||
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}
|
||||
|
||||
// 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),
|
||||
)
|
||||
// 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))
|
||||
// Should match despite different Unicode normalization forms
|
||||
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -632,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
|
||||
@@ -643,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
|
||||
@@ -660,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
|
||||
}
|
||||
|
||||
|
||||
3
go.mod
3
go.mod
@@ -5,6 +5,8 @@ go 1.25
|
||||
// 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
|
||||
|
||||
replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260117215539-f414ba45cbd9
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
|
||||
@@ -60,6 +62,7 @@ require (
|
||||
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.0.0-00010101000000-000000000000
|
||||
go.uber.org/goleak v1.3.0
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/net v0.49.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -36,6 +36,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/deluan/go-taglib v0.0.0-20260117215539-f414ba45cbd9 h1:h2YB9dUm8wQ1ERSQCFuIJp3tecnm+AOJiRFrw3zJa3E=
|
||||
github.com/deluan/go-taglib v0.0.0-20260117215539-f414ba45cbd9/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=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Discord Rich Presence Plugin (Rust)
|
||||
|
||||
A Navidrome plugin that displays your currently playing track on Discord using Rich Presence. This is the Rust implementation demonstrating how to use the generated `nd-host` library.
|
||||
A Navidrome plugin that displays your currently playing track on Discord using Rich Presence. This is the Rust implementation demonstrating how to use the `nd-pdk` library.
|
||||
|
||||
## ⚠️ Warning
|
||||
|
||||
@@ -21,20 +21,20 @@ This plugin is for **demonstration purposes only**. It requires storing your Dis
|
||||
|
||||
## Capabilities
|
||||
|
||||
This plugin implements three capabilities to demonstrate the nd-host library:
|
||||
This plugin implements multiple capabilities to demonstrate the nd-pdk library:
|
||||
|
||||
- **Scrobbler**: Receives now-playing events from Navidrome
|
||||
- **SchedulerCallback**: Handles heartbeat and activity clearing timers
|
||||
- **WebSocketCallback**: Communicates with Discord gateway
|
||||
- **WebSocketCallback**: Communicates with Discord gateway (text, binary, error, and close handlers)
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure in the Navidrome UI (Settings → Plugins → discord-rich-presence):
|
||||
|
||||
| Key | Description | Example |
|
||||
|---------------|-------------------------------------------|--------------------------------|
|
||||
| `clientid` | Your Discord application ID | `123456789012345678` |
|
||||
| `user.<name>` | Discord token for the specified user | `user.alice` = `token123` |
|
||||
| Key | Description | Example |
|
||||
|---------------|--------------------------------------|---------------------------|
|
||||
| `clientid` | Your Discord application ID | `123456789012345678` |
|
||||
| `user.<name>` | Discord token for the specified user | `user.alice` = `token123` |
|
||||
|
||||
Each user is configured as a separate key with the `user.` prefix.
|
||||
|
||||
@@ -69,27 +69,30 @@ make discord-rich-presence-rs.ndp
|
||||
3. Enable and configure the plugin in the Navidrome UI (Settings → Plugins)
|
||||
4. Restart Navidrome if needed
|
||||
|
||||
## Using nd-host Library
|
||||
## Using nd-pdk Library
|
||||
|
||||
This plugin demonstrates how to use the generated Rust host function wrappers:
|
||||
This plugin demonstrates how to use the Rust plugin development kit:
|
||||
|
||||
```rust
|
||||
use nd_host::{artwork, cache, scheduler, websocket};
|
||||
use nd_pdk::host::{artwork, cache, scheduler, websocket};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Get artwork URL
|
||||
let (url, _) = artwork::artwork_get_track_url(track_id, 300)?;
|
||||
let url = artwork::get_track_url(track_id, 300)?;
|
||||
|
||||
// Cache operations
|
||||
cache::cache_set_string("key", "value", 3600)?;
|
||||
let (value, exists) = cache::cache_get_string("key")?;
|
||||
cache::set_string("key", "value", 3600)?;
|
||||
if let Some(value) = cache::get_string("key")? {
|
||||
// Use the cached value
|
||||
}
|
||||
|
||||
// Schedule tasks
|
||||
scheduler::scheduler_schedule_one_time(60, "payload", "task-id")?;
|
||||
scheduler::scheduler_schedule_recurring("@every 30s", "heartbeat", "heartbeat-task")?;
|
||||
scheduler::schedule_one_time(60, "payload", "task-id")?;
|
||||
scheduler::schedule_recurring("@every 30s", "heartbeat", "heartbeat-task")?;
|
||||
|
||||
// WebSocket operations
|
||||
let conn_id = websocket::websocket_connect("wss://example.com/socket")?;
|
||||
websocket::websocket_send_text(&conn_id, "Hello")?;
|
||||
let conn_id = websocket::connect("wss://example.com/socket", HashMap::new(), "my-conn")?;
|
||||
websocket::send_text(&conn_id, "Hello")?;
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
@@ -42,10 +43,11 @@ func TestPlugins(t *testing.T) {
|
||||
|
||||
func buildTestPlugins(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
start := time.Now()
|
||||
t.Logf("[BeforeSuite] Current working directory: %s", path)
|
||||
cmd := exec.Command("make", "-C", path)
|
||||
out, err := cmd.CombinedOutput()
|
||||
t.Logf("[BeforeSuite] Make output: %s", string(out))
|
||||
t.Logf("[BeforeSuite] Make output: %s elapsed: %s", string(out), time.Since(start))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build test plugins: %v", err)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) {
|
||||
artId, err := decodeArtworkID(id)
|
||||
if err != nil {
|
||||
log.Error(r, "Error decoding artwork id", "id", id, err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
size := p.IntOr("size", 0)
|
||||
|
||||
@@ -166,11 +166,30 @@ func getTranscoding(ctx context.Context) (format string, bitRate int) {
|
||||
return
|
||||
}
|
||||
|
||||
func isClientInList(clientList, client string) bool {
|
||||
if clientList == "" || client == "" {
|
||||
return false
|
||||
}
|
||||
clients := strings.Split(clientList, ",")
|
||||
for _, c := range clients {
|
||||
if strings.TrimSpace(c) == client {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child {
|
||||
child := responses.Child{}
|
||||
child.Id = mf.ID
|
||||
child.Title = mf.FullTitle()
|
||||
child.IsDir = false
|
||||
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
|
||||
return child
|
||||
}
|
||||
|
||||
child.Parent = mf.AlbumID
|
||||
child.Album = mf.Album
|
||||
child.Year = int32(mf.Year)
|
||||
@@ -183,7 +202,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
child.BitRate = int32(mf.BitRate)
|
||||
child.CoverArt = mf.CoverArtID().String()
|
||||
child.ContentType = mf.ContentType()
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
|
||||
if ok && player.ReportRealPath {
|
||||
child.Path = mf.AbsolutePath()
|
||||
} else {
|
||||
@@ -211,8 +230,8 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child
|
||||
}
|
||||
|
||||
func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.OpenSubsonicChild {
|
||||
player, _ := request.PlayerFrom(ctx)
|
||||
if strings.Contains(conf.Server.Subsonic.LegacyClients, player.Client) {
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
|
||||
return nil
|
||||
}
|
||||
child := responses.OpenSubsonicChild{}
|
||||
|
||||
@@ -169,6 +169,190 @@ var _ = Describe("helpers", func() {
|
||||
})
|
||||
})
|
||||
|
||||
DescribeTable("isClientInList",
|
||||
func(list, client string, expected bool) {
|
||||
Expect(isClientInList(list, client)).To(Equal(expected))
|
||||
},
|
||||
Entry("returns false when clientList is empty", "", "some-client", false),
|
||||
Entry("returns false when client is empty", "client1,client2", "", false),
|
||||
Entry("returns false when both are empty", "", "", false),
|
||||
Entry("returns true when client matches single entry", "my-client", "my-client", true),
|
||||
Entry("returns true when client matches first in list", "client1,client2,client3", "client1", true),
|
||||
Entry("returns true when client matches middle in list", "client1,client2,client3", "client2", true),
|
||||
Entry("returns true when client matches last in list", "client1,client2,client3", "client3", true),
|
||||
Entry("returns false when client does not match", "client1,client2", "client3", false),
|
||||
Entry("trims whitespace from client list entries", "client1, client2 , client3", "client2", true),
|
||||
Entry("does not trim the client parameter", "client1,client2", " client1", false),
|
||||
)
|
||||
|
||||
Describe("childFromMediaFile", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
mf = model.MediaFile{
|
||||
ID: "mf-1",
|
||||
Title: "Test Song",
|
||||
Album: "Test Album",
|
||||
AlbumID: "album-1",
|
||||
Artist: "Test Artist",
|
||||
ArtistID: "artist-1",
|
||||
Year: 2023,
|
||||
Genre: "Rock",
|
||||
TrackNumber: 5,
|
||||
Duration: 180.5,
|
||||
Size: 5000000,
|
||||
Suffix: "mp3",
|
||||
BitRate: 320,
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns only basic fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Id).To(Equal("mf-1"))
|
||||
Expect(child.Title).To(Equal("Test Song"))
|
||||
Expect(child.IsDir).To(BeFalse())
|
||||
|
||||
// These should not be set
|
||||
Expect(child.Album).To(BeEmpty())
|
||||
Expect(child.Artist).To(BeEmpty())
|
||||
Expect(child.Parent).To(BeEmpty())
|
||||
Expect(child.Year).To(BeZero())
|
||||
Expect(child.Genre).To(BeEmpty())
|
||||
Expect(child.Track).To(BeZero())
|
||||
Expect(child.Duration).To(BeZero())
|
||||
Expect(child.Size).To(BeZero())
|
||||
Expect(child.Suffix).To(BeEmpty())
|
||||
Expect(child.BitRate).To(BeZero())
|
||||
Expect(child.CoverArt).To(BeEmpty())
|
||||
Expect(child.ContentType).To(BeEmpty())
|
||||
Expect(child.Path).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not include OpenSubsonic extension", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.OpenSubsonicChild).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Id).To(Equal("mf-1"))
|
||||
Expect(child.Title).To(Equal("Test Song"))
|
||||
Expect(child.IsDir).To(BeFalse())
|
||||
Expect(child.Album).To(Equal("Test Album"))
|
||||
Expect(child.Artist).To(Equal("Test Artist"))
|
||||
Expect(child.Parent).To(Equal("album-1"))
|
||||
Expect(child.Year).To(Equal(int32(2023)))
|
||||
Expect(child.Genre).To(Equal("Rock"))
|
||||
Expect(child.Track).To(Equal(int32(5)))
|
||||
Expect(child.Duration).To(Equal(int32(180)))
|
||||
Expect(child.Size).To(Equal(int64(5000000)))
|
||||
Expect(child.Suffix).To(Equal("mp3"))
|
||||
Expect(child.BitRate).To(Equal(int32(320)))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Album).To(Equal("Test Album"))
|
||||
Expect(child.Artist).To(Equal("Test Artist"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns all fields", func() {
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.Album).To(Equal("Test Album"))
|
||||
Expect(child.Artist).To(Equal("Test Artist"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("osChildFromMediaFile", func() {
|
||||
var mf model.MediaFile
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
mf = model.MediaFile{
|
||||
ID: "mf-1",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
Comment: "Test Comment",
|
||||
}
|
||||
ctx = context.Background()
|
||||
})
|
||||
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns nil", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns OpenSubsonic child fields", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).ToNot(BeNil())
|
||||
Expect(osChild.Comment).To(Equal("Test Comment"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns OpenSubsonic child fields", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns OpenSubsonic child fields", func() {
|
||||
osChild := osChildFromMediaFile(ctx, mf)
|
||||
Expect(osChild).ToNot(BeNil())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("selectedMusicFolderIds", func() {
|
||||
var user model.User
|
||||
var ctx context.Context
|
||||
|
||||
@@ -7,8 +7,10 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server/subsonic/responses"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
@@ -23,7 +25,7 @@ func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
response := newResponse()
|
||||
response.Playlists = &responses.Playlists{
|
||||
Playlist: slice.Map(allPls, api.buildPlaylist),
|
||||
Playlist: slice.MapWithArg(allPls, ctx, api.buildPlaylist),
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -51,7 +53,7 @@ func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subso
|
||||
|
||||
response := newResponse()
|
||||
response.Playlist = &responses.PlaylistWithSongs{
|
||||
Playlist: api.buildPlaylist(*pls),
|
||||
Playlist: api.buildPlaylist(ctx, *pls),
|
||||
}
|
||||
response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile)
|
||||
return response, nil
|
||||
@@ -152,21 +154,28 @@ func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error)
|
||||
return newResponse(), nil
|
||||
}
|
||||
|
||||
func (api *Router) buildPlaylist(p model.Playlist) responses.Playlist {
|
||||
func (api *Router) buildPlaylist(ctx context.Context, p model.Playlist) responses.Playlist {
|
||||
pls := responses.Playlist{}
|
||||
pls.Id = p.ID
|
||||
pls.Name = p.Name
|
||||
pls.Comment = p.Comment
|
||||
pls.SongCount = int32(p.SongCount)
|
||||
pls.Owner = p.OwnerName
|
||||
pls.Duration = int32(p.Duration)
|
||||
pls.Public = p.Public
|
||||
pls.Created = p.CreatedAt
|
||||
pls.CoverArt = p.CoverArtID().String()
|
||||
if p.IsSmartPlaylist() {
|
||||
pls.Changed = time.Now()
|
||||
} else {
|
||||
pls.Changed = p.UpdatedAt
|
||||
}
|
||||
|
||||
player, ok := request.PlayerFrom(ctx)
|
||||
if ok && isClientInList(conf.Server.Subsonic.MinimalClients, player.Client) {
|
||||
return pls
|
||||
}
|
||||
|
||||
pls.Comment = p.Comment
|
||||
pls.Owner = p.OwnerName
|
||||
pls.Public = p.Public
|
||||
pls.CoverArt = p.CoverArtID().String()
|
||||
|
||||
return pls
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ package subsonic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@@ -12,6 +15,108 @@ import (
|
||||
|
||||
var _ core.Playlists = (*fakePlaylists)(nil)
|
||||
|
||||
var _ = Describe("buildPlaylist", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
var ctx context.Context
|
||||
var playlist model.Playlist
|
||||
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
ctx = context.Background()
|
||||
|
||||
createdAt := time.Date(2023, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
updatedAt := time.Date(2023, 2, 20, 14, 45, 0, 0, time.UTC)
|
||||
|
||||
playlist = model.Playlist{
|
||||
ID: "pls-1",
|
||||
Name: "My Playlist",
|
||||
Comment: "Test comment",
|
||||
OwnerName: "admin",
|
||||
Public: true,
|
||||
SongCount: 10,
|
||||
Duration: 600,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
})
|
||||
|
||||
Context("with minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "minimal-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns only basic fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
|
||||
// These should not be set
|
||||
Expect(result.Comment).To(BeEmpty())
|
||||
Expect(result.Owner).To(BeEmpty())
|
||||
Expect(result.Public).To(BeFalse())
|
||||
Expect(result.CoverArt).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Context("with non-minimal client", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = "minimal-client"
|
||||
player := model.Player{Client: "regular-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Id).To(Equal("pls-1"))
|
||||
Expect(result.Name).To(Equal("My Playlist"))
|
||||
Expect(result.SongCount).To(Equal(int32(10)))
|
||||
Expect(result.Duration).To(Equal(int32(600)))
|
||||
Expect(result.Created).To(Equal(playlist.CreatedAt))
|
||||
Expect(result.Changed).To(Equal(playlist.UpdatedAt))
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when minimal clients list is empty", func() {
|
||||
BeforeEach(func() {
|
||||
conf.Server.Subsonic.MinimalClients = ""
|
||||
player := model.Player{Client: "any-client"}
|
||||
ctx = request.WithPlayer(ctx, player)
|
||||
})
|
||||
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("when no player in context", func() {
|
||||
It("returns all fields", func() {
|
||||
result := router.buildPlaylist(ctx, playlist)
|
||||
|
||||
Expect(result.Comment).To(Equal("Test comment"))
|
||||
Expect(result.Owner).To(Equal("admin"))
|
||||
Expect(result.Public).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
var _ = Describe("UpdatePlaylist", func() {
|
||||
var router *Router
|
||||
var ds model.DataStore
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "sort name",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumList>
|
||||
<album id="1" isDir="false" isVideo="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit">
|
||||
<album id="1" isDir="false" sortName="sort name" mediaType="album" musicBrainzId="00000000-0000-0000-0000-000000000000" displayArtist="Display artist" displayAlbumArtist="Display album artist" explicitStatus="explicit">
|
||||
<genres name="Genre 1"></genres>
|
||||
<genres name="Genre 2"></genres>
|
||||
<moods>mood1</moods>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<albumList>
|
||||
<album id="1" isDir="false" title="title" isVideo="false"></album>
|
||||
<album id="1" isDir="false" title="title"></album>
|
||||
</albumList>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -93,7 +93,6 @@
|
||||
"transcodedSuffix": "mp3",
|
||||
"duration": 146,
|
||||
"bitRate": 320,
|
||||
"isVideo": false,
|
||||
"bpm": 127,
|
||||
"comment": "a comment",
|
||||
"sortName": "sorted song",
|
||||
@@ -185,7 +184,6 @@
|
||||
"transcodedSuffix": "mp3",
|
||||
"duration": 146,
|
||||
"bitRate": 320,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<moods>sad</moods>
|
||||
<artists id="1" name="artist1"></artists>
|
||||
<artists id="2" name="artist2"></artists>
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<song id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted song" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist1 & artist2" displayAlbumArtist="album artist1 & album artist2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<genres name="rock"></genres>
|
||||
<genres name="progressive"></genres>
|
||||
@@ -33,7 +33,7 @@
|
||||
<artist id="2" name="artist2"></artist>
|
||||
</contributors>
|
||||
</song>
|
||||
<song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false">
|
||||
<song id="2" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320">
|
||||
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
|
||||
</song>
|
||||
</album>
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
"entry": {
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
},
|
||||
"position": 123,
|
||||
"username": "user2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<bookmarks>
|
||||
<bookmark position="123" username="user2" comment="a comment" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title"></entry>
|
||||
</bookmark>
|
||||
</bookmarks>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
"transcodedSuffix": "mp3",
|
||||
"duration": 146,
|
||||
"bitRate": 320,
|
||||
"isVideo": false,
|
||||
"bpm": 127,
|
||||
"comment": "a comment",
|
||||
"sortName": "sorted title",
|
||||
@@ -116,7 +115,6 @@
|
||||
{
|
||||
"id": "",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" isVideo="false" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<child id="1" isDir="true" title="title" album="album" artist="artist" track="1" year="1985" genre="Rock" coverArt="1" size="8421341" contentType="audio/flac" suffix="flac" starred="2016-03-02T20:30:00Z" transcodedContentType="audio/mpeg" transcodedSuffix="mp3" duration="146" bitRate="320" bpm="127" comment="a comment" sortName="sorted title" mediaType="song" musicBrainzId="4321" channelCount="2" samplingRate="44100" bitDepth="16" displayArtist="artist 1 & artist 2" displayAlbumArtist="album artist 1 & album artist 2" displayComposer="composer 1 & composer 2" explicitStatus="clean">
|
||||
<isrc>ISRC-1</isrc>
|
||||
<isrc>ISRC-2</isrc>
|
||||
<genres name="rock"></genres>
|
||||
@@ -25,7 +25,7 @@
|
||||
<artist id="4" name="composer2"></artist>
|
||||
</contributors>
|
||||
</child>
|
||||
<child id="" isDir="false" isVideo="false">
|
||||
<child id="" isDir="false">
|
||||
<replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain>
|
||||
</child>
|
||||
</directory>
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"child": [
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false
|
||||
"isDir": false
|
||||
}
|
||||
],
|
||||
"id": "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
<child id="1" isDir="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"isVideo": false,
|
||||
"bpm": 0,
|
||||
"comment": "",
|
||||
"sortName": "",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="" name="">
|
||||
<child id="1" isDir="false" isVideo="false"></child>
|
||||
<child id="1" isDir="false"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
],
|
||||
"id": "1",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<directory id="1" name="N">
|
||||
<child id="1" isDir="false" title="title" isVideo="false"></child>
|
||||
<child id="1" isDir="false" title="title"></child>
|
||||
</directory>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
],
|
||||
"current": "111",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueue current="111" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title"></entry>
|
||||
</playQueue>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
],
|
||||
"currentIndex": 0,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playQueueByIndex currentIndex="0" position="243" username="user1" changed="0001-01-01T00:00:00Z" changedBy="a_client">
|
||||
<entry id="1" isDir="false" title="title" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title"></entry>
|
||||
</playQueueByIndex>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"name": "bbb",
|
||||
"songCount": 0,
|
||||
"duration": 0,
|
||||
"public": false,
|
||||
"created": "0001-01-01T00:00:00Z",
|
||||
"changed": "0001-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<playlists>
|
||||
<playlist id="111" name="aaa" comment="comment" songCount="2" duration="120" public="true" owner="admin" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z" coverArt="pl-123123123123"></playlist>
|
||||
<playlist id="222" name="bbb" songCount="0" duration="0" public="false" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
<playlist id="222" name="bbb" songCount="0" duration="0" created="0001-01-01T00:00:00Z" changed="0001-01-01T00:00:00Z"></playlist>
|
||||
</playlists>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -14,8 +14,7 @@
|
||||
"title": "title",
|
||||
"album": "album",
|
||||
"artist": "artist",
|
||||
"duration": 120,
|
||||
"isVideo": false
|
||||
"duration": 120
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
@@ -23,8 +22,7 @@
|
||||
"title": "title 2",
|
||||
"album": "album",
|
||||
"artist": "artist",
|
||||
"duration": 300,
|
||||
"isVideo": false
|
||||
"duration": 300
|
||||
}
|
||||
],
|
||||
"id": "ABC123",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<shares>
|
||||
<share id="ABC123" url="http://localhost/p/ABC123" description="Check it out!" username="deluan" created="2016-03-02T20:30:00Z" expires="2016-03-02T20:30:00Z" lastVisited="2016-03-02T20:30:00Z" visitCount="2">
|
||||
<entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120" isVideo="false"></entry>
|
||||
<entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300" isVideo="false"></entry>
|
||||
<entry id="1" isDir="false" title="title" album="album" artist="artist" duration="120"></entry>
|
||||
<entry id="2" isDir="false" title="title 2" album="album" artist="artist" duration="300"></entry>
|
||||
</share>
|
||||
</shares>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<similarSongs>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
<song id="1" isDir="false" title="title"></song>
|
||||
</similarSongs>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<similarSongs2>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
<song id="1" isDir="false" title="title"></song>
|
||||
</similarSongs2>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
{
|
||||
"id": "1",
|
||||
"isDir": false,
|
||||
"title": "title",
|
||||
"isVideo": false
|
||||
"title": "title"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true">
|
||||
<topSongs>
|
||||
<song id="1" isDir="false" title="title" isVideo="false"></song>
|
||||
<song id="1" isDir="false" title="title"></song>
|
||||
</topSongs>
|
||||
</subsonic-response>
|
||||
|
||||
@@ -161,7 +161,7 @@ type Child struct {
|
||||
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
|
||||
IsVideo bool `xml:"isVideo,attr,omitempty" json:"isVideo,omitempty"`
|
||||
BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"`
|
||||
/*
|
||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 -->
|
||||
@@ -177,7 +177,7 @@ type OpenSubsonicChild struct {
|
||||
SortName string `xml:"sortName,attr,omitempty" json:"sortName"`
|
||||
MediaType MediaType `xml:"mediaType,attr,omitempty" json:"mediaType"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
|
||||
Isrc Array[string] `xml:"isrc,omitempty" json:"isrc"`
|
||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||
ReplayGain ReplayGain `xml:"replayGain,omitempty" json:"replayGain"`
|
||||
ChannelCount int32 `xml:"channelCount,attr,omitempty" json:"channelCount"`
|
||||
@@ -308,7 +308,7 @@ type Playlist struct {
|
||||
Comment string `xml:"comment,attr,omitempty" json:"comment,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int32 `xml:"duration,attr" json:"duration"`
|
||||
Public bool `xml:"public,attr" json:"public"`
|
||||
Public bool `xml:"public,attr,omitempty" json:"public,omitempty"`
|
||||
Owner string `xml:"owner,attr,omitempty" json:"owner,omitempty"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Changed time.Time `xml:"changed,attr" json:"changed"`
|
||||
|
||||
11
tests/fixtures/playlists/private_playlist.nsp
vendored
Normal file
11
tests/fixtures/playlists/private_playlist.nsp
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Private Playlist",
|
||||
"comment": "A smart playlist that is explicitly private",
|
||||
"public": false,
|
||||
"all": [
|
||||
{"is": {"loved": true}}
|
||||
],
|
||||
"sort": "title",
|
||||
"order": "asc",
|
||||
"limit": 100
|
||||
}
|
||||
11
tests/fixtures/playlists/public_playlist.nsp
vendored
Normal file
11
tests/fixtures/playlists/public_playlist.nsp
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "Public Playlist",
|
||||
"comment": "A smart playlist that is public",
|
||||
"public": true,
|
||||
"all": [
|
||||
{"inTheLast": {"lastPlayed": 30}}
|
||||
],
|
||||
"sort": "lastPlayed",
|
||||
"order": "desc",
|
||||
"limit": 50
|
||||
}
|
||||
Reference in New Issue
Block a user