Compare commits

..

6 Commits

Author SHA1 Message Date
Terry Raimondo
03120bac32 feat(subsonic): Add avgRating from subsonic spec (#4900)
* feat(subsonic): add averageRating to API responses

Add averageRating attribute to Subsonic API responses for artists,
albums, and songs. The average is calculated across all user ratings.

* perf(db): add index for average rating queries

Add composite index on (item_id, item_type, rating) to optimize
the correlated subquery used for calculating average ratings.

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* test: add tests for averageRating feature

Add tests for:
- Album.AverageRating calculation in persistence layer
- MediaFile.AverageRating calculation in persistence layer
- AverageRating mapping in subsonic response helpers

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* test: improve averageRating rounding test with 3 users

Add third test user to fixtures and update rounding test to use
3 ratings (5 + 4 + 4) / 3 = 4.33 for proper decimal rounding coverage.

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>

* perf: store avg_rating on entity tables instead of using subquery

- Add avg_rating column to album, media_file, and artist tables
- Update SetRating() to recalculate and store average when ratings change
- Read avg_rating directly from entity table in withAnnotation()
- Remove old annotation index migration (no longer needed)

This trades write-time computation for read-time performance by
pre-computing the average rating instead of using a correlated
subquery on every read.

* feat: add Subsonic.EnableAverageRating config option (default true)

Allow administrators to disable exposing averageRating in Subsonic API
responses if they don't want to expose other users' rating data.

The avg_rating column is still updated internally when users rate items,
but the value is only included in API responses when this option is enabled.

* address PR comments

- Use structs:"avg_rating" with db:"avg_rating" tag instead of SQL alias
- Remove avg_rating indexes (not needed)
- Populate avg_rating columns from existing ratings in migration

* Woops

* rename avg_rating column to average_rating

---------

Signed-off-by: Terry Raimondo <terry.raimondo@gmail.com>
2026-01-18 17:42:42 -05:00
Deluan
0473c50b49 feat(insights): add file suffix counting 2026-01-18 17:00:35 -05:00
Deluan Quintão
2de2484bca feat: add go-taglib pure Go metadata extractor (#4902)
* feat: implement go-taglib extractor

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance ID3v2 frame parsing for language-specific lyrics

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: add support for reading iTunes-specific tags from M4A files

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: expose BitDepth in AudioProperties struct

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance WMA tag parsing by adding support for ASF attributes

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance ID3v2 frame parsing for WAV and AIFF formats to support language codes

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: usa a ignored go.work for local dependency management

* feat: optimize metadata extraction by consolidating file reads and improving tag processing

Signed-off-by: Deluan <deluan@navidrome.org>

* remove comment

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: improve language code extraction for lyrics tags in metadata processing

Signed-off-by: Deluan <deluan@navidrome.org>

* address PR comments

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: remove outdated comments in gotaglib.go

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: enhance extractor to utilize filesystem for file handling

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: update go-taglib dependency version in go.mod and go.sum

Signed-off-by: Deluan <deluan@navidrome.org>

* feat: make new go-taglib extractor default

Signed-off-by: Deluan <deluan@navidrome.org>

* chore: formatting

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-01-18 14:42:53 -05:00
Albert Brugués
64e165aaef fix(ui): update Spanish translations (#4904)
* update spanish translations

* fix typo in word Arreglistas

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix missing pipe char

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix invalidJson value

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix click translation in clickPermissions key

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix remove_missing_title value

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix remove_all_missing_title value

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix missing accent

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix missing accents

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix disabled translation

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-18 13:28:40 -05:00
Alex Gustafsson
8e96dd0784 feat(ui): add composer field to table views (#4857)
* feat(ui): Add composer field to datatables

In order to make the UI a bit more useful for classical music, where the
recorded artist isn't the composer of the work, add the composer field
to the song and album datatables.

To not affect existing users, the field is default off.

* Fix typo

* Remove composer field for albums

Albums can have more than one composer. Showing all or just one of them
in a list doesn't really make sense.

* Format code
2026-01-18 13:15:53 -05:00
Alanna Tempest
9bd91d2c04 feat(ui): prompt before closing window if music is playing (#4899)
* feat(ui): prompt before closing window if music is playing - #4898

* simplify logic
2026-01-18 13:11:12 -05:00
39 changed files with 1990 additions and 1118 deletions

3
.gitignore vendored
View File

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

View 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))
}
}
})
})
})

View 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}
})
}

View 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")
}

View 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())
})
})
})
})

View File

@@ -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) {

View File

@@ -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}
})

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"`

View 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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})
}

View File

@@ -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())

View File

@@ -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 {

View File

@@ -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 {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}
}

View File

@@ -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"
}

View File

@@ -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": {

View File

@@ -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",

View File

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

View File

@@ -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": {

View File

@@ -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 {

View File

@@ -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}
})

View File

@@ -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))
})
})
})
})

View File

@@ -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"`

View File

@@ -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',

View File

@@ -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,

View File

@@ -10,6 +10,7 @@
"playCount": "Plays",
"title": "Title",
"artist": "Artist",
"composer": "Composer",
"album": "Album",
"path": "File path",
"libraryName": "Library",

View File

@@ -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',