mirror of
https://github.com/navidrome/navidrome.git
synced 2026-01-18 19:58:15 -05:00
Compare commits
6 Commits
update-tra
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03120bac32 | ||
|
|
0473c50b49 | ||
|
|
2de2484bca | ||
|
|
64e165aaef | ||
|
|
8e96dd0784 | ||
|
|
9bd91d2c04 |
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{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))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
263
adapters/gotaglib/gotaglib.go
Normal file
263
adapters/gotaglib/gotaglib.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// 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)
|
||||
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}
|
||||
})
|
||||
}
|
||||
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")
|
||||
}
|
||||
302
adapters/gotaglib/gotaglib_test.go
Normal file
302
adapters/gotaglib/gotaglib_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -168,7 +168,7 @@ func parseTIPL(tags map[string][]string) {
|
||||
var _ local.Extractor = (*extractor)(nil)
|
||||
|
||||
func init() {
|
||||
local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||
// ignores fs, as taglib extractor only works with local files
|
||||
return &extractor{baseDir}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -152,6 +152,7 @@ type subsonicOptions struct {
|
||||
AppendSubtitle bool
|
||||
ArtistParticipations bool
|
||||
DefaultReportRealPath bool
|
||||
EnableAverageRating bool
|
||||
LegacyClients string
|
||||
MinimalClients string
|
||||
}
|
||||
@@ -366,10 +367,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
|
||||
@@ -609,6 +606,7 @@ 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)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/deluan/rest"
|
||||
_ "github.com/navidrome/navidrome/adapters/taglib" // Register taglib extractor
|
||||
_ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
|
||||
|
||||
@@ -265,6 +265,10 @@ 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)
|
||||
|
||||
@@ -40,6 +40,7 @@ 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"`
|
||||
|
||||
23
db/migrations/20260117201522_add_avg_rating_column.sql
Normal file
23
db/migrations/20260117201522_add_avg_rating_column.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- +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;
|
||||
10
go.mod
10
go.mod
@@ -2,8 +2,13 @@ module github.com/navidrome/navidrome
|
||||
|
||||
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 (
|
||||
// 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-20260118171208-db06bab917c7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Masterminds/squirrel v1.5.4
|
||||
@@ -60,6 +65,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-20260118171208-db06bab917c7 h1:ICwI2s4BQdDgp+TY2mAf0jMB7B2hgML7IsSAKTuTRBk=
|
||||
github.com/deluan/go-taglib v0.0.0-20260118171208-db06bab917c7/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=
|
||||
|
||||
@@ -3,12 +3,13 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type AnnotatedRepository interface {
|
||||
|
||||
@@ -353,6 +353,7 @@ 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)
|
||||
|
||||
@@ -126,6 +126,89 @@ 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
|
||||
|
||||
@@ -124,6 +124,25 @@ 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})
|
||||
}
|
||||
|
||||
@@ -41,6 +41,44 @@ 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{
|
||||
@@ -119,6 +157,74 @@ 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())
|
||||
|
||||
@@ -130,7 +130,8 @@ 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"}
|
||||
testUsers = model.Users{adminUser, regularUser}
|
||||
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
|
||||
testUsers = model.Users{adminUser, regularUser, thirdUser}
|
||||
)
|
||||
|
||||
func p(path string) string {
|
||||
|
||||
@@ -17,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
|
||||
return query.Columns(fmt.Sprintf("%s.average_rating", r.tableName))
|
||||
}
|
||||
query = query.
|
||||
LeftJoin("annotation on ("+
|
||||
@@ -38,6 +38,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -79,7 +81,22 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
ratedAt := time.Now()
|
||||
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
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
|
||||
}
|
||||
|
||||
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,12 +12,16 @@
|
||||
"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",
|
||||
@@ -25,7 +29,6 @@
|
||||
"quality": "Calidad",
|
||||
"bpm": "BPM",
|
||||
"playDate": "Últimas reproducciones",
|
||||
"channels": "Canales",
|
||||
"createdAt": "Creado el",
|
||||
"grouping": "Agrupación",
|
||||
"mood": "Estado de ánimo",
|
||||
@@ -33,20 +36,17 @@
|
||||
"tags": "Etiquetas",
|
||||
"mappedTags": "Etiquetas asignadas",
|
||||
"rawTags": "Etiquetas sin procesar",
|
||||
"bitDepth": "Profundidad de bits",
|
||||
"sampleRate": "Frecuencia de muestreo",
|
||||
"missing": "Faltante",
|
||||
"libraryName": "Biblioteca"
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"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",
|
||||
"showInPlaylist": "Mostrar en la lista de reproducción"
|
||||
"info": "Obtener informació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",
|
||||
"updatedAt": "Actualizado el",
|
||||
"comment": "Comentario",
|
||||
"rating": "Calificación",
|
||||
"createdAt": "Creado el",
|
||||
"size": "Tamaño del archivo",
|
||||
"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",
|
||||
"recordLabel": "Discográfica",
|
||||
"catalogNum": "Número de catálogo",
|
||||
"releaseType": "Tipo de lanzamiento",
|
||||
"grouping": "Agrupación",
|
||||
"media": "Medios",
|
||||
"mood": "Estado de ánimo",
|
||||
"date": "Fecha de grabación",
|
||||
"missing": "Faltante",
|
||||
"libraryName": "Biblioteca"
|
||||
"missing": "Faltante"
|
||||
},
|
||||
"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",
|
||||
"share": "Compartir"
|
||||
"info": "Obtener información"
|
||||
},
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
|
||||
},
|
||||
"actions": {
|
||||
"topSongs": "Más destacadas",
|
||||
"shuffle": "Aleatorio",
|
||||
"radio": "Radio",
|
||||
"topSongs": "Más destacadas"
|
||||
"radio": "Radio"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -141,6 +141,7 @@
|
||||
"userName": "Nombre de usuario",
|
||||
"isAdmin": "Es administrador",
|
||||
"lastLoginAt": "Último inicio de sesión",
|
||||
"lastAccessAt": "Último acceso",
|
||||
"updatedAt": "Actualizado el",
|
||||
"name": "Nombre",
|
||||
"password": "Contraseña",
|
||||
@@ -149,7 +150,6 @@
|
||||
"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 default",
|
||||
"defaultBitRate": "Tasa de bits por defecto",
|
||||
"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,11 +239,12 @@
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"name": "Compartir",
|
||||
"name": "Compartir |||| Compartidos",
|
||||
"fields": {
|
||||
"username": "Nombre de usuario",
|
||||
"username": "Compartido por",
|
||||
"url": "URL",
|
||||
"description": "Descripción",
|
||||
"downloadable": "¿Permitir descargas?",
|
||||
"contents": "Contenido",
|
||||
"expiresAt": "Caduca el",
|
||||
"lastVisitedAt": "Visitado por última vez el",
|
||||
@@ -251,12 +252,14 @@
|
||||
"format": "Formato",
|
||||
"maxBitRate": "Tasa de bits Máx.",
|
||||
"updatedAt": "Actualizado el",
|
||||
"createdAt": "Creado el",
|
||||
"downloadable": "¿Permitir descargas?"
|
||||
}
|
||||
"createdAt": "Creado el"
|
||||
},
|
||||
"notifications": {},
|
||||
"actions": {}
|
||||
},
|
||||
"missing": {
|
||||
"name": "Faltante",
|
||||
"name": "Fichero faltante |||| Ficheros faltantes",
|
||||
"empty": "No faltan archivos",
|
||||
"fields": {
|
||||
"path": "Ruta",
|
||||
"size": "Tamaño",
|
||||
@@ -269,8 +272,7 @@
|
||||
},
|
||||
"notifications": {
|
||||
"removed": "Eliminado"
|
||||
},
|
||||
"empty": "No hay archivos perdidos"
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"name": "Biblioteca |||| Bibliotecas",
|
||||
@@ -290,7 +292,7 @@
|
||||
"totalMissingFiles": "Archivos faltantes",
|
||||
"totalSize": "Tamaño total",
|
||||
"totalDuration": "Duración",
|
||||
"defaultNewUsers": "Valor por defecto para los nuevos usuarios",
|
||||
"defaultNewUsers": "Por defecto para nuevos usuarios",
|
||||
"createdAt": "Creado",
|
||||
"updatedAt": "Actualizado"
|
||||
},
|
||||
@@ -300,20 +302,20 @@
|
||||
},
|
||||
"actions": {
|
||||
"scan": "Escanear biblioteca",
|
||||
"manageUsers": "Gestionar el acceso de usarios",
|
||||
"viewDetails": "Ver detalles",
|
||||
"quickScan": "Escaneo rápido",
|
||||
"fullScan": "Escaneo completo"
|
||||
"fullScan": "Escaneo completo",
|
||||
"manageUsers": "Gestionar el acceso de usarios",
|
||||
"viewDetails": "Ver detalles"
|
||||
},
|
||||
"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"
|
||||
"scanError": "Error al iniciar el escaneo. Revisa los registros",
|
||||
"scanCompleted": "El escaneo de la biblioteca se completó"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "El nombre de la biblioteca es obligatorio",
|
||||
@@ -328,6 +330,78 @@
|
||||
"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": {
|
||||
@@ -365,6 +439,7 @@
|
||||
"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",
|
||||
@@ -388,7 +463,6 @@
|
||||
"close_menu": "Cerrar menú",
|
||||
"unselect": "Deseleccionado",
|
||||
"skip": "Omitir",
|
||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||
"share": "Compartir",
|
||||
"download": "Descargar"
|
||||
},
|
||||
@@ -480,41 +554,47 @@
|
||||
"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})",
|
||||
"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"
|
||||
"downloadOriginalFormat": "Descargar formato original"
|
||||
},
|
||||
"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",
|
||||
@@ -525,28 +605,22 @@
|
||||
"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": "Ninguno",
|
||||
"album": "Álbum",
|
||||
"track": "Pista"
|
||||
},
|
||||
"lastfmNotConfigured": "La clave API de Last.fm no está configurada"
|
||||
"none": "Desactivado",
|
||||
"album": "Ganancia del álbum",
|
||||
"track": "Ganancia de pista"
|
||||
}
|
||||
}
|
||||
},
|
||||
"albumList": "Álbumes",
|
||||
"about": "Acerca de",
|
||||
"playlists": "Playlists",
|
||||
"sharedPlaylists": "Playlists Compartidas",
|
||||
"librarySelector": {
|
||||
"allLibraries": "Todas las bibliotecas (%{count})",
|
||||
"multipleLibraries": "%{selected} de %{total} bibliotecas",
|
||||
"selectLibraries": "Seleccionar bibliotecas",
|
||||
"none": "Ninguno"
|
||||
}
|
||||
"about": "Acerca de"
|
||||
},
|
||||
"player": {
|
||||
"playListsText": "Fila de reproducción",
|
||||
@@ -605,12 +679,17 @@
|
||||
"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",
|
||||
"selectiveScan": "Selectivo"
|
||||
"elapsedTime": "Tiempo transcurrido"
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "En reproducción",
|
||||
"empty": "Nada en reproducción",
|
||||
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
||||
},
|
||||
"help": {
|
||||
"title": "Atajos de teclado de Navidrome",
|
||||
@@ -620,15 +699,10 @@
|
||||
"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",
|
||||
"current_song": "Canción actual"
|
||||
"toggle_love": "Marca esta canción como favorita"
|
||||
}
|
||||
},
|
||||
"nowPlaying": {
|
||||
"title": "En reproducción",
|
||||
"empty": "Nada en reproducción",
|
||||
"minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,78 +328,6 @@
|
||||
"scanInProgress": "Skannaus käynnissä...",
|
||||
"noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Liitännäinen |||| Liitännäiset",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Nimi",
|
||||
"description": "Kuvaus",
|
||||
"version": "Versio",
|
||||
"author": "Tekijä",
|
||||
"website": "Verkkosivusto",
|
||||
"permissions": "Oikeudet",
|
||||
"enabled": "Käytössä",
|
||||
"status": "Tila",
|
||||
"path": "Polku",
|
||||
"lastError": "Virhe",
|
||||
"hasError": "Virhe",
|
||||
"updatedAt": "Päivitetty",
|
||||
"createdAt": "Asennettu",
|
||||
"configKey": "Avain",
|
||||
"configValue": "Arvo",
|
||||
"allUsers": "Salli kaikki käyttäjät",
|
||||
"selectedUsers": "Valitut käyttäjät",
|
||||
"allLibraries": "Salli kaikki kirjastot",
|
||||
"selectedLibraries": "Valitut kirjastot"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Tila",
|
||||
"info": "Lisäosan tiedot",
|
||||
"configuration": "Määritykset",
|
||||
"manifest": "Luettelo",
|
||||
"usersPermission": "Käyttäjäoikeudet",
|
||||
"libraryPermission": "Kirjaston oikeudet"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Käytössä",
|
||||
"disabled": "Ei käytössä"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Ota käyttöön",
|
||||
"disable": "Poista käytöstä",
|
||||
"disabledDueToError": "Korjaa virhe ennen käyttöönottoa",
|
||||
"disabledUsersRequired": "Valitse käyttäjät ennen käyttöönottoa",
|
||||
"disabledLibrariesRequired": "Valitse kirjastot ennen käyttöönottoa",
|
||||
"addConfig": "Lisää määritykset",
|
||||
"rescan": "Skannaa uudelleen"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Lisäosa käytössä",
|
||||
"disabled": "Lisäosa ei käytössä",
|
||||
"updated": "Lisäosa päivitetty",
|
||||
"error": "Virhe lisäosaa päivitettäessä"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Määrityksen on oltava kelvollinen JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "Määritä lisäosa avain-arvo-parien avulla. Jätä tyhjäksi, jos lisäosa ei vaadi määrityksiä.",
|
||||
"clickPermissions": "Napsauta käyttöoikeutta saadaksesi lisätietoja",
|
||||
"noConfig": "Ei määritettyjä asetuksia",
|
||||
"allUsersHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin käyttäjiin, myös tulevaisuudessa luotaviin.",
|
||||
"noUsers": "Ei valittuja käyttäjiä",
|
||||
"permissionReason": "Syy",
|
||||
"usersRequired": "Tämä laajennus vaatii pääsyn käyttäjätietoihin. Valitse käyttäjät, joihin laajennus voi päästä, tai ota käyttöön 'Salli kaikki käyttäjät'.",
|
||||
"allLibrariesHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin kirjastoihin, myös tulevaisuudessa luotaviin.",
|
||||
"noLibraries": "Ei valittuja kirjastoja",
|
||||
"librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.",
|
||||
"requiredHosts": "Vaaditut palvelimet"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "avain",
|
||||
"configValue": "arvo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -658,16 +586,16 @@
|
||||
},
|
||||
"tabs": {
|
||||
"about": "Tietoja",
|
||||
"config": "Määritykset"
|
||||
"config": "Kokoonpano"
|
||||
},
|
||||
"config": {
|
||||
"configName": "Konfiguraation nimi",
|
||||
"environmentVariable": "Ympäristömuuttuja",
|
||||
"currentValue": "Nykyinen arvo",
|
||||
"configurationFile": "Määritystiedosto",
|
||||
"exportToml": "Vie määritys (TOML)",
|
||||
"exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa",
|
||||
"exportFailed": "Määritysten kopiointi epäonnistui",
|
||||
"configurationFile": "Konfiguraatiotiedosto",
|
||||
"exportToml": "Vie konfiguraatio (TOML)",
|
||||
"exportSuccess": "Konfiguraatio viety leikepöydälle TOML-muodossa",
|
||||
"exportFailed": "Konfiguraation kopiointi epäonnistui",
|
||||
"devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
|
||||
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
|
||||
}
|
||||
|
||||
@@ -328,78 +328,6 @@
|
||||
"scanInProgress": "Scan is bezig...",
|
||||
"noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "Plugin |||| Plugins",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "Naam",
|
||||
"description": "Omschrijving",
|
||||
"version": "Versie",
|
||||
"author": "Auteur",
|
||||
"website": "Website",
|
||||
"permissions": "Permissies",
|
||||
"enabled": "Aangezet",
|
||||
"status": "Status",
|
||||
"path": "Pad",
|
||||
"lastError": "Fout",
|
||||
"hasError": "Fout",
|
||||
"updatedAt": "Geupdate",
|
||||
"createdAt": "Geinstalleerd",
|
||||
"configKey": "Sleutel",
|
||||
"configValue": "Waarde",
|
||||
"allUsers": "Alle gebruikers toelaten",
|
||||
"selectedUsers": "Geselecteerde gebruikers",
|
||||
"allLibraries": "Alle bibliotheken toestaan",
|
||||
"selectedLibraries": "Geselecteerde bibliotheken"
|
||||
},
|
||||
"sections": {
|
||||
"status": "Status",
|
||||
"info": "Plugin informatie",
|
||||
"configuration": "Configuratie",
|
||||
"manifest": "Manifest",
|
||||
"usersPermission": "Gebruikers permissie",
|
||||
"libraryPermission": "Bibliotheekpermissie"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Aangezet",
|
||||
"disabled": "Uitgezet"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "Aanzetten",
|
||||
"disable": "Uitzetten",
|
||||
"disabledDueToError": "Herstel de fout voor aanzetten",
|
||||
"disabledUsersRequired": "Selecteer gebruikers voor aanzetten",
|
||||
"disabledLibrariesRequired": "Selecteer bibliotheek voor aanzetten",
|
||||
"addConfig": "Configuratie toevoegen",
|
||||
"rescan": "Opnieuw scannen"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "Plugin actief",
|
||||
"disabled": "Plugin niet actief",
|
||||
"updated": "Plugin geupdate",
|
||||
"error": "Fout bij updaten plugin"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "Configuratie moet geldige JSON zijn"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "",
|
||||
"clickPermissions": "Klik op permissie voor details",
|
||||
"noConfig": "Geen configuratie ingesteld",
|
||||
"allUsersHelp": "",
|
||||
"noUsers": "Geen gebruikers geselecteerd",
|
||||
"permissionReason": "Reden",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"noLibraries": "Geen bibliotheken geselecteerd",
|
||||
"librariesRequired": "",
|
||||
"requiredHosts": "Benodigde hosts"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "Sleutel",
|
||||
"configValue": "Waarde"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
|
||||
@@ -301,19 +301,14 @@
|
||||
"actions": {
|
||||
"scan": "Skeniraj knjižnico",
|
||||
"manageUsers": "Upravljanje dostopa uporabnikov",
|
||||
"viewDetails": "Ogled podrobnosti",
|
||||
"quickScan": "Hitro skeniranje",
|
||||
"fullScan": "Popolno skeniranje"
|
||||
"viewDetails": "Ogled podrobnosti"
|
||||
},
|
||||
"notifications": {
|
||||
"created": "Knjižnica je uspešno ustvarjena",
|
||||
"updated": "Knjižnica je bila uspešno posodobljena",
|
||||
"deleted": "Knjižnica je uspešno izbrisana",
|
||||
"scanStarted": "Skeniranje knjižnice se je začelo",
|
||||
"scanCompleted": "Skeniranje knjižnice končano",
|
||||
"quickScanStarted": "Hitro skeniranje se je začelo",
|
||||
"fullScanStarted": "Popolno skeniranje se je začelo",
|
||||
"scanError": "Napaka pri začetku skeniranja. Preverite dnevnike"
|
||||
"scanCompleted": "Skeniranje knjižnice končano"
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Ime knjižnice je obvezno",
|
||||
@@ -328,78 +323,6 @@
|
||||
"scanInProgress": "Skeniranje v teku...",
|
||||
"noLibrariesAssigned": "Uporabnik nima dodeljenih knjižnic"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "",
|
||||
"fields": {
|
||||
"id": "",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"version": "",
|
||||
"author": "",
|
||||
"website": "",
|
||||
"permissions": "",
|
||||
"enabled": "",
|
||||
"status": "",
|
||||
"path": "",
|
||||
"lastError": "",
|
||||
"hasError": "",
|
||||
"updatedAt": "",
|
||||
"createdAt": "",
|
||||
"configKey": "",
|
||||
"configValue": "",
|
||||
"allUsers": "",
|
||||
"selectedUsers": "",
|
||||
"allLibraries": "",
|
||||
"selectedLibraries": ""
|
||||
},
|
||||
"sections": {
|
||||
"status": "",
|
||||
"info": "",
|
||||
"configuration": "",
|
||||
"manifest": "",
|
||||
"usersPermission": "",
|
||||
"libraryPermission": ""
|
||||
},
|
||||
"status": {
|
||||
"enabled": "",
|
||||
"disabled": ""
|
||||
},
|
||||
"actions": {
|
||||
"enable": "",
|
||||
"disable": "",
|
||||
"disabledDueToError": "",
|
||||
"disabledUsersRequired": "",
|
||||
"disabledLibrariesRequired": "",
|
||||
"addConfig": "",
|
||||
"rescan": ""
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "",
|
||||
"disabled": "",
|
||||
"updated": "",
|
||||
"error": ""
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": ""
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "",
|
||||
"clickPermissions": "",
|
||||
"noConfig": "",
|
||||
"allUsersHelp": "",
|
||||
"noUsers": "",
|
||||
"permissionReason": "",
|
||||
"usersRequired": "",
|
||||
"allLibrariesHelp": "",
|
||||
"noLibraries": "",
|
||||
"librariesRequired": "",
|
||||
"requiredHosts": ""
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "",
|
||||
"configValue": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
@@ -681,8 +604,7 @@
|
||||
"serverDown": "NEPOVEZAN",
|
||||
"scanType": "Tip",
|
||||
"status": "Napaka pri skeniranju",
|
||||
"elapsedTime": "Pretečeni čas",
|
||||
"selectiveScan": "Selektivno"
|
||||
"elapsedTime": "Pretečeni čas"
|
||||
},
|
||||
"help": {
|
||||
"title": "Hitre tipke",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"playCount": "Spelningar",
|
||||
"title": "Titel",
|
||||
"artist": "Artist",
|
||||
"composer": "Kompositör",
|
||||
"album": "Album",
|
||||
"path": "Sökväg",
|
||||
"genre": "Genre",
|
||||
|
||||
@@ -328,78 +328,6 @@
|
||||
"scanInProgress": "กำลังสแกน...",
|
||||
"noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
|
||||
}
|
||||
},
|
||||
"plugin": {
|
||||
"name": "ปลั๊กอิน |||| ปลั๊กอิน",
|
||||
"fields": {
|
||||
"id": "ID",
|
||||
"name": "ชื่อ",
|
||||
"description": "รายละเอียด",
|
||||
"version": "เวอร์ชั่น",
|
||||
"author": "ผู้สร้าง",
|
||||
"website": "เว็บไซต์",
|
||||
"permissions": "การอนุญาติ",
|
||||
"enabled": "เปิดใช้",
|
||||
"status": "สถานะ",
|
||||
"path": "เส้นทาง",
|
||||
"lastError": "ผิดพลาด",
|
||||
"hasError": "ผิดพลาด",
|
||||
"updatedAt": "อัพเดทแล้ว",
|
||||
"createdAt": "ติดตั้งแล้ว",
|
||||
"configKey": "คีย์",
|
||||
"configValue": "ค่า",
|
||||
"allUsers": "อนุญาติผู้ใช้ทั้งหมด",
|
||||
"selectedUsers": "ผู้ใช้ถูกเลือก",
|
||||
"allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด",
|
||||
"selectedLibraries": "ห้องสมุดเพลงถูกเลือก"
|
||||
},
|
||||
"sections": {
|
||||
"status": "สถานะ",
|
||||
"info": "ข้อมูลปลั๊กอิน",
|
||||
"configuration": "การตั้งค่า",
|
||||
"manifest": "แสดง",
|
||||
"usersPermission": "สิทธิของผู้ใช้",
|
||||
"libraryPermission": "สิทธิของห้องสมุดเพลง"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "เปิดใช้งานแล้ว",
|
||||
"disabled": "ปิดใช้งานแล้ว"
|
||||
},
|
||||
"actions": {
|
||||
"enable": "เปิดใช้งาน",
|
||||
"disable": "ปิดใช้งาน",
|
||||
"disabledDueToError": "แก้ไขข้อผิดพลาดก่อนเปิดใช้งาน",
|
||||
"disabledUsersRequired": "เลือกผู้ใช้ที่จะเปิดใช้งาน",
|
||||
"disabledLibrariesRequired": "เลือกห้องสมุดเพลงที่จะเปิดใช้งาน",
|
||||
"addConfig": "เพิ่มการตั้งค่า",
|
||||
"rescan": "สแกนซ้ำ"
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": "เปิดใช้ปลั๊กอินแล้ว",
|
||||
"disabled": "ปิดใช้ปลั๊กอินแล้ว",
|
||||
"updated": "ปลั๊กอินอัพเดท",
|
||||
"error": "อัพเดทผิดพลาด"
|
||||
},
|
||||
"validation": {
|
||||
"invalidJson": "ต้องตั้งค่าตามไวยากรณ์ JSON"
|
||||
},
|
||||
"messages": {
|
||||
"configHelp": "ใส่ค่าให้เข้าคู่กับคีย์ของปลั๊กอิน ปล่อยว่างถ้าปลั๊กอินไม่ต้องการใช้",
|
||||
"clickPermissions": "กดดูรายละเอียดของการอนุญาติ",
|
||||
"noConfig": "ไม่ได้ตั้งค่า",
|
||||
"allUsersHelp": "เมื่อเปิดใช้ ปลั๊กอินจะใช้กับผู้ใช้ทุกคน รวมถึงผู้ใช้ใหม่ในอนาคต",
|
||||
"noUsers": "ไม่ได้เลือกผู้ใช้",
|
||||
"permissionReason": "เหตุผล",
|
||||
"usersRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลผู้ใช้ เลือกผู้ใช้ที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับผู้ใช้ทั้งหมด",
|
||||
"allLibrariesHelp": "เมื่อเปิดใช้งาน ปลั๊กอินจะเข้าถึงทุกห้องสมุดเพลง รวมถึงของผู้ใช้ใหม่ในอนาคต",
|
||||
"noLibraries": "ไม่มีห้องสมุดเพลงถูกเลือก",
|
||||
"librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด",
|
||||
"requiredHosts": "ต้องการ Host"
|
||||
},
|
||||
"placeholders": {
|
||||
"configKey": "คีย์",
|
||||
"configValue": "ค่า"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ra": {
|
||||
|
||||
@@ -410,6 +410,9 @@ 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
|
||||
}
|
||||
@@ -447,6 +450,9 @@ 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 {
|
||||
|
||||
@@ -101,6 +101,9 @@ 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
|
||||
}
|
||||
@@ -116,6 +119,9 @@ 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
|
||||
}
|
||||
@@ -218,6 +224,9 @@ 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 {
|
||||
@@ -329,6 +338,9 @@ 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
|
||||
}
|
||||
@@ -422,6 +434,9 @@ 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}
|
||||
})
|
||||
|
||||
@@ -456,4 +456,131 @@ var _ = Describe("helpers", func() {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("AverageRating in responses", func() {
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
conf.Server.Subsonic.EnableAverageRating = true
|
||||
})
|
||||
|
||||
Describe("childFromMediaFile", func() {
|
||||
It("includes averageRating when set", func() {
|
||||
mf := model.MediaFile{
|
||||
ID: "mf-avg-1",
|
||||
Title: "Test Song",
|
||||
Annotations: model.Annotations{
|
||||
AverageRating: 4.5,
|
||||
},
|
||||
}
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.AverageRating).To(Equal(4.5))
|
||||
})
|
||||
|
||||
It("returns 0 for averageRating when not set", func() {
|
||||
mf := model.MediaFile{
|
||||
ID: "mf-avg-2",
|
||||
Title: "Test Song No Rating",
|
||||
}
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.AverageRating).To(Equal(0.0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("childFromAlbum", func() {
|
||||
It("includes averageRating when set", func() {
|
||||
al := model.Album{
|
||||
ID: "al-avg-1",
|
||||
Name: "Test Album",
|
||||
Annotations: model.Annotations{
|
||||
AverageRating: 3.75,
|
||||
},
|
||||
}
|
||||
child := childFromAlbum(ctx, al)
|
||||
Expect(child.AverageRating).To(Equal(3.75))
|
||||
})
|
||||
|
||||
It("returns 0 for averageRating when not set", func() {
|
||||
al := model.Album{
|
||||
ID: "al-avg-2",
|
||||
Name: "Test Album No Rating",
|
||||
}
|
||||
child := childFromAlbum(ctx, al)
|
||||
Expect(child.AverageRating).To(Equal(0.0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("toArtist", func() {
|
||||
It("includes averageRating when set", func() {
|
||||
conf.Server.Subsonic.EnableAverageRating = true
|
||||
r := httptest.NewRequest("GET", "/test", nil)
|
||||
a := model.Artist{
|
||||
ID: "ar-avg-1",
|
||||
Name: "Test Artist",
|
||||
Annotations: model.Annotations{
|
||||
AverageRating: 5.0,
|
||||
},
|
||||
}
|
||||
artist := toArtist(r, a)
|
||||
Expect(artist.AverageRating).To(Equal(5.0))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("toArtistID3", func() {
|
||||
It("includes averageRating when set", func() {
|
||||
conf.Server.Subsonic.EnableAverageRating = true
|
||||
r := httptest.NewRequest("GET", "/test", nil)
|
||||
a := model.Artist{
|
||||
ID: "ar-avg-2",
|
||||
Name: "Test Artist ID3",
|
||||
Annotations: model.Annotations{
|
||||
AverageRating: 2.5,
|
||||
},
|
||||
}
|
||||
artist := toArtistID3(r, a)
|
||||
Expect(artist.AverageRating).To(Equal(2.5))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("EnableAverageRating config", func() {
|
||||
It("excludes averageRating when disabled", func() {
|
||||
conf.Server.Subsonic.EnableAverageRating = false
|
||||
|
||||
mf := model.MediaFile{
|
||||
ID: "mf-cfg-1",
|
||||
Title: "Test Song",
|
||||
Annotations: model.Annotations{
|
||||
AverageRating: 4.5,
|
||||
},
|
||||
}
|
||||
child := childFromMediaFile(ctx, mf)
|
||||
Expect(child.AverageRating).To(Equal(0.0))
|
||||
|
||||
al := model.Album{
|
||||
ID: "al-cfg-1",
|
||||
Name: "Test Album",
|
||||
Annotations: model.Annotations{
|
||||
AverageRating: 3.75,
|
||||
},
|
||||
}
|
||||
albumChild := childFromAlbum(ctx, al)
|
||||
Expect(albumChild.AverageRating).To(Equal(0.0))
|
||||
|
||||
r := httptest.NewRequest("GET", "/test", nil)
|
||||
a := model.Artist{
|
||||
ID: "ar-cfg-1",
|
||||
Name: "Test Artist",
|
||||
Annotations: model.Annotations{
|
||||
AverageRating: 5.0,
|
||||
},
|
||||
}
|
||||
artist := toArtist(r, a)
|
||||
Expect(artist.AverageRating).To(Equal(0.0))
|
||||
|
||||
artistID3 := toArtistID3(r, a)
|
||||
Expect(artistID3.AverageRating).To(Equal(0.0))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,11 +95,9 @@ type Artist struct {
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||
CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"`
|
||||
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
||||
/* TODO:
|
||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
|
||||
*/
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
@@ -160,13 +158,11 @@ type Child struct {
|
||||
ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||
Type string `xml:"type,attr,omitempty" json:"type,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||
SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"`
|
||||
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 -->
|
||||
*/
|
||||
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
|
||||
*OpenSubsonicChild `xml:",omitempty" json:",omitempty"`
|
||||
}
|
||||
|
||||
type OpenSubsonicChild struct {
|
||||
@@ -198,14 +194,15 @@ type Songs struct {
|
||||
}
|
||||
|
||||
type Directory struct {
|
||||
Child []Child `xml:"child" json:"child,omitempty"`
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
Child []Child `xml:"child" json:"child,omitempty"`
|
||||
Id string `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Parent string `xml:"parent,attr,omitempty" json:"parent,omitempty"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"`
|
||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||
|
||||
// ID3
|
||||
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
|
||||
@@ -217,10 +214,6 @@ type Directory struct {
|
||||
Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"`
|
||||
Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
|
||||
|
||||
/*
|
||||
<xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 -->
|
||||
*/
|
||||
}
|
||||
|
||||
// ArtistID3Ref is a reference to an artist, a simplified version of ArtistID3. This is used to resolve the
|
||||
@@ -237,6 +230,7 @@ type ArtistID3 struct {
|
||||
AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"`
|
||||
Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,omitempty"`
|
||||
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||
ArtistImageUrl string `xml:"artistImageUrl,attr,omitempty" json:"artistImageUrl,omitempty"`
|
||||
*OpenSubsonicArtistID3 `xml:",omitempty" json:",omitempty"`
|
||||
}
|
||||
@@ -268,6 +262,7 @@ type OpenSubsonicAlbumID3 struct {
|
||||
// OpenSubsonic extensions
|
||||
Played *time.Time `xml:"played,attr,omitempty" json:"played,omitempty"`
|
||||
UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating"`
|
||||
AverageRating float64 `xml:"averageRating,attr,omitempty" json:"averageRating,omitempty"`
|
||||
Genres Array[ItemGenre] `xml:"genres,omitempty" json:"genres"`
|
||||
MusicBrainzId string `xml:"musicBrainzId,attr,omitempty" json:"musicBrainzId"`
|
||||
IsCompilation bool `xml:"isCompilation,attr,omitempty" json:"isCompilation"`
|
||||
|
||||
@@ -108,6 +108,9 @@ const AlbumSongs = (props) => {
|
||||
/>
|
||||
),
|
||||
artist: isDesktop && <ArtistLinkField source="artist" sortable={false} />,
|
||||
composer: isDesktop && (
|
||||
<ArtistLinkField source="composer" sortable={false} />
|
||||
),
|
||||
duration: <DurationField source="duration" sortable={false} />,
|
||||
year: isDesktop && (
|
||||
<FunctionField
|
||||
@@ -148,6 +151,7 @@ const AlbumSongs = (props) => {
|
||||
columns: toggleableFields,
|
||||
omittedColumns: ['title'],
|
||||
defaultOff: [
|
||||
'composer',
|
||||
'channels',
|
||||
'bpm',
|
||||
'year',
|
||||
|
||||
@@ -95,6 +95,19 @@ const Player = () => {
|
||||
}
|
||||
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e) => {
|
||||
// Check there's a current track and is actually playing/not paused
|
||||
if (playerState.current?.uuid && audioInstance && !audioInstance.paused) {
|
||||
e.preventDefault()
|
||||
e.returnValue = '' // Chrome requires returnValue to be set
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [playerState, audioInstance])
|
||||
|
||||
const defaultOptions = useMemo(
|
||||
() => ({
|
||||
theme: playerTheme,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"playCount": "Plays",
|
||||
"title": "Title",
|
||||
"artist": "Artist",
|
||||
"composer": "Composer",
|
||||
"album": "Album",
|
||||
"path": "File path",
|
||||
"libraryName": "Library",
|
||||
|
||||
@@ -145,6 +145,7 @@ const SongList = (props) => {
|
||||
return {
|
||||
album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />,
|
||||
artist: <ArtistLinkField source="artist" />,
|
||||
composer: <ArtistLinkField source="composer" />,
|
||||
albumArtist: <ArtistLinkField source="albumArtist" />,
|
||||
trackNumber: isDesktop && <NumberField source="trackNumber" />,
|
||||
playCount: isDesktop && (
|
||||
@@ -192,6 +193,7 @@ const SongList = (props) => {
|
||||
resource: 'song',
|
||||
columns: toggleableFields,
|
||||
defaultOff: [
|
||||
'composer',
|
||||
'channels',
|
||||
'bpm',
|
||||
'playDate',
|
||||
|
||||
Reference in New Issue
Block a user