From 46b4dcd5f67182611c077607601491d9d95944c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 28 Apr 2026 19:40:08 -0400 Subject: [PATCH 01/60] feat(smartplaylist): add isMissing and isPresent operators (#5436) * feat(smartplaylist): add IsMissing and IsPresent operator types Add two new Expression types for detecting absent/present tags and roles in smart playlist criteria. Includes JSON marshal/unmarshal support and Walk visitor registration. * test(smartplaylist): add JSON marshal/unmarshal tests for isMissing/isPresent * feat(smartplaylist): add SQL generation for isMissing/isPresent operators Tags check json_tree(media_file.tags) for key existence. Roles check json_tree(media_file.participants) for key existence. Regular DB column fields are rejected with an error. * test(smartplaylist): add e2e tests for isMissing/isPresent operators Tests cover tag presence/absence with selective matching (grouping), universal absence (lyricist role), universal presence (composer role), and combined operator usage. * refactor(smartplaylist): use strconv.ParseBool in IsTruthy Replace hand-rolled string truthiness check with strconv.ParseBool, which correctly handles standard boolean strings and rejects unrecognized values as false. * refactor(smartplaylist): clarify missingExpr parameter naming Rename defaultNegate to checkAbsence and extract truthy local for readability. The XNOR logic (checkAbsence == truthy) is now easier to follow: isMissing passes true, isPresent passes false. * refactor(smartplaylist): reuse jsonExpr in missingExpr, improve errors - tagCond/roleCond now handle nil cond (existence-only check) - missingExpr delegates to jsonExpr(info, nil, negate) instead of building SQL manually - Better error messages: unknown fields now report the field name --- model/criteria/json.go | 4 +++ model/criteria/operators.go | 35 +++++++++++++++++++- model/criteria/operators_test.go | 4 +++ model/criteria/walk.go | 2 +- persistence/criteria_sql.go | 46 +++++++++++++++++++++++---- persistence/criteria_sql_test.go | 30 +++++++++++++++++ persistence/e2e/e2e_suite_test.go | 4 +-- persistence/e2e/smartplaylist_test.go | 41 ++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 10 deletions(-) diff --git a/model/criteria/json.go b/model/criteria/json.go index f6ab56eda..18a664988 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -69,6 +69,10 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { return InPlaylist(m) case "notinplaylist": return NotInPlaylist(m) + case "ismissing": + return IsMissing(m) + case "ispresent": + return IsPresent(m) } return nil } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 983c6aa1a..7def18934 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,6 +1,9 @@ package criteria -import "time" +import ( + "strconv" + "time" +) // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { @@ -162,6 +165,36 @@ func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { func (nipl NotInPlaylist) fields() map[string]any { return nipl } +type IsMissing map[string]any + +func (im IsMissing) MarshalJSON() ([]byte, error) { + return marshalExpression("isMissing", im) +} + +func (im IsMissing) fields() map[string]any { return im } + +type IsPresent map[string]any + +func (ip IsPresent) MarshalJSON() ([]byte, error) { + return marshalExpression("isPresent", ip) +} + +func (ip IsPresent) fields() map[string]any { return ip } + +func IsTruthy(v any) bool { + switch val := v.(type) { + case bool: + return val + case float64: + return val != 0 + case string: + b, err := strconv.ParseBool(val) + return err == nil && b + default: + return v != nil + } +} + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index c93e8f2b2..bfdca3e31 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -46,5 +46,9 @@ var _ = Describe("Operators", func() { Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`), Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`), Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`), + Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`), + Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`), + Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), + Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) }) diff --git a/model/criteria/walk.go b/model/criteria/walk.go index acaf48289..7445c2aef 100644 --- a/model/criteria/walk.go +++ b/model/criteria/walk.go @@ -24,7 +24,7 @@ func Walk(expr Expression, visit Visitor) error { return err } } - case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist: + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent: return nil default: return fmt.Errorf("unknown criteria expression type %T", expr) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 1431f0d0e..8fca6b2a2 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -185,6 +185,10 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz return c.inList(e, false) case criteria.NotInPlaylist: return c.inList(e, true) + case criteria.IsMissing: + return missingExpr(e, true) + case criteria.IsPresent: + return missingExpr(e, false) default: return nil, fmt.Errorf("unknown criteria expression type %T", expr) } @@ -201,6 +205,22 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { return squirrel.NotEq(fields), nil } +func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { + field, value, info, ok := singleField(values) + if !ok { + if len(values) != 1 { + return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field") + } + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if !info.IsTag && !info.IsRole { + return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) + } + + negate := checkAbsence == criteria.IsTruthy(value) + return jsonExpr(info, nil, negate), nil +} + func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil @@ -327,11 +347,18 @@ type tagCond struct { } func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - if e.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag) } - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) if e.not { cond = "not " + cond } @@ -345,8 +372,15 @@ type roleCond struct { } func (e roleCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + } if e.not { cond = "not " + cond } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index e02032d9a..a2212c4e5 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,26 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + // isMissing — tags + Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isMissing tag [false]", criteria.IsMissing{"genre": false}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isMissing — roles + Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, + "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, + "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + // isPresent — tags + Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isPresent tag [false]", criteria.IsPresent{"genre": false}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + // isPresent — roles + Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, + "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, + "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), ) Describe("playlist permissions", func() { @@ -115,6 +135,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError("invalid field in criteria: unknown")) }) + It("returns an error when isMissing is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + + It("returns an error when isPresent is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index ea0d0fea8..f42292f02 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -116,9 +116,9 @@ func buildTestFS() { fs := storagetest.FakeFS{} fs.SetFiles(fstest.MapFS{ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100})), + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 086e73703..a844dc982 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -330,4 +330,45 @@ var _ = Describe("Smart Playlists", func() { }) }) + + Describe("isMissing/isPresent operators", func() { + It("isMissing finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent false finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing returns all tracks for a tag nobody has", func() { + results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent returns all tracks for a role everyone has", func() { + results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("combines isMissing with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) }) From d9dac44456187beb98331811a64c287dc3767f6d Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 19:46:12 -0400 Subject: [PATCH 02/60] feat(smartplaylist): add ReplayGain fields to criteria system Expose the four ReplayGain database columns (rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak) as first-class numeric criteria fields for smart playlists. This allows users to filter tracks by ReplayGain values, e.g. finding tracks with album gain below a threshold. --- model/criteria/fields.go | 4 ++++ persistence/criteria_sql.go | 4 ++++ persistence/criteria_sql_test.go | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 20c0048b3..37b4cc017 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -72,6 +72,10 @@ var fieldMap = map[string]FieldInfo{ "mbz_recording_id": {Name: "mbz_recording_id"}, "mbz_release_track_id": {Name: "mbz_release_track_id"}, "mbz_release_group_id": {Name: "mbz_release_group_id"}, + "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, + "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, + "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, + "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, "library_id": {Name: "library_id", Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 8fca6b2a2..123ec29eb 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -111,6 +111,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "rgalbumgain": {expr: "media_file.rg_album_gain"}, + "rgalbumpeak": {expr: "media_file.rg_album_peak"}, + "rgtrackgain": {expr: "media_file.rg_track_gain"}, + "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, "value": {expr: "value"}, diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index a2212c4e5..c93465735 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + // ReplayGain fields + Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), + Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), + Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0), // isMissing — tags Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), From 0fd9c6df2eaf653186228d59a4a9a460b6db69d8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 20:22:48 -0400 Subject: [PATCH 03/60] refactor(smartplaylist): clarify FieldInfo naming in criteria package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename FieldInfo.Name to Alias (only meaningful for backward-compat entries like albumtype→releasetype) and FieldInfo.alias to tagAlias (tag names from mappings.yml that resolve to existing fields). Add a Name() method that derives the canonical name from the map key, eliminating 60+ redundant Name declarations where the value matched the key. LookupField now populates the private name field automatically. Signed-off-by: Deluan --- model/criteria/fields.go | 171 +++++++++++++++++-------------- model/criteria/fields_test.go | 12 +-- model/criteria/sort.go | 2 +- persistence/criteria_sql.go | 16 +-- persistence/criteria_sql_test.go | 4 +- 5 files changed, 111 insertions(+), 94 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 37b4cc017..44d43457b 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -2,87 +2,94 @@ package criteria import "strings" -// FieldInfo contains semantic metadata about a criteria field +// FieldInfo contains semantic metadata about a criteria field. type FieldInfo struct { - Name string + Alias string // If set, this field is a backward-compat alias for another canonical name IsTag bool IsRole bool Numeric bool - alias string + + tagAlias string // If set, a tag name from mappings.yml that resolves to this field + name string // Canonical name, populated by LookupField from the map key +} + +// Name returns the canonical field name (the map key used to register this field). +func (f FieldInfo) Name() string { + return f.name } var fieldMap = map[string]FieldInfo{ - "title": {Name: "title"}, - "album": {Name: "album"}, - "hascoverart": {Name: "hascoverart"}, - "tracknumber": {Name: "tracknumber"}, - "discnumber": {Name: "discnumber"}, - "year": {Name: "year"}, - "date": {Name: "date", alias: "recordingdate"}, - "originalyear": {Name: "originalyear"}, - "originaldate": {Name: "originaldate"}, - "releaseyear": {Name: "releaseyear"}, - "releasedate": {Name: "releasedate"}, - "size": {Name: "size"}, - "compilation": {Name: "compilation"}, - "missing": {Name: "missing"}, - "explicitstatus": {Name: "explicitstatus"}, - "dateadded": {Name: "dateadded"}, - "datemodified": {Name: "datemodified"}, - "discsubtitle": {Name: "discsubtitle"}, - "comment": {Name: "comment"}, - "lyrics": {Name: "lyrics"}, - "sorttitle": {Name: "sorttitle"}, - "sortalbum": {Name: "sortalbum"}, - "sortartist": {Name: "sortartist"}, - "sortalbumartist": {Name: "sortalbumartist"}, - "albumcomment": {Name: "albumcomment"}, - "catalognumber": {Name: "catalognumber"}, - "filepath": {Name: "filepath"}, - "filetype": {Name: "filetype"}, - "codec": {Name: "codec"}, - "duration": {Name: "duration"}, - "bitrate": {Name: "bitrate"}, - "bitdepth": {Name: "bitdepth"}, - "samplerate": {Name: "samplerate"}, - "bpm": {Name: "bpm"}, - "channels": {Name: "channels"}, - "loved": {Name: "loved"}, - "dateloved": {Name: "dateloved"}, - "lastplayed": {Name: "lastplayed"}, - "daterated": {Name: "daterated"}, - "playcount": {Name: "playcount"}, - "rating": {Name: "rating"}, - "averagerating": {Name: "averagerating", Numeric: true}, - "albumrating": {Name: "albumrating"}, - "albumloved": {Name: "albumloved"}, - "albumplaycount": {Name: "albumplaycount"}, - "albumlastplayed": {Name: "albumlastplayed"}, - "albumdateloved": {Name: "albumdateloved"}, - "albumdaterated": {Name: "albumdaterated"}, - "artistrating": {Name: "artistrating"}, - "artistloved": {Name: "artistloved"}, - "artistplaycount": {Name: "artistplaycount"}, - "artistlastplayed": {Name: "artistlastplayed"}, - "artistdateloved": {Name: "artistdateloved"}, - "artistdaterated": {Name: "artistdaterated"}, - "mbz_album_id": {Name: "mbz_album_id"}, - "mbz_album_artist_id": {Name: "mbz_album_artist_id"}, - "mbz_artist_id": {Name: "mbz_artist_id"}, - "mbz_recording_id": {Name: "mbz_recording_id"}, - "mbz_release_track_id": {Name: "mbz_release_track_id"}, - "mbz_release_group_id": {Name: "mbz_release_group_id"}, - "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, - "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, - "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, - "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, - "library_id": {Name: "library_id", Numeric: true}, + "title": {}, + "album": {}, + "hascoverart": {}, + "tracknumber": {}, + "discnumber": {}, + "year": {}, + "date": {tagAlias: "recordingdate"}, + "originalyear": {}, + "originaldate": {}, + "releaseyear": {}, + "releasedate": {}, + "size": {}, + "compilation": {}, + "missing": {}, + "explicitstatus": {}, + "dateadded": {}, + "datemodified": {}, + "discsubtitle": {}, + "comment": {}, + "lyrics": {}, + "sorttitle": {}, + "sortalbum": {}, + "sortartist": {}, + "sortalbumartist": {}, + "albumcomment": {}, + "catalognumber": {}, + "filepath": {}, + "filetype": {}, + "codec": {}, + "duration": {}, + "bitrate": {}, + "bitdepth": {}, + "samplerate": {}, + "bpm": {}, + "channels": {}, + "loved": {}, + "dateloved": {}, + "lastplayed": {}, + "daterated": {}, + "playcount": {}, + "rating": {}, + "averagerating": {Numeric: true}, + "albumrating": {}, + "albumloved": {}, + "albumplaycount": {}, + "albumlastplayed": {}, + "albumdateloved": {}, + "albumdaterated": {}, + "artistrating": {}, + "artistloved": {}, + "artistplaycount": {}, + "artistlastplayed": {}, + "artistdateloved": {}, + "artistdaterated": {}, + "mbz_album_id": {}, + "mbz_album_artist_id": {}, + "mbz_artist_id": {}, + "mbz_recording_id": {}, + "mbz_release_track_id": {}, + "mbz_release_group_id": {}, + "rgalbumgain": {Numeric: true}, + "rgalbumpeak": {Numeric: true}, + "rgtrackgain": {Numeric: true}, + "rgtrackpeak": {Numeric: true}, + "library_id": {Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {Name: "releasetype", IsTag: true}, + "albumtype": {Alias: "releasetype", IsTag: true}, - "random": {Name: "random"}, - "value": {Name: "value"}, + "random": {}, + "value": {}, } // AllFieldNames returns the names of all registered criteria fields. @@ -96,7 +103,15 @@ func AllFieldNames() []string { // LookupField returns semantic metadata for a criteria field name. func LookupField(name string) (FieldInfo, bool) { - f, ok := fieldMap[strings.ToLower(name)] + key := strings.ToLower(name) + f, ok := fieldMap[key] + if ok { + if f.Alias != "" { + f.name = f.Alias + } else { + f.name = key + } + } return f, ok } @@ -108,7 +123,7 @@ func AddRoles(roles []string) { if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = FieldInfo{Name: name, IsRole: true} + fieldMap[name] = FieldInfo{IsRole: true} } } @@ -120,14 +135,16 @@ func AddTagNames(tagNames []string) { if _, ok := fieldMap[name]; ok { continue } - for _, fm := range fieldMap { - if fm.alias == name { + for key, fm := range fieldMap { + if fm.tagAlias == name { + fm.Alias = key + fm.tagAlias = "" fieldMap[name] = fm break } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = FieldInfo{Name: name, IsTag: true} + fieldMap[name] = FieldInfo{IsTag: true} } } } @@ -140,7 +157,7 @@ func AddNumericTags(tagNames []string) { fm.Numeric = true fieldMap[name] = fm } else { - fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} + fieldMap[name] = FieldInfo{IsTag: true, Numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index ecbeb5857..f60666788 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -11,14 +11,14 @@ var _ = Describe("fields", func() { field, ok := LookupField("Title") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) + gomega.Expect(field.Name()).To(gomega.Equal("title")) }) - It("resolves aliases to their semantic field name", func() { + It("resolves aliases to their canonical field name", func() { field, ok := LookupField("albumtype") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("releasetype")) + gomega.Expect(field.Name()).To(gomega.Equal("releasetype")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -26,7 +26,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("value") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("value")) + gomega.Expect(field.Name()).To(gomega.Equal("value")) }) It("finds registered tag names", func() { @@ -35,7 +35,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_mood") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_mood")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -56,7 +56,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_producer") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) }) diff --git a/model/criteria/sort.go b/model/criteria/sort.go index 05b108cf9..e38fe0551 100644 --- a/model/criteria/sort.go +++ b/model/criteria/sort.go @@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField { if order == "desc" { desc = !desc } - fields = append(fields, SortField{Field: info.Name, Desc: desc}) + fields = append(fields, SortField{Field: info.Name(), Desc: desc}) } if len(fields) == 0 { log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 123ec29eb..4fd8e62e6 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -338,9 +338,9 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { - return roleCond{role: info.Name, cond: cond, not: negate} + return roleCond{role: info.Name(), cond: cond, not: negate} } - return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} + return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate} } type tagCond struct { @@ -412,7 +412,7 @@ func sqlFields(values map[string]any) (map[string]any, error) { if info.IsTag || info.IsRole { return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) } - sqlField, ok := fieldExpr(info.Name) + sqlField, ok := fieldExpr(info.Name()) if !ok || sqlField == "" { return nil, fmt.Errorf("invalid field in criteria: %s", field) } @@ -431,7 +431,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { if !ok { return smartPlaylistJoinNone } - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok { return smartPlaylistJoinNone } @@ -479,17 +479,17 @@ func sortExpr(sortField string) (string, bool) { if !ok { return "", false } - if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { + if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" { return field.order, true } var mapped string switch { case info.IsTag: - mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')" case info.IsRole: - mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')" default: - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok || field.expr == "" { return "", false } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index c93465735..40d21c9bf 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -194,8 +194,8 @@ var _ = Describe("Smart playlist criteria SQL", func() { if info.IsTag || info.IsRole { continue } - _, hasSQLField := smartPlaylistFields[info.Name] - Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name) + _, hasSQLField := smartPlaylistFields[info.Name()] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name()) } }) From 57fc85f434111fa7d943e099ba89161166175021 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 20:44:54 -0400 Subject: [PATCH 04/60] refactor(smartplaylist): remove unused 'value' field and clarify 'random' usage Signed-off-by: Deluan --- model/criteria/fields.go | 2 +- model/criteria/fields_test.go | 7 ------- persistence/criteria_sql.go | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 44d43457b..b35913f28 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -88,8 +88,8 @@ var fieldMap = map[string]FieldInfo{ // Backward compatibility: albumtype is an alias for the releasetype tag. "albumtype": {Alias: "releasetype", IsTag: true}, + // Pseudo-field for random sorting "random": {}, - "value": {}, } // AllFieldNames returns the names of all registered criteria fields. diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index f60666788..270a14473 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -22,13 +22,6 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) - It("finds special fields", func() { - field, ok := LookupField("value") - - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name()).To(gomega.Equal("value")) - }) - It("finds registered tag names", func() { AddTagNames([]string{"task3_mood"}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 4fd8e62e6..9a99cfa8e 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -117,7 +117,6 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, - "value": {expr: "value"}, } func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { From bdea9ed6a1bbcfdf6c251f117225559b05c07671 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:48:59 -0600 Subject: [PATCH 05/60] fix(ui): show album tile actions on keyboard focus (#5434) * fix(ui): show album tile actions on keyboard focus - #4836 The album grid tile bar (containing play/heart/context-menu buttons) had opacity:0 by default and only became visible on mouse :hover. Keyboard users tabbing through the album grid never saw which tile was focused and could not discover the available actions. Mirrors the existing :hover rule with :focus-within, which matches when the link itself or any descendant (e.g. the play button) has focus. Signed-off-by: Daniel Banariba * fix(ui): also disable pointer events on hidden album tile bar - #4836 Per review feedback (@gemini-code-assist): the tileBar's buttons remained clickable even at opacity:0, causing accidental Play/Menu triggers when a user clicked what looked like the album cover. Set 'pointerEvents: none' on the base tileBar and restore 'auto' on the same selector that turns it visible. Signed-off-by: Daniel Banariba --------- Signed-off-by: Daniel Banariba --- ui/src/album/AlbumGridView.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 9717618fa..33db9e40f 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -34,6 +34,7 @@ const useStyles = makeStyles( tileBar: { transition: 'all 150ms ease-out', opacity: 0, + pointerEvents: 'none', textAlign: 'left', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', @@ -78,8 +79,9 @@ const useStyles = makeStyles( position: 'relative', display: 'block', textDecoration: 'none', - '&:hover $tileBar': { + '&:hover $tileBar, &:focus-within $tileBar': { opacity: 1, + pointerEvents: 'auto', }, }, albumLink: { From 2307a64da759157ed3adc1033f58e314eda882ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 29 Apr 2026 15:36:39 -0400 Subject: [PATCH 06/60] fix(ui): start new album from track 1 after closing player (#5441) When a user played an album, advanced a few tracks, closed the player, then played a different album, playback started mid-album at the previous track index instead of track 1. The root cause was in reduceSyncQueue: the hasPendingSwitch check compared playIndex against savedPlayIndex, but after clearQueue both reset to 0, making the check falsely conclude no switch was pending. This caused PLAYER_SYNC_QUEUE to prematurely clear the playIndex and clear flags before the music player library could act on them. Fix: also treat clear=true as a signal that a track switch is pending, since it means a new queue was just loaded. --- ui/src/reducers/playerReducer.js | 14 +++-- ui/src/reducers/playerReducer.test.js | 82 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 466a3ec87..d6ab7484b 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -164,13 +164,15 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { - // Only keep clear and playIndex alive when there is an actual pending - // track switch (playIndex differs from savedPlayIndex). This lets - // PLAYER_PLAY_TRACKS selections survive the sync, while allowing - // PLAYER_PLAY_NEXT (which sets playIndex to the current track) to - // reset immediately and avoid restarting playback. + // Keep clear and playIndex alive when there is a pending track switch. + // A switch is pending when playIndex is set AND either: + // - playIndex differs from savedPlayIndex, OR + // - clear is true (a new queue was loaded, e.g. after clearQueue + playTracks) + // The clear check handles the edge case where both playIndex and + // savedPlayIndex are 0 (close player then play a new album from track 1). const hasPendingSwitch = - state.playIndex != null && state.playIndex !== state.savedPlayIndex + state.playIndex != null && + (state.clear || state.playIndex !== state.savedPlayIndex) return { ...state, queue: audioLists, diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 10e9512d7..110ce8c53 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -96,6 +96,88 @@ describe('playerReducer', () => { }) }) + describe('play new album after closing player (issue #5440)', () => { + it('SYNC_QUEUE preserves pending playIndex=0 after clearQueue', () => { + // Scenario: user plays album A, advances to track 3, closes player, + // then plays album B. After clearQueue, savedPlayIndex=0. + // PLAYER_PLAY_TRACKS sets playIndex=0. SYNC_QUEUE must NOT clear it. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, // reset by clearQueue + clear: true, + volume: 1, + } + + const action = { + type: PLAYER_SYNC_QUEUE, + data: { + audioInfo: {}, + audioLists: stateAfterClearThenPlay.queue, + }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for wrong track preserves pending playIndex=0 after clearQueue', () => { + // The music player fires onAudioPlay for the old track (at index 3) + // before switching to the new track at index 0. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + { trackId: 'b4', uuid: 'u4', name: 'B Song 4' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player reports track at index 3 as current (stale callback) + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u4', name: 'B Song 4', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for correct track consumes pending playIndex=0', () => { + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player confirms it switched to track at index 0 + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u1', name: 'B Song 1', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + expect(result.savedPlayIndex).toBe(0) + }) + }) + describe('PLAYER_REFRESH_QUEUE', () => { it('clamps negative savedPlayIndex to 0', () => { const state = { From 2b9f32699348d520fc96acbd74be24b12702b02a Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 30 Apr 2026 16:55:10 -0400 Subject: [PATCH 07/60] chore(docker): add app directory to PATH in Dockerfile --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 105656afb..ad1e2a41c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -164,6 +164,7 @@ RUN touch /.nddockerenv EXPOSE ${ND_PORT} WORKDIR /app +ENV PATH="/app:${PATH}" ENTRYPOINT ["/app/navidrome"] From 94eb6c522b63198bdc4565442d86918ad43156e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 30 Apr 2026 23:04:05 -0400 Subject: [PATCH 08/60] feat(subsonic): implement playbackReport OpenSubsonic extension (#5442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(req): add Float64Or helper for parsing float query params * feat(scrobbler): extend NowPlayingInfo with state/position/rate fields * feat(scrobbler): implement ReportPlayback with state machine and auto-scrobble * feat(responses): add state/positionMs/playbackRate to NowPlayingEntry * feat(subsonic): add reportPlayback endpoint handler * feat(subsonic): include state/positionMs/playbackRate in getNowPlaying response * feat(subsonic): register playbackReport OpenSubsonic extension * test(e2e): add reportPlayback endpoint e2e tests * refactor(scrobbler): simplify ReportPlayback — extract helpers, remove duplication - Add state constants and exported ValidStates map - Extract remainingTTL() helper (was duplicated 3x) - Merge playing/paused switch cases into single branch - Use Get instead of GetWithParticipants for non-stopped states - Guard NowPlayingCount broadcast with count-change detection - Use cache entry for NowPlaying dispatch instead of extra DB query - Remove redundant Position field from NowPlayingInfo * refactor(scrobbler): skip DB query in playing/paused when playMap has entry * fix(play_tracker): handle errors when adding/updating NowPlayingInfo in cache Signed-off-by: Deluan * refactor(play_tracker): replace sort with slices.SortFunc for NowPlayingInfo Signed-off-by: Deluan * fix(play_tracker): check all ReportPlayback errors in tests Replace _ = with explicit error assertions to avoid masking failures in intermediate calls. Signed-off-by: Deluan * test(e2e): use real PlayTracker and assert getNowPlaying after reportPlayback Replace noopPlayTracker with a real PlayTracker backed by the E2E database. E2E tests now verify the full round-trip: reportPlayback creates/updates/removes entries visible via getNowPlaying, including state, positionMs, and playbackRate fields. Export NewPlayTracker constructor for use outside the scrobbler package. * fix(play_tracker): account for playback rate in TTL and detect track switches The remainingTTL function now divides remaining time by the playback rate, so cache entries expire correctly at non-1x speeds (e.g., 2x playback halves the TTL). Zero/negative rates default to 1.0. The playing/paused case now checks if the cached MediaFile ID matches the reported mediaId, falling back to a DB fetch when the client switches tracks without sending stopped/starting. Adds parameterized tests for remainingTTL covering rate variations and edge cases. * fix(subsonic): validate positionMs and playbackRate in reportPlayback Reject negative positionMs values and invalid playbackRate values (NaN, Inf, zero, negative) at the API boundary before they reach TTL and position estimation math. Returns clear error messages for each case. * feat(play_tracker): add ClientId and ClientName to ReportPlayback parameters Signed-off-by: Deluan * refactor(play_tracker): replace NowPlaying method with ReportPlayback calls Signed-off-by: Deluan * refactor(play_tracker_test): remove redundant TTL behavior tests and clean up mockPluginLoader Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/scrobbler/play_tracker.go | 175 ++++-- core/scrobbler/play_tracker_test.go | 514 ++++++++++++++---- server/e2e/e2e_suite_test.go | 28 +- server/e2e/subsonic_media_annotation_test.go | 111 ++++ server/e2e/subsonic_sonic_similarity_test.go | 3 +- server/subsonic/album_lists.go | 13 +- server/subsonic/api.go | 1 + server/subsonic/media_annotation.go | 72 ++- server/subsonic/media_annotation_test.go | 118 +++- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 62 ++- ...es NowPlaying with data should match .JSON | 23 + ...ses NowPlaying with data should match .XML | 5 + ...NowPlaying without data should match .JSON | 8 + ... NowPlaying without data should match .XML | 3 + server/subsonic/responses/responses.go | 11 +- server/subsonic/responses/responses_test.go | 36 ++ utils/cache/simple_cache.go | 5 + utils/req/req.go | 12 + utils/req/req_test.go | 17 + 20 files changed, 1002 insertions(+), 216 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d1338ca39..462fc85ba 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -3,7 +3,7 @@ package scrobbler import ( "context" "maps" - "sort" + "slices" "sync" "time" @@ -17,13 +17,30 @@ import ( "github.com/navidrome/navidrome/utils/singleton" ) +const ( + StateStarting = "starting" + StatePlaying = "playing" + StatePaused = "paused" + StateStopped = "stopped" +) + +var ValidStates = map[string]bool{ + StateStarting: true, + StatePlaying: true, + StatePaused: true, + StateStopped: true, +} + type NowPlayingInfo struct { - MediaFile model.MediaFile - Start time.Time - Position int - Username string - PlayerId string - PlayerName string + MediaFile model.MediaFile + Start time.Time + Username string + PlayerId string + PlayerName string + State string + PositionMs int64 + PlaybackRate float64 + LastReport time.Time } type Submission struct { @@ -31,6 +48,16 @@ type Submission struct { Timestamp time.Time } +type ReportPlaybackParams struct { + MediaId string + PositionMs int64 + State string + PlaybackRate float64 + IgnoreScrobble bool + ClientId string + ClientName string +} + type nowPlayingEntry struct { ctx context.Context userId string @@ -39,9 +66,9 @@ type nowPlayingEntry struct { } type PlayTracker interface { - NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) Submit(ctx context.Context, submissions []Submission) error + ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } // PluginLoader is a minimal interface for plugin manager usage in PlayTracker @@ -72,8 +99,12 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug }) } -// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by -// the GetPlayTracker function above +// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton, +// returned by the GetPlayTracker function above. This constructor is exported for testing. +func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { + return newPlayTracker(ds, broker, pluginManager) +} + func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { m := cache.NewSimpleCache[string, NowPlayingInfo]() p := &playTracker{ @@ -193,36 +224,104 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } -func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) - if err != nil { - log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) - return err +func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { + if rate <= 0 { + rate = 1.0 } + remainingMs := float64(int64(durationSec*1000)-positionMs) / rate + remainingSec := max(int(remainingMs/1000), 0) + return time.Duration(remainingSec+5) * time.Second +} +func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error { + player, _ := request.PlayerFrom(ctx) user, _ := request.UserFrom(ctx) - info := NowPlayingInfo{ - MediaFile: *mf, - Start: time.Now(), - Position: position, - Username: user.UserName, - PlayerId: playerId, - PlayerName: playerName, + clientId := params.ClientId + client := params.ClientName + + now := time.Now() + prevCount := p.playMap.Len() + + switch params.State { + case StateStarting: + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info := NowPlayingInfo{ + MediaFile: *mf, + Start: now, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + if err != nil { + log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StatePlaying, StatePaused: + info, getErr := p.playMap.Get(clientId) + if getErr != nil || info.MediaFile.ID != params.MediaId { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info = NowPlayingInfo{ + MediaFile: *mf, + Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + } + } + info.State = params.State + info.PositionMs = params.PositionMs + info.PlaybackRate = params.PlaybackRate + info.LastReport = now + ttl := 30 * time.Minute + if params.State == StatePlaying { + ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) + } + err := p.playMap.AddWithTTL(clientId, info, ttl) + if err != nil { + log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StateStopped: + if !params.IgnoreScrobble && player.ScrobbleEnabled { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + trackDurationMs := int64(mf.Duration * 1000) + threshold := min(trackDurationMs*50/100, 240_000) + if params.PositionMs >= threshold { + err = p.incPlay(ctx, mf, now) + if err != nil { + log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) + } + p.dispatchScrobble(ctx, mf, now) + } + } + p.playMap.Remove(clientId) } - // Calculate TTL based on remaining track duration. If position exceeds track duration, - // remaining is set to 0 to avoid negative TTL. - remaining := max(int(mf.Duration)-position, 0) - // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. - ttl := time.Duration(remaining+5) * time.Second - _ = p.playMap.AddWithTTL(playerId, info, ttl) - if conf.Server.EnableNowPlaying { + if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - player, _ := request.PlayerFrom(ctx) - if player.ScrobbleEnabled { - p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position) + + if !params.IgnoreScrobble && player.ScrobbleEnabled && + (params.State == StateStarting || params.State == StatePlaying) { + if info, err := p.playMap.Get(clientId); err == nil { + p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) + } } + return nil } @@ -296,9 +395,17 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t * func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { res := p.playMap.Values() - sort.Slice(res, func(i, j int) bool { - return res[i].Start.After(res[j].Start) + slices.SortFunc(res, func(a, b NowPlayingInfo) int { + return b.Start.Compare(a.Start) }) + for i := range res { + if res[i].State == StatePlaying { + elapsed := time.Since(res[i].LastReport).Milliseconds() + estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate) + trackDurationMs := int64(res[i].MediaFile.Duration * 1000) + res[i].PositionMs = min(estimated, trackDurationMs) + } + } return res, nil } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f7edecdfd..64b3d3ee0 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -20,9 +20,6 @@ import ( . "github.com/onsi/gomega" ) -// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests -// Moved to top-level scope to avoid linter issues - type mockPluginLoader struct { mu sync.RWMutex names []string @@ -107,91 +104,21 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) }) - Describe("NowPlaying", func() { - It("sends track to agent", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - Expect(fake.GetUserID()).To(Equal("u-1")) - Expect(fake.GetTrack().ID).To(Equal("123")) - Expect(fake.GetTrack().Participants).To(Equal(track.Participants)) - }) - It("does not send track to agent if user has not authorized", func() { - fake.Authorized = false - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if player is not enabled to send scrobbles", func() { - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if artist is unknown", func() { - track.Artist = consts.UnknownArtist - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - - It("stores position when greater than zero", func() { - pos := 42 - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos)) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].Position).To(Equal(pos)) - }) - - It("sends event with count", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - eventList := eventBroker.getEvents() - Expect(eventList).ToNot(BeEmpty()) - evt, ok := eventList[0].(*events.NowPlayingCount) - Expect(ok).To(BeTrue()) - Expect(evt.Count).To(Equal(1)) - }) - - It("does not send event when disabled", func() { - conf.Server.EnableNowPlaying = false - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(eventBroker.getEvents()).To(BeEmpty()) - }) - - It("passes user to scrobbler via context (fix for issue #4787)", func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"}) - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - // Verify the username was passed through async dispatch via context - Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser")) - }) - }) - Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) - _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) + ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one", + }) + ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) playing, err := tracker.GetNowPlaying(ctx) @@ -336,6 +263,379 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("ReportPlayback", func() { + const defaultClientId = "client-1" + + BeforeEach(func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true}) + }) + + It("creates entry on starting and removes on stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].MediaFile.ID).To(Equal("123")) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing[0].State).To(Equal("paused")) + Expect(playing[0].PositionMs).To(Equal(int64(30000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("starting replaces existing entry for same player", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("multiple players have independent sessions", func() { + ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true}) + + ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true}) + + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + + err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2", + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(2)) + }) + + Describe("auto-scrobble", func() { + It("scrobbles on stopped when positionMs >= 50% of track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + Expect(album.PlayCount).To(Equal(int64(1))) + Expect(artist1.PlayCount).To(Equal(int64(1))) + }) + + It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() { + longTrack := model.MediaFile{ + ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1", + Duration: 600, + Participants: map[model.Role]model.ParticipantList{ + model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, + }, + } + _ = ds.MediaFile(ctx).Put(&longTrack) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(longTrack.PlayCount).To(Equal(int64(1))) + }) + + It("does NOT scrobble when positionMs below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("scrobbles twice for two separate sessions of same song", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(2))) + }) + + It("dispatches to external scrobblers on auto-scrobble", func() { + fake.ScrobbleCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + }) + + Describe("position estimation", func() { + It("estimates position for playing state based on elapsed time", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000))) + }) + + It("does NOT estimate for paused", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(10000))) + }) + + It("does NOT estimate for starting", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("respects playbackRate", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(100 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + // At 2x speed, 100ms real time = ~200ms playback time + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100))) + }) + + It("caps estimated position at track duration", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000 + }) + + }) + + Describe("resilience (no prior starting)", func() { + It("playing without prior starting creates entry with Start approx now - positionMs", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + expectedStart := time.Now().Add(-30 * time.Second) + Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second)) + }) + + It("paused without prior starting creates entry", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("paused")) + }) + + It("stopped without prior starting auto-scrobbles if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("stopped without prior starting does NOT scrobble if below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("external scrobbler dispatch", func() { + It("dispatches NowPlaying on starting", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("dispatches NowPlaying on playing", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("does NOT dispatch on paused", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ignoreScrobble=true", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch when ScrobbleEnabled=false", func() { + fake.nowPlayingCalled.Store(false) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + }) + }) + Describe("Plugin scrobbler logic", func() { var pluginLoader *mockPluginLoader var pluginFake *fakeScrobbler @@ -354,27 +654,32 @@ var _ = Describe("PlayTracker", func() { }) It("registers and uses plugin scrobbler for NowPlaying", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("removes plugin scrobbler if not present anymore", func() { - // First call: plugin present - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) pluginFake.nowPlayingCalled.Store(false) - // Remove plugin pluginLoader.SetNames([]string{}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - // Should not be called since plugin was removed + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("calls both builtin and plugin scrobblers for NowPlaying", func() { fake.nowPlayingCalled.Store(false) pluginFake.nowPlayingCalled.Store(false) - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) @@ -550,6 +855,24 @@ var _ = Describe("PlayTracker", func() { }) }) +var _ = DescribeTable("remainingTTL", + func(durationSec float32, positionMs int64, rate float64, expected time.Duration) { + Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected)) + }, + Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second), + Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second), + Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second), + Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second), + Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second), + Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second), + Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second), + Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second), + Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second), + Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second), + Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second), + Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second), +) + type fakeScrobbler struct { Authorized bool nowPlayingCalled atomic.Bool @@ -577,17 +900,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } -func (f *fakeScrobbler) GetPosition() int { - return int(f.position.Load()) -} - -func (f *fakeScrobbler) GetUsername() string { - if p := f.username.Load(); p != nil { - return *p - } - return "" -} - func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4b003167a..7ce8de2e6 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } -// noopPlayTracker implements scrobbler.PlayTracker -type noopPlayTracker struct{} - -func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { - return nil -} - -func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { - return nil, nil -} - -func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { - return nil -} - // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -513,7 +497,7 @@ func setupTestDB() { s, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go index 0f7e92083..ec9b070de 100644 --- a/server/e2e/subsonic_media_annotation_test.go +++ b/server/e2e/subsonic_media_annotation_test.go @@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + + Describe("ReportPlayback", Ordered, func() { + var songID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("returns error when required params are missing", func() { + resp := doReq("reportPlayback") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("returns error for invalid state", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "invalid", + ) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("starting report creates a getNowPlaying entry", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.Status).To(Equal(responses.StatusOK)) + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("starting")) + }) + + It("playing report updates getNowPlaying state and position", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "playing", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("playing")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000))) + }) + + It("paused report freezes position in getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "paused", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("paused")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000))) + }) + + It("stopped report removes entry from getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "90000", + "state", "stopped", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(BeEmpty()) + }) + + It("accepts mediaType=podcast without error", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "podcast", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("accepts optional playbackRate and ignoreScrobble", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "5000", + "state", "playing", + "playbackRate", "1.5", + "ignoreScrobble", "true", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5)) + }) + }) }) diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 3a010bc67..40161470b 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" @@ -42,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { nil, // scanner events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 56cf469c5..275665b02 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -214,11 +214,14 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { return responses.NowPlayingEntry{ - Child: childFromMediaFile(ctx, np.MediaFile), - UserName: np.Username, - MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything - PlayerName: np.PlayerName, + Child: childFromMediaFile(ctx, np.MediaFile), + UserName: np.Username, + MinutesAgo: int32(time.Since(np.Start).Minutes()), + PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerName: np.PlayerName, + State: np.State, + PositionMs: np.PositionMs, + PlaybackRate: np.PlaybackRate, } }) return response, nil diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 5b6565260..0bbfcb83e 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -144,6 +144,7 @@ func (api *Router) routes() http.Handler { h(r, "star", api.Star) h(r, "unstar", api.Unstar) h(r, "scrobble", api.Scrobble) + h(r, "reportPlayback", api.ReportPlayback) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 39bc83fa9..e8b0278c1 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "fmt" + "math" "net/http" "time" @@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi } log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) - err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) - return err + return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: trackId, + PositionMs: int64(position) * 1000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: client, + }) +} + +func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + mediaId, err := p.String("mediaId") + if err != nil { + return nil, err + } + mediaType, err := p.String("mediaType") + if err != nil { + return nil, err + } + positionMs, err := p.Int64("positionMs") + if err != nil { + return nil, err + } + if positionMs < 0 { + return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative") + } + state, err := p.String("state") + if err != nil { + return nil, err + } + + if !scrobbler.ValidStates[state] { + return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state) + } + + playbackRate := p.Float64Or("playbackRate", 1.0) + if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 { + return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number") + } + ignoreScrobble := p.BoolOr("ignoreScrobble", false) + + ctx := r.Context() + if mediaType != "song" { + log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId) + } + + player, _ := request.PlayerFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + + err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: mediaId, + PositionMs: positionMs, + State: state, + PlaybackRate: playbackRate, + IgnoreScrobble: ignoreScrobble, + ClientId: clientId, + ClientName: client, + }) + if err != nil { + log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err) + return nil, err + } + + return newResponse(), nil } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1c513edf4..f5cf434e0 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -89,32 +90,107 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.Submissions).To(BeEmpty()) }) - It("registers a NowPlaying", func() { + It("registers a NowPlaying via ReportPlayback", func() { _, err := router.Scrobble(req) Expect(err).ToNot(HaveOccurred()) - Expect(playTracker.Playing).To(HaveLen(1)) - Expect(playTracker.Playing).To(HaveKey("player-1")) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12")) + Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1")) }) }) }) + + Describe("ReportPlayback", func() { + It("returns error when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newGetRequest("mediaId=123", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when positionMs is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when state is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid state value", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative positionMs", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for NaN playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for Inf playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for zero playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("accepts mediaType=podcast without error", func() { + r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + resp, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + _, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0)) + Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse()) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1")) + Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { - Submissions []scrobbler.Submission - Playing map[string]string - Error error -} - -func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { - if f.Error != nil { - return f.Error - } - if f.Playing == nil { - f.Playing = make(map[string]string) - } - f.Playing[playerId] = trackId - return nil + Submissions []scrobbler.Submission + ReportedPlayback []scrobbler.ReportPlaybackParams + Error error } func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { @@ -129,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm return nil } +func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error { + if f.Error != nil { + return f.Error + } + f.ReportedPlayback = append(f.ReportedPlayback, params) + return nil +} + var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil) type fakeEventBroker struct { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 6c54d36d0..85edb1012 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -14,6 +14,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, + {Name: "playbackReport", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index d98599f8f..3ccbf232e 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,42 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) - Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ) - }) - }) - - Context("with sonic similarity plugin", func() { - BeforeEach(func() { - sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) - }) - - It("should return 6 extensions including sonicSimilarity", func() { - router.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) @@ -90,6 +61,37 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 7 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(7), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON new file mode 100644 index 000000000..f99cb62af --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON @@ -0,0 +1,23 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "Song", + "username": "testuser", + "minutesAgo": 2, + "playerId": 1, + "playerName": "TestPlayer", + "state": "playing", + "positionMs": 120000, + "playbackRate": 1.5 + } + ] + } +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML new file mode 100644 index 000000000..ae05b68d0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML @@ -0,0 +1,5 @@ + + + + + diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON new file mode 100644 index 000000000..ab8f793fa --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": {} +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML new file mode 100644 index 000000000..763b4b583 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML @@ -0,0 +1,3 @@ + + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c2e863b0f..b0b2b8752 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -358,10 +358,13 @@ type Starred2 struct { type NowPlayingEntry struct { Child - UserName string `xml:"username,attr" json:"username"` - MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` - PlayerId int32 `xml:"playerId,attr" json:"playerId"` - PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + UserName string `xml:"username,attr" json:"username"` + MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` + PlayerId int32 `xml:"playerId,attr" json:"playerId"` + PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + State string `xml:"state,attr" json:"state"` + PositionMs int64 `xml:"positionMs,attr" json:"positionMs"` + PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"` } type NowPlaying struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ba86deaf2..ee98a3daa 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1109,6 +1109,42 @@ var _ = Describe("Responses", func() { }) }) + Describe("NowPlaying", func() { + BeforeEach(func() { + response.NowPlaying = &NowPlaying{} + }) + + Describe("without data", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("with data", func() { + BeforeEach(func() { + response.NowPlaying.Entry = []NowPlayingEntry{{ + Child: Child{Id: "1", Title: "Song", IsDir: false}, + UserName: "testuser", + MinutesAgo: 2, + PlayerId: 1, + PlayerName: "TestPlayer", + State: "playing", + PositionMs: 120000, + PlaybackRate: 1.5, + }} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + Describe("SonicMatches", func() { Context("without data", func() { BeforeEach(func() { diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index cac41be7b..2f0ff4764 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface { AddWithTTL(key K, value V, ttl time.Duration) error Get(key K) (V, error) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) + Remove(key K) Keys() []K Values() []V Len() int @@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error return nil } +func (c *simpleCache[K, V]) Remove(key K) { + c.data.Delete(key) +} + func (c *simpleCache[K, V]) Get(key K) (V, error) { item := c.data.Get(key) if item == nil { diff --git a/utils/req/req.go b/utils/req/req.go index f9fa5724b..6b6135e1a 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool { } return v } + +func (r *Values) Float64Or(param string, def float64) float64 { + v, err := r.String(param) + if err != nil { + return def + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return def + } + return f +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index e710365bd..d76b3b934 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -244,6 +244,23 @@ var _ = Describe("Request Helpers", func() { }) }) + Describe("Float64Or", func() { + It("returns parsed float value", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=1.5", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.5)) + }) + + It("returns default when param is missing", func() { + r := req.Params(httptest.NewRequest("GET", "/test", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + + It("returns default when param is not a valid float", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=abc", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + }) + Describe("ParamBoolPtr", func() { Context("value is true", func() { BeforeEach(func() { From 556f345a10ed600aefd75810b2412c37f05bf8b2 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 1 May 2026 14:22:51 -0400 Subject: [PATCH 09/60] chore(lint): upgrade golangci-lint, fix/ignore new errors Signed-off-by: Deluan --- Makefile | 2 +- server/subsonic/api_test.go | 2 +- server/subsonic/media_retrieval.go | 2 +- server/subsonic/middlewares.go | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index ad96afd31..e303017c7 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.11.1 +GOLANGCI_LINT_VERSION ?= v2.12.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 3b88704b1..f3053c8af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -156,7 +156,7 @@ var _ = Describe("sendResponse", func() { It("updates status pointer when an error occurs", func() { pointer := int32(0) - ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet r = r.WithContext(ctx) payload.Status = responses.StatusFailed diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 3faae1650..9ab3a20b0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -37,7 +37,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses log.Warn(ctx, "User needs an email for gravatar to work", "username", username) return api.getPlaceHolderAvatar(w, r) } - http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) + http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) //nolint:gosec // URL is not constructed from user input return nil, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 2d8b1fd94..5832bb1de 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -199,7 +199,7 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - cookie := &http.Cookie{ + cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, MaxAge: consts.CookieExpiry, @@ -239,7 +239,9 @@ func playerIDCookieName(userName string) string { return cookieName } -const subsonicErrorPointer = "subsonicErrorPointer" +type contextKey string + +const subsonicErrorPointer contextKey = "subsonicErrorPointer" func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { From 7e16b6acb5c11e283fcd320a0abb82372a8ab0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 1 May 2026 15:27:32 -0400 Subject: [PATCH 10/60] feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add UIPlaybackReportInterval setting * feat(server): expose playbackReportIntervalMs to UI config * feat(ui): add playbackReportIntervalMs config default * feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer * feat(ui): replace scrobble logic with reportPlayback state machine in Player * refactor(ui): simplify Player heartbeat using useInterval hook - Replace manual setInterval/clearInterval with existing useInterval hook - Extract shared reportPlaybackUrl helper to deduplicate URL construction - Use ref for currentTrackId in beforeunload to stabilize effect deps - Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime * feat(ui): redesign NowPlaying panel with Discord-style layout Show album art with play/pause overlay icon, track title, artist, album name, progress bar with position/duration, and username. * fix(ui): adjust NowPlaying panel height to fit 3 entries * fix(ui): send stopped on player close and tab close while paused - onBeforeDestroy now sends reportPlayback stopped before clearing queue - beforeunload sends stopped beacon regardless of pause state * feat(ui): animate NowPlaying progress bar with 1s client-side tick * fix(ui): account for playbackRate in NowPlaying progress interpolation * fix(ui): use timestamp-based interpolation for smooth NowPlaying progress Replace tick counter with fetchedAt timestamp so the progress bar advances smoothly without resetting on each server poll. * fix(ui): fix NowPlaying progress bar not animating Pass `now` (Date.now()) as a prop that changes every tick, so React.memo'd components actually re-render each second. * fix(ui): prevent progress bar reset on NowPlaying poll Set fetchedAt and now atomically on fetch so the elapsed offset starts at zero and the server's already-estimated positionMs is used as the base without a visible jump. * fix(ui): stamp entries with fetch time to prevent progress bar reset Embed _fetchedAt timestamp directly into each entry object so the position and its reference timestamp are always in the same state update, eliminating the React 17 multi-setState batching race. * fix(server): estimate position for starting state in GetNowPlaying GetNowPlaying was only estimating elapsed position for the "playing" state, returning raw positionMs=0 for "starting". Since the UI player sends "starting" once and then doesn't update until the 60s heartbeat, NowPlaying polls returned 0 for up to a minute, causing the progress bar to reset on every poll. * fix(ui): send playing immediately after starting to enable position estimation The server only estimates elapsed position for "playing" state in GetNowPlaying. The Player was sending "starting" once and then not updating until the 60s heartbeat, leaving the server state as "starting" with positionMs=0 for up to a minute. Now the Player follows up "starting" with an immediate "playing" call, transitioning the server state so position estimation works from the first poll. * fix(subsonic): fix getNowPlaying returning same playerId for all entries PlayerId was never incremented in the map callback, so every entry got playerId=1. This caused the UI to use duplicate React keys, mixing up rendered entries between players. Also use a stable composite key in the UI instead of the sequential playerId. * fix(ui): only send stopped beacon when tab is actually closing Move the reportPlaybackBeacon call from beforeunload to pagehide. beforeunload fires before the confirmation dialog, so cancelling the close would still send stopped. pagehide only fires when the page is actually being unloaded. * fix(ui): revert to beforeunload for stopped beacon pagehide does not fire reliably in Chrome when closing tabs. Use beforeunload instead — if the user cancels the close dialog, the heartbeat will re-register the NowPlaying entry on its next tick. * fix(ui): use synchronous XHR for stopped report on tab close Replace sendBeacon with synchronous XMLHttpRequest in beforeunload. This blocks the page from closing until the server acknowledges the stopped state, ensuring the NowPlaying entry is always removed. * fix(ui): fix confirmation dialog and use fetch keepalive for tab close - Move e.preventDefault() before the stopped report so the dialog always shows regardless of XHR errors - Use fetch with keepalive:true instead of sync XHR (more reliable, non-blocking, survives page teardown) - Fall back to sendBeacon if fetch throws * fix(ui): prevent heartbeat from re-adding entry after stopped on tab close Set a stoppedRef flag in beforeunload so the heartbeat interval skips sending playing reports after stopped has been sent. Without this, the heartbeat could re-register the NowPlaying entry after the stopped event removed it. * fix(ui): include client unique ID header in stopped report on tab close Root cause: reportPlaybackSync (fetch keepalive) did not include the X-ND-Client-Unique-Id header. Regular reportPlayback calls via httpClient include this header, and the server uses it as the playMap key. Without the header, the stopped call fell back to player.ID as the key, which didn't match the entry added with the UUID key. The playMap.Remove targeted the wrong key, so the entry persisted. Fix: export clientUniqueId from httpClient and include it as a header in the fetch keepalive request. * fix(ui): use pagehide for stopped report to avoid premature send beforeunload fires before the confirmation dialog, so the stopped event was sent even when the user cancelled closing. Move the stopped report to pagehide, which only fires when the page is actually being unloaded (after confirmation). * feat(server): broadcast NowPlaying SSE on every state change Previously, the SSE broadcast only fired when the NowPlaying count changed. Now it fires on every reportPlayback call (starting, playing, paused, stopped), so the NowPlaying panel gets instant updates for state transitions and position changes. The UI reducer stores a nowPlayingLastUpdate timestamp alongside the count, ensuring every SSE event triggers a re-fetch even when the count is unchanged (e.g., pause/resume). * fix(ui): clamp NowPlaying position to prevent negative time display * fix(ui): debounce NowPlaying fetches to prevent progress bar trembling During track changes, rapid SSE events (stopped, starting, playing) triggered multiple refetches within milliseconds, each resetting the interpolation base and causing the progress bar to oscillate. Skip fetches within 1 second of the previous fetch. * feat(ui): report playback position on seek Send a reportPlayback(playing) call when the user seeks/scrubs in the player, so the NowPlaying panel and server position stay in sync immediately instead of waiting for the next 60s heartbeat. * refactor: code review cleanup - Export clientUniqueIdHeader from httpClient, use in subsonic layer - Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList - Fix onBeforeDestroy nested dep (read isRadio from ref instead) - Only broadcast SSE on state transitions, not heartbeat position updates - Only enqueue NowPlaying to external scrobblers on state transitions Signed-off-by: Deluan * fix(ui): use trailing-edge debounce for NowPlaying fetch Replace the leading-edge throttle (which fetched on the first event and blocked subsequent ones) with a trailing-edge debounce (300ms). During track transitions, the burst of events (stopped → starting → playing) now collapses into a single fetch after the burst settles, showing the new track immediately instead of briefly showing empty. * fix(ui): only show overlay on NowPlaying artwork when paused Signed-off-by: Deluan * refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync * refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive The function was never synchronous — it uses fetch with keepalive:true, which is fire-and-forget. The name now reflects the actual behavior. * style: format code with prettier * test: add tests for reportPlayback SSE broadcast and UI changes - play_tracker: verify SSE broadcast on every state transition and that broadcasts are skipped when EnableNowPlaying is false - activityReducer: verify nowPlayingLastUpdate timestamp is set - subsonic/index: verify reportPlayback URL construction Signed-off-by: Deluan * fix(ui): prevent NowPlaying from fetching every second when panel is open fetchList had unstable identity because it depended on doFetch (which depended on notify/dispatch). Each 1s setNow re-render recreated the callback chain, re-triggering the useEffect that calls fetchList. Use a ref for the fetch logic so fetchList has a stable identity with empty deps. * fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel The fetch dispatched nowPlayingCountUpdate on every result, which updated nowPlayingLastUpdate in Redux, which triggered the SSE effect to call fetchList again — creating a fetch loop. Fix: remove dispatch from fetch results. The badge count uses entries.length (from local state) when entries are loaded, falling back to Redux count (from SSE) when they aren't. SSE events remain the only trigger for nowPlayingLastUpdate, breaking the loop. * fix(ui): clear NowPlaying entries on panel close so badge uses SSE count * style: format code with prettier * fix: address code review feedback - Fix currentTime truthiness check to handle position 0 correctly - Report actual player state (playing/paused) on seek instead of always sending 'playing' - Remove idx from React key to avoid reorder issues - Add debounce timer cleanup on unmount - Keep entries on panel close so badge stays accurate from polling - Fix test description to match actual assertion * fix(ui): keep NowPlaying badge count accurate from polling Add a separate nowPlayingCountSync action that updates the Redux count without setting nowPlayingLastUpdate (which would trigger the SSE effect and cause a fetch loop). Polling results now sync the badge count via this action, so the badge stays accurate even when SSE is unavailable. * style: format code with prettier --------- Signed-off-by: Deluan --- conf/configuration.go | 2 + consts/consts.go | 11 +- core/scrobbler/play_tracker.go | 3 +- core/scrobbler/play_tracker_test.go | 63 +++++ server/serve_index.go | 1 + server/serve_index_test.go | 1 + server/subsonic/album_lists.go | 3 +- ui/src/actions/serverEvents.js | 6 + ui/src/audioplayer/Player.jsx | 170 ++++++++----- ui/src/config.js | 1 + ui/src/dataProvider/httpClient.js | 4 +- ui/src/dataProvider/index.js | 4 +- ui/src/layout/NowPlayingPanel.jsx | 306 +++++++++++++++++------- ui/src/layout/NowPlayingPanel.test.jsx | 50 ++-- ui/src/reducers/activityReducer.js | 8 + ui/src/reducers/activityReducer.test.js | 17 ++ ui/src/subsonic/index.js | 33 ++- ui/src/subsonic/index.test.js | 30 +++ 18 files changed, 517 insertions(+), 196 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 916efe70b..1c4829d82 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -95,6 +95,7 @@ type configOptions struct { EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool + UIPlaybackReportInterval time.Duration GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -776,6 +777,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) + viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) diff --git a/consts/consts.go b/consts/consts.go index 3db0b831a..bf32006d6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -67,11 +67,12 @@ const ( ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - DefaultUIVolume = 100 - DefaultUISearchDebounceMs = 200 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 + DefaultUIPlaybackReportInterval = time.Minute DefaultHttpClientTimeOut = 10 * time.Second diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 462fc85ba..161d04d7d 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -240,7 +240,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP client := params.ClientName now := time.Now() - prevCount := p.playMap.Len() switch params.State { case StateStarting: @@ -311,7 +310,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.playMap.Remove(clientId) } - if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { + if conf.Server.EnableNowPlaying { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 64b3d3ee0..734f8f66f 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -371,6 +371,69 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(HaveLen(2)) }) + Describe("SSE broadcast on state change", func() { + BeforeEach(func() { + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + }) + + It("broadcasts NowPlayingCount on every state change", func() { + // starting -> count should be 1 + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts := eventBroker.getEvents() + Expect(evts).To(HaveLen(1)) + Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1)) + + // playing -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(2)) + Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1)) + + // paused -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(3)) + Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1)) + + // stopped -> count should be 0 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(4)) + Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0)) + }) + + It("does NOT broadcast when EnableNowPlaying is false", func() { + conf.Server.EnableNowPlaying = false + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) + }) + Describe("auto-scrobble", func() { It("scrobbles on stopped when positionMs >= 50% of track", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ diff --git a/server/serve_index.go b/server/serve_index.go index 734aabc70..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, + "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 31bca02cf..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -106,6 +106,7 @@ var _ = Describe("serveIndex", func() { Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), + Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)), ) It("sanitizes entity-encoded welcomeMessage as html", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 275665b02..2a2338d89 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -213,11 +213,12 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response.NowPlaying = &responses.NowPlaying{} var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), UserName: np.Username, MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerId: i, PlayerName: np.PlayerName, State: np.State, PositionMs: np.PositionMs, diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 995534550..892f2e27e 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -2,6 +2,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' +export const EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ @@ -18,6 +19,11 @@ export const nowPlayingCountUpdate = (data) => ({ data: data, }) +export const nowPlayingCountSync = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT_SYNC, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 5599b9e1d..e2070deea 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useInterval } from '../common' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' @@ -41,9 +42,11 @@ const Player = () => { const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) + const [currentTrackId, setCurrentTrackId] = useState(null) + const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) + const lastPositionMsRef = useRef(0) + const currentTrackIdRef = useRef(null) + const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = @@ -58,6 +61,21 @@ const Player = () => { const playerStateRef = useRef(playerState) playerStateRef.current = playerState + currentTrackIdRef.current = currentTrackId + + useInterval( + () => { + if (heartbeatTrackId && !stoppedRef.current) { + subsonic.reportPlayback( + heartbeatTrackId, + lastPositionMsRef.current, + 'playing', + ) + } + }, + heartbeatTrackId ? config.playbackReportIntervalMs : null, + ) + // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { @@ -155,15 +173,33 @@ const Player = () => { 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 + e.returnValue = '' + } + } + + const handlePageHide = () => { + if (currentTrackIdRef.current && !playerState.current?.isRadio) { + stoppedRef.current = true + try { + subsonic.reportPlaybackKeepalive( + currentTrackIdRef.current, + lastPositionMsRef.current, + 'stopped', + ) + } catch { + // fetch/sendBeacon may throw; ignore + } } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('pagehide', handlePageHide) + } }, [playerState, audioInstance]) const defaultOptions = useMemo( @@ -227,44 +263,25 @@ const Player = () => { [dispatch], ) - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) + const onAudioProgress = useCallback((info) => { + if (info.ended) { + document.title = 'Navidrome' + } + if (!info.isRadio && info.currentTime != null) { + lastPositionMsRef.current = Math.floor(info.currentTime * 1000) + } + }, []) - const onAudioProgress = useCallback( + const onAudioSeeked = useCallback( (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null && !next.isRadio) { - // Trigger decision pre-fetch (this also warms the cache) - decisionService.prefetchDecisions([next.trackId]) - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const state = audioInstance?.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackId, posMs, state) } }, - [startTime, scrobbled, nextSong, preloaded], + [currentTrackId, audioInstance], ) const onAudioVolumeChange = useCallback( @@ -275,24 +292,30 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const isNewTrack = info.trackId !== currentTrackId + if (isNewTrack) { + subsonic + .reportPlayback(info.trackId, posMs, 'starting') + .then(() => + subsonic.reportPlayback(info.trackId, posMs, 'playing'), + ) + setCurrentTrackId(info.trackId) + } else { + subsonic.reportPlayback(info.trackId, posMs, 'playing') + } + setHeartbeatTrackId(info.trackId) } - setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', @@ -309,34 +332,49 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, startTime], + [context, dispatch, showNotifications, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, [currentTrackId]) const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], + (info) => { + dispatch(currentPlaying(info)) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + subsonic.reportPlayback(currentTrackId, posMs, 'paused') + } + setHeartbeatTrackId(null) + }, + [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) + if (currentTrackId && !info.isRadio) { + const posMs = Math.floor((info.duration || 0) * 1000) + subsonic.reportPlayback(currentTrackId, posMs, 'stopped') + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider], + [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -369,10 +407,19 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentTrackId && !playerStateRef.current?.current?.isRadio) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(clearQueue()) reject() }) - }, [dispatch]) + }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -397,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/config.js b/ui/src/config.js index f4d60dfba..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -33,6 +33,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index a8897aef8..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -6,8 +6,8 @@ import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' -const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -const clientUniqueId = uuidv4() +export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +export const clientUniqueId = uuidv4() const httpClient = (url, options = {}) => { url = baseUrl(url) diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index e9ceea761..059023378 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient from './httpClient' +import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient } +export { httpClient, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 4aaee1bee..509263b42 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux' import { useTranslate, Link, useNotify } from 'react-admin' @@ -9,30 +9,29 @@ import { Tooltip, List, ListItem, - ListItemText, - ListItemAvatar, Avatar, Badge, Card, CardContent, Typography, + LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' -import { FaRegCirclePlay } from 'react-icons/fa6' +import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' -import { nowPlayingCountUpdate } from '../actions' +import { nowPlayingCountSync } from '../actions' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { - width: '30em', + width: '26em', maxHeight: (props) => { - // Calculate height for up to 4 entries before scrolling - const entryHeight = 80 - const maxEntries = Math.min(props.entryCount || 0, 4) + const entryHeight = 120 + const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', @@ -42,42 +41,111 @@ const useStyles = makeStyles((theme) => ({ padding: 0, }, cardContent: { - padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + padding: `${theme.spacing(1)}px !important`, '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1.5), + padding: theme.spacing(1), + }, + avatarContainer: { + position: 'relative', + flexShrink: 0, + width: theme.spacing(8), + height: theme.spacing(8), }, avatar: { - width: theme.spacing(6), - height: theme.spacing(6), + width: '100%', + height: '100%', cursor: 'pointer', + borderRadius: theme.spacing(0.5), '&:hover': { opacity: 0.8, }, }, + stateOverlay: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + borderRadius: theme.spacing(0.5), + pointerEvents: 'none', + }, + stateIcon: { + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 18, + }, + entryContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.25), + }, + trackTitle: { + fontWeight: 600, + fontSize: '0.875rem', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + trackDetail: { + fontSize: '0.75rem', + color: theme.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + artistLink: { + cursor: 'pointer', + color: theme.palette.text.secondary, + fontSize: '0.75rem', + '&:hover': { + textDecoration: 'underline', + }, + }, + progressRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + marginTop: theme.spacing(0.5), + }, + progressTime: { + fontSize: '0.65rem', + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums', + flexShrink: 0, + }, + progressBar: { + flex: 1, + height: 3, + borderRadius: 2, + backgroundColor: theme.palette.action.disabledBackground, + '& .MuiLinearProgress-bar': { + borderRadius: 2, + }, + }, + userInfo: { + fontSize: '0.65rem', + color: theme.palette.text.disabled, + marginTop: theme.spacing(0.25), + }, badge: { '& .MuiBadge-badge': { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, }, - artistLink: { - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }, - primaryText: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - }, })) // NowPlayingButton component - handles the button with badge @@ -113,15 +181,32 @@ NowPlayingButton.propTypes = { onClick: PropTypes.func.isRequired, } -// NowPlayingItem component - individual list item const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() - const translate = useTranslate() + const isPaused = nowPlayingEntry.state === 'paused' + const isPlaying = + nowPlayingEntry.state === 'playing' || + nowPlayingEntry.state === 'starting' + const basePositionMs = nowPlayingEntry.positionMs || 0 + const rate = nowPlayingEntry.playbackRate || 1 + const elapsedSinceFetch = now - (nowPlayingEntry._fetchedAt || now) + const interpolatedMs = isPlaying + ? basePositionMs + elapsedSinceFetch * rate + : basePositionMs + const durationMs = (nowPlayingEntry.duration || 0) * 1000 + const clampedMs = Math.max(0, interpolatedMs) + const positionMs = + durationMs > 0 ? Math.min(clampedMs, durationMs) : clampedMs + const positionSec = positionMs / 1000 + const durationSec = nowPlayingEntry.duration || 0 + const progress = durationSec > 0 ? (positionSec / durationSec) * 100 : 0 + const artistId = nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId + const artistName = nowPlayingEntry.albumArtist || nowPlayingEntry.artist return ( - - + +
- - - {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - ) : ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - )} -  - {nowPlayingEntry.title} + {isPaused && ( +
+
- } - secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} - /> + )} +
+
+ + {nowPlayingEntry.title} + + {artistId ? ( + + {artistName} + + ) : ( + + {artistName} + + )} + + {nowPlayingEntry.album} + +
+ + {formatDuration(positionSec)} + + + + {formatDuration(durationSec)} + +
+ + {nowPlayingEntry.username} + {nowPlayingEntry.playerName + ? ` (${nowPlayingEntry.playerName})` + : ''} + +
) }, @@ -178,16 +291,19 @@ NowPlayingItem.propTypes = { title: PropTypes.string.isRequired, username: PropTypes.string.isRequired, playerName: PropTypes.string, - minutesAgo: PropTypes.number.isRequired, album: PropTypes.string, + state: PropTypes.string, + positionMs: PropTypes.number, + duration: PropTypes.number, }).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // NowPlayingList component - handles the popover content const NowPlayingList = React.memo( - ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { const classes = useStyles({ entryCount: entries.length }) const translate = useTranslate() @@ -215,10 +331,11 @@ const NowPlayingList = React.memo( > {entries.map((nowPlayingEntry) => ( ))} @@ -239,12 +356,14 @@ NowPlayingList.propTypes = { entries: PropTypes.arrayOf(PropTypes.object).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // Main NowPlayingPanel component const NowPlayingPanel = () => { const dispatch = useDispatch() const count = useSelector((state) => state.activity.nowPlayingCount) + const lastUpdate = useSelector((state) => state.activity.nowPlayingLastUpdate) const streamReconnected = useSelector( (state) => state.activity.streamReconnected, ) @@ -258,6 +377,7 @@ const NowPlayingPanel = () => { const [anchorEl, setAnchorEl] = useState(null) const [entries, setEntries] = useState([]) + const [now, setNow] = useState(Date.now()) const open = Boolean(anchorEl) const handleMenuOpen = useCallback((event) => { @@ -282,40 +402,57 @@ const NowPlayingPanel = () => { : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` }, []) - const fetchList = useCallback( - () => - subsonic - .getNowPlaying() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - const nowPlayingEntries = data.nowPlaying?.entry || [] - setEntries(nowPlayingEntries) - // Also update the count in Redux store - dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) - } else { - throw new Error( - data.error?.message || 'Failed to fetch now playing data', - ) - } + const fetchTimerRef = useRef(null) + const doFetchRef = useRef() + doFetchRef.current = () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + const fetchTime = Date.now() + setEntries( + nowPlayingEntries.map((e) => ({ ...e, _fetchedAt: fetchTime })), + ) + dispatch(nowPlayingCountSync({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, }) - .catch((error) => { - notify('ra.page.error', 'warning', { - messageArgs: { error: error.message || 'Unknown error' }, - }) - }), - [dispatch, notify], - ) + }) + const fetchList = useCallback(() => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + fetchTimerRef.current = setTimeout(() => { + fetchTimerRef.current = null + doFetchRef.current() + }, 300) + }, []) + + useEffect(() => { + return () => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + } + }, []) // Initialize count and entries on mount, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) - // Refresh when count changes from WebSocket events (if panel is open) + // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() - }, [count, open, fetchList, serverUp]) + }, [lastUpdate, open, fetchList, serverUp]) + + // Update current time every second when open to animate progress bars + useInterval(() => setNow(Date.now()), open ? 1000 : null) // Periodic refresh when panel is open (10 seconds) useInterval( @@ -341,6 +478,7 @@ const NowPlayingPanel = () => { open={open} onClose={handleMenuClose} entries={entries} + now={now} onLinkClick={handleLinkClick} getArtistLink={getArtistLink} /> diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index 4dd5dac8b..ea4a3568b 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -70,7 +70,12 @@ describe('', () => { ) } + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() mockUseMediaQuery.mockReturnValue(false) // Default to large screen @@ -105,10 +110,8 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + // Advance past debounce and flush promises + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -128,21 +131,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect( - screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), - ).toBeInTheDocument() + expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() }) }) it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', @@ -170,19 +168,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + expect(screen.getByText('u1')).toBeInTheDocument() }) }) it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, }, @@ -194,10 +189,7 @@ describe('', () => { , ) - // Wait for initial fetch - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -215,10 +207,7 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) // Open the panel fireEvent.click(screen.getByRole('button')) @@ -268,7 +257,9 @@ describe('', () => { expect(subsonic.getNowPlaying).not.toHaveBeenCalled() }) - it('does not double-fetch on server reconnection', () => { + it('does not double-fetch on server reconnection', async () => { + vi.useFakeTimers() + const initialStore = createMockStore({ nowPlayingCount: 1, serverStart: { startTime: null }, // Server initially down @@ -295,8 +286,13 @@ describe('', () => { , ) + // Advance past the debounce window + vi.advanceTimersByTime(500) + // Should only make one call despite both serverUp and streamReconnected changing expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + + vi.useRealTimers() }) it('skips polling when server is down', () => { diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 8238e395a..d61ed6018 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -3,6 +3,7 @@ import { EVENT_SCAN_STATUS, EVENT_SERVER_START, EVENT_NOW_PLAYING_COUNT, + EVENT_NOW_PLAYING_COUNT_SYNC, EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' @@ -17,6 +18,7 @@ const initialState = { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, // Timestamp of last reconnection } @@ -45,6 +47,12 @@ export const activityReducer = (previousState = initialState, payload) => { }, } case EVENT_NOW_PLAYING_COUNT: + return { + ...previousState, + nowPlayingCount: data.count, + nowPlayingLastUpdate: Date.now(), + } + case EVENT_NOW_PLAYING_COUNT_SYNC: return { ...previousState, nowPlayingCount: data.count } case EVENT_STREAM_RECONNECTED: return { ...previousState, streamReconnected: Date.now() } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index c9db38dbb..e52a262c1 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -18,6 +18,7 @@ describe('activityReducer', () => { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, } @@ -133,6 +134,22 @@ describe('activityReducer', () => { expect(newState.nowPlayingCount).toEqual(5) }) + it('handles EVENT_NOW_PLAYING_COUNT with nowPlayingLastUpdate', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 3 }, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.nowPlayingCount).toEqual(3) + expect(newState.nowPlayingLastUpdate).toBeGreaterThanOrEqual( + beforeTimestamp, + ) + expect(newState.nowPlayingLastUpdate).toBeLessThanOrEqual(afterTimestamp) + }) + it('handles EVENT_STREAM_RECONNECTED', () => { const action = { type: EVENT_STREAM_RECONNECTED, diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 3579619aa..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,9 @@ import { baseUrl } from '../utils' -import { httpClient } from '../dataProvider' +import { + httpClient, + clientUniqueId, + clientUniqueIdHeader, +} from '../dataProvider' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -37,16 +41,21 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true, position = null) => - httpClient( - url('scrobble', id, { - ...(submission && time && { time }), - submission, - ...(!submission && position !== null && { position }), - }), - ) +const reportPlaybackUrl = (mediaId, positionMs, state) => + url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) -const nowPlaying = (id, position = null) => scrobble(id, null, false, position) +const reportPlayback = (mediaId, positionMs, state) => + httpClient(reportPlaybackUrl(mediaId, positionMs, state)) + +const reportPlaybackKeepalive = (mediaId, positionMs, state) => { + const u = reportPlaybackUrl(mediaId, positionMs, state) + if (u) { + fetch(baseUrl(u), { + keepalive: true, + headers: { [clientUniqueIdHeader]: clientUniqueId }, + }) + } +} const star = (id) => httpClient(url('star', id)) @@ -132,8 +141,8 @@ const streamUrl = (id, options) => { export default { url, ping, - scrobble, - nowPlaying, + reportPlayback, + reportPlaybackKeepalive, download, star, unstar, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index a750694f4..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -194,3 +194,33 @@ describe('getAvatarUrl', () => { expect(url).toContain('username=john') }) }) + +describe('reportPlayback', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should construct reportPlayback URL with correct parameters', () => { + const url = subsonic.url('reportPlayback', null, { + mediaId: 'song-123', + mediaType: 'song', + positionMs: 5000, + state: 'playing', + }) + expect(url).toContain('reportPlayback') + expect(url).toContain('mediaId=song-123') + expect(url).toContain('mediaType=song') + expect(url).toContain('positionMs=5000') + expect(url).toContain('state=playing') + }) +}) From 13c48b38a0737236b79af02b4a7bd42cb6ee1b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 1 May 2026 19:21:48 -0400 Subject: [PATCH 11/60] fix(smartplaylists): coerce string booleans in smart playlist rules (#5450) * fix(criteria): coerce string booleans in smart playlist rules - #4826 When clients (e.g. Feishin) send boolean values as strings ("true"/"false") in smart playlist JSON rules, the SQL comparison fails because SQLite stores booleans as 0/1 integers. For example, `COALESCE(annotation.starred, false) = 'true'` never matches. This adds a `boolean` flag to mapped fields and coerces string values to native Go bools in `mapFields`, so squirrel generates correct SQL parameters. Signed-off-by: mango766 Signed-off-by: easonysliu * fix(criteria): implement boolean string coercion for smart playlist rules Signed-off-by: Deluan --------- Signed-off-by: mango766 Signed-off-by: easonysliu Signed-off-by: Deluan Co-authored-by: easonysliu --- model/criteria/fields.go | 13 +- model/criteria/fields_test.go | 1 + model/criteria/json.go | 36 +++++ model/criteria/operators.go | 19 +-- model/criteria/operators_test.go | 75 ++++++++++ persistence/criteria_sql.go | 6 +- persistence/criteria_sql_test.go | 5 + persistence/smart_playlist_repository_test.go | 65 +++++++++ server/e2e/subsonic_playlists_test.go | 130 ++++++++++++++++++ 9 files changed, 325 insertions(+), 25 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b35913f28..9eafff7ab 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -8,6 +8,7 @@ type FieldInfo struct { IsTag bool IsRole bool Numeric bool + Boolean bool tagAlias string // If set, a tag name from mappings.yml that resolves to this field name string // Canonical name, populated by LookupField from the map key @@ -21,7 +22,7 @@ func (f FieldInfo) Name() string { var fieldMap = map[string]FieldInfo{ "title": {}, "album": {}, - "hascoverart": {}, + "hascoverart": {Boolean: true}, "tracknumber": {}, "discnumber": {}, "year": {}, @@ -31,8 +32,8 @@ var fieldMap = map[string]FieldInfo{ "releaseyear": {}, "releasedate": {}, "size": {}, - "compilation": {}, - "missing": {}, + "compilation": {Boolean: true}, + "missing": {Boolean: true}, "explicitstatus": {}, "dateadded": {}, "datemodified": {}, @@ -54,7 +55,7 @@ var fieldMap = map[string]FieldInfo{ "samplerate": {}, "bpm": {}, "channels": {}, - "loved": {}, + "loved": {Boolean: true}, "dateloved": {}, "lastplayed": {}, "daterated": {}, @@ -62,13 +63,13 @@ var fieldMap = map[string]FieldInfo{ "rating": {}, "averagerating": {Numeric: true}, "albumrating": {}, - "albumloved": {}, + "albumloved": {Boolean: true}, "albumplaycount": {}, "albumlastplayed": {}, "albumdateloved": {}, "albumdaterated": {}, "artistrating": {}, - "artistloved": {}, + "artistloved": {Boolean: true}, "artistplaycount": {}, "artistlastplayed": {}, "artistdateloved": {}, diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 270a14473..5b6f53341 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -52,5 +52,6 @@ var _ = Describe("fields", func() { gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) + }) }) diff --git a/model/criteria/json.go b/model/criteria/json.go index 18a664988..ca47ceb95 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -3,6 +3,7 @@ package criteria import ( "encoding/json" "fmt" + "strconv" "strings" ) @@ -38,6 +39,7 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { if err != nil { return nil } + normalizeBoolFields(m) switch opName { case "is": return Is(m) @@ -70,13 +72,47 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { case "notinplaylist": return NotInPlaylist(m) case "ismissing": + normalizeAllBoolFields(m) return IsMissing(m) case "ispresent": + normalizeAllBoolFields(m) return IsPresent(m) } return nil } +func normalizeAllBoolFields(m map[string]any) { + for k, v := range m { + m[k] = normalizeBoolValue(v) + } +} + +func normalizeBoolFields(m map[string]any) { + for field, value := range m { + info, ok := LookupField(field) + if ok && info.Boolean { + m[field] = normalizeBoolValue(value) + } + } +} + +func normalizeBoolValue(v any) any { + switch val := v.(type) { + case string: + if b, err := strconv.ParseBool(val); err == nil { + return b + } + case float64: + if val == 1 { + return true + } + if val == 0 { + return false + } + } + return v +} + func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression { var items unmarshalConjunctionType err := json.Unmarshal(rawValue, &items) diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 7def18934..3ddd77f8b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,9 +1,6 @@ package criteria -import ( - "strconv" - "time" -) +import "time" // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { @@ -181,20 +178,6 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) { func (ip IsPresent) fields() map[string]any { return ip } -func IsTruthy(v any) bool { - switch val := v.(type) { - case bool: - return val - case float64: - return val != 0 - case string: - b, err := strconv.ParseBool(val) - return err == nil && b - default: - return v != nil - } -} - func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index bfdca3e31..17c4272ba 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -31,6 +31,7 @@ var _ = Describe("Operators", func() { }, Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`), Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`), + Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`), Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`), Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`), Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`), @@ -51,4 +52,78 @@ var _ = Describe("Operators", func() { Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) + + Describe("Boolean string coercion at unmarshal time (issue #4826)", func() { + It("coerces string 'true' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces string 'false' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("does not coerce string values for non-boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"})) + }) + + It("coerces numeric 1 to bool true for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces numeric 0 to bool false for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("coerces in nested any/all groups", func() { + var c Criteria + err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + all := c.Expression.(All) + nested := all[1].(Any) + gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces isMissing string 'true' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true})) + }) + + It("coerces isMissing numeric 0 to bool false", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false})) + }) + + It("coerces isPresent string 'false' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false})) + }) + + It("coerces isPresent numeric 1 to bool true", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true})) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 9a99cfa8e..a1bae3170 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -220,7 +220,11 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) } - negate := checkAbsence == criteria.IsTruthy(value) + b, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) + } + negate := checkAbsence == b return jsonExpr(info, nil, negate), nil } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 40d21c9bf..ae2695a4d 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -149,6 +149,11 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) }) + It("returns an error when isMissing has a non-boolean value", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where() + Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 207fe0c36..7bc705385 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -281,6 +281,71 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(pls.Tracks).To(BeEmpty()) }) + + It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() { + // songComeTogether (ID "1002") is starred in test fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ContainElement("1002")) + Expect(len(pls.Tracks)).To(BeNumerically(">=", 1)) + }) + + It("returns same results for string and bool loved values (issue #4826)", func() { + boolRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": true}, + }, + }, + } + boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules} + Expect(repo.Put(&boolPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(boolPls.ID) }) + + stringRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules} + Expect(repo.Put(&stringPls)).To(Succeed()) + testPlaylistID = stringPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + boolResult, err := repo.GetWithTracks(boolPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + stringResult, err := repo.GetWithTracks(stringPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + boolIDs := make([]string, len(boolResult.Tracks)) + for i, t := range boolResult.Tracks { + boolIDs[i] = t.MediaFileID + } + stringIDs := make([]string, len(stringResult.Tracks)) + for i, t := range stringResult.Tracks { + stringIDs[i] = t.MediaFileID + } + Expect(stringIDs).To(ConsistOf(boolIDs)) + }) }) Describe("Smart Playlists with Tag Criteria", func() { diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 3468979f4..466e68cf0 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -517,4 +517,134 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) }) }) + + Describe("Smart Playlist Boolean String Normalization (issue #4826)", Ordered, func() { + var songID string + var boolPlaylistID, stringPlaylistID, nestedPlaylistID string + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + // Star the song via the Subsonic API + resp := doReq("star", "id", songID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Force immediate refresh for all smart playlists + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + // Create smart playlist with boolean true + boolPls := &model.Playlist{ + Name: "Bool Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + boolPlaylistID = boolPls.ID + + // Create smart playlist with string "true" + stringPls := &model.Playlist{ + Name: "String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + stringPlaylistID = stringPls.ID + + // Create smart playlist with string "true" in nested any group (exact issue #4826 scenario) + nestedPls := &model.Playlist{ + Name: "Nested String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }}, + } + Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed()) + nestedPlaylistID = nestedPls.ID + }) + + It("smart playlist with bool loved=true returns starred song", func() { + resp := doReq("getPlaylist", "id", boolPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("smart playlist with string loved='true' returns same results as bool (issue #4826)", func() { + boolResp := doReq("getPlaylist", "id", boolPlaylistID) + stringResp := doReq("getPlaylist", "id", stringPlaylistID) + + Expect(stringResp.Status).To(Equal(responses.StatusOK)) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + + It("nested any group with string loved='true' returns starred song (issue #4826)", func() { + resp := doReq("getPlaylist", "id", nestedPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("isPresent with string 'true' matches songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Present String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + }) + + It("isMissing with string 'true' excludes songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Missing String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(Equal(int32(0))) + }) + + It("isMissing with string 'true' returns same results as bool true", func() { + boolPls := &model.Playlist{ + Name: "Genre Missing Bool", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + + stringPls := &model.Playlist{ + Name: "Genre Missing String2", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + + boolResp := doReq("getPlaylist", "id", boolPls.ID) + stringResp := doReq("getPlaylist", "id", stringPls.ID) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + }) }) From ae0e0c89d9b41e6467359f02a13755b73781864a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 2 May 2026 16:14:53 -0400 Subject: [PATCH 12/60] feat(plugins): add PlaybackReport to scrobbler capability (#5452) * feat(plugins): add PlaybackReport to Scrobbler interface and all implementations * feat(plugins): add PlaybackReport worker and dispatch in PlayTracker * feat(plugins): add PlaybackReportRequest to plugin scrobbler capability * chore(plugins): regenerate PDK files with PlaybackReport * feat(plugins): add PlaybackReport to test scrobbler plugin * feat(plugins): add PlaybackReport to plugin scrobbler adapter * refactor(plugins): fix double DB fetch in StateStopped and batch getActiveScrobblers - Hoist mf from scrobble branch so PlaybackReport reuses it instead of fetching again from DB - Call getActiveScrobblers once per drain batch instead of per-entry * chore(plugins): include generated scrobbler schema with PlaybackReport * fix(plugins): skip PlaybackReport for plugins that don't export it Plugins detected as scrobblers only need to export one scrobbler function. Older plugins that don't export nd_scrobbler_playback_report would cause noisy error logs on every reportPlayback call. Now errFunctionNotFound and errNotImplemented are treated as no-ops. * refactor: rename NowPlayingInfo to PlaybackReport Signed-off-by: Deluan * refactor: rename stopNowPlayingWorker to stopBackgroundWorkers Signed-off-by: Deluan * refactor: move NowPlaying and PlaybackReport logic to separate worker files Signed-off-by: Deluan * refactor(scrobbler): rename NowPlayingInfo to PlaybackSession and add expired state Rename NowPlayingInfo struct to PlaybackSession to better reflect its role as a complete playback session representation. Add UserId field to make sessions self-contained, removing redundant userId parameters from PlaybackReport interface method and internal dispatch functions. Introduce StateExpired internal state that fires when a session cache entry expires without an explicit stop, ensuring plugins always receive a terminal event regardless of client behavior. * fix(scrobbler): update playback state description to include 'expired' Signed-off-by: Deluan * fix(scrobbler): resolve data race in OnExpiration callback Capture conf.Server.EnableNowPlaying at construction time instead of reading it from the background ttlcache eviction goroutine. The previous code raced with test config cleanup that writes to the same field concurrently. * fix(scrobbler): return error when media file lookup fails in StateStopped Simplify the MediaFile population logic in the stopped case to return an error if the track cannot be found. A stop report with an empty MediaFile is useless to plugins, and returning the error allows clients to retry or alert the user when auto-scrobble is enabled. * refactor(scrobbler): use session data directly in PlaybackReport adapter Use info.Username from PlaybackSession instead of extracting it from context in the plugin adapter, since the session is now self-contained. Add debug/trace logging for session expiration and enqueue the expired report with a user-enriched context so downstream handlers can identify the user. --------- Signed-off-by: Deluan --- adapters/lastfm/agent.go | 4 + adapters/listenbrainz/agent.go | 4 + core/scrobbler/buffered_scrobbler.go | 8 + core/scrobbler/interfaces.go | 1 + core/scrobbler/nowplaying_worker.go | 78 +++++++ core/scrobbler/play_tracker.go | 159 +++++++-------- core/scrobbler/play_tracker_test.go | 191 +++++++++++++++--- core/scrobbler/playbackreport_worker.go | 64 ++++++ plugins/capabilities/scrobbler.go | 26 ++- plugins/capabilities/scrobbler.yaml | 44 ++++ plugins/pdk/go/scrobbler/scrobbler.go | 53 ++++- plugins/pdk/go/scrobbler/scrobbler_stub.go | 24 ++- .../rust/nd-pdk-capabilities/src/scrobbler.rs | 41 +++- plugins/scrobbler_adapter.go | 29 ++- plugins/scrobbler_adapter_test.go | 56 +++++ plugins/testdata/test-scrobbler/main.go | 14 ++ server/subsonic/album_lists.go | 2 +- server/subsonic/media_annotation_test.go | 2 +- 18 files changed, 680 insertions(+), 120 deletions(-) create mode 100644 core/scrobbler/nowplaying_worker.go create mode 100644 core/scrobbler/playbackreport_worker.go diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index b3e89a9dc..02c198120 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { return err == nil && sk != "" } +func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface { diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 019c6e9f4..826a9672e 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin return songs, nil } +func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { if conf.Server.ListenBrainz.Enabled { diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index be36e1f24..67593e9eb 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob return nil } +func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + s, ok := b.loader() + if !ok { + return errors.New("scrobbler not available") + } + return s.PlaybackReport(ctx, info) +} + func (b *bufferedScrobbler) sendWakeSignal() { // Don't block if the previous signal was not read yet select { diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index f8567e91b..8a18bb37e 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -23,6 +23,7 @@ type Scrobbler interface { IsAuthorized(ctx context.Context, userId string) bool NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error Scrobble(ctx context.Context, userId string, s Scrobble) error + PlaybackReport(ctx context.Context, info PlaybackSession) error } type Constructor func(ds model.DataStore) Scrobbler diff --git a/core/scrobbler/nowplaying_worker.go b/core/scrobbler/nowplaying_worker.go new file mode 100644 index 000000000..1bacec689 --- /dev/null +++ b/core/scrobbler/nowplaying_worker.go @@ -0,0 +1,78 @@ +package scrobbler + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { + p.npMu.Lock() + defer p.npMu.Unlock() + ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing + p.npQueue[playerId] = nowPlayingEntry{ + ctx: ctx, + userId: userId, + track: track, + position: position, + } + p.sendNowPlayingSignal() +} + +func (p *playTracker) sendNowPlayingSignal() { + // Don't block if the previous signal was not read yet + select { + case p.npSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) nowPlayingWorker() { + defer close(p.workerDone) + for { + select { + case <-p.shutdown: + return + case <-time.After(time.Second): + case <-p.npSignal: + } + + p.npMu.Lock() + if len(p.npQueue) == 0 { + p.npMu.Unlock() + continue + } + + // Keep a copy of the entries to process and clear the queue + entries := p.npQueue + p.npQueue = make(map[string]nowPlayingEntry) + p.npMu.Unlock() + + // Process entries without holding lock + for _, entry := range entries { + p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) + } + } +} + +func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) + return + } + allScrobblers := p.getActiveScrobblers() + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, userId) { + continue + } + log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) + err := s.NowPlaying(ctx, userId, t, position) + if err != nil { + log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) + continue + } + } +} diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 161d04d7d..bdb261ef2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -22,6 +22,7 @@ const ( StatePlaying = "playing" StatePaused = "paused" StateStopped = "stopped" + StateExpired = "expired" ) var ValidStates = map[string]bool{ @@ -31,9 +32,10 @@ var ValidStates = map[string]bool{ StateStopped: true, } -type NowPlayingInfo struct { +type PlaybackSession struct { MediaFile model.MediaFile Start time.Time + UserId string Username string PlayerId string PlayerName string @@ -65,8 +67,13 @@ type nowPlayingEntry struct { position int } +type playbackReportEntry struct { + ctx context.Context + info PlaybackSession +} + type PlayTracker interface { - GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) + GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) Submit(ctx context.Context, submissions []Submission) error ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } @@ -81,7 +88,7 @@ type PluginLoader interface { type playTracker struct { ds model.DataStore broker events.Broker - playMap cache.SimpleCache[string, NowPlayingInfo] + playMap cache.SimpleCache[string, PlaybackSession] builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -91,6 +98,10 @@ type playTracker struct { npSignal chan struct{} shutdown chan struct{} workerDone chan struct{} + prQueue []playbackReportEntry + prMu sync.Mutex + prSignal chan struct{} + prWorkerDone chan struct{} } func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { @@ -106,7 +117,7 @@ func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { - m := cache.NewSimpleCache[string, NowPlayingInfo]() + m := cache.NewSimpleCache[string, PlaybackSession]() p := &playTracker{ ds: ds, playMap: m, @@ -118,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug npSignal: make(chan struct{}, 1), shutdown: make(chan struct{}), workerDone: make(chan struct{}), + prSignal: make(chan struct{}, 1), + prWorkerDone: make(chan struct{}), } - if conf.Server.EnableNowPlaying { - m.OnExpiration(func(_ string, _ NowPlayingInfo) { + enableNowPlaying := conf.Server.EnableNowPlaying + m.OnExpiration(func(_ string, info PlaybackSession) { + log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state", + info.State, "username", info.Username, "userId", info.UserId) + if enableNowPlaying { broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()}) - }) - } + } + ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username}) + if info.State != StateStopped { + log.Trace("Enqueueing PlaybackReport for expired session", "session", info) + info.State = StateExpired + info.LastReport = time.Now() + p.enqueuePlaybackReport(ctx, info) + } + }) var enabled []string for name, constructor := range constructors { @@ -138,13 +161,15 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } log.Debug("List of builtin scrobblers enabled", "names", enabled) go p.nowPlayingWorker() + go p.playbackReportWorker() return p } -// stopNowPlayingWorker stops the background worker. This is primarily for testing. -func (p *playTracker) stopNowPlayingWorker() { +// stopBackgroundWorkers stops the background workers. This is primarily for testing. +func (p *playTracker) stopBackgroundWorkers() { close(p.shutdown) - <-p.workerDone // Wait for worker to finish + <-p.workerDone // Wait for nowPlaying worker to finish + <-p.prWorkerDone // Wait for playbackReport worker to finish } // pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers. @@ -247,9 +272,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } - info := NowPlayingInfo{ + info := PlaybackSession{ MediaFile: *mf, Start: now, + UserId: user.ID, Username: user.UserName, PlayerId: clientId, PlayerName: client, @@ -260,8 +286,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP } err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) if err != nil { - log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } + p.enqueuePlaybackReport(ctx, info) case StatePlaying, StatePaused: info, getErr := p.playMap.Get(clientId) @@ -270,9 +297,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } - info = NowPlayingInfo{ + info = PlaybackSession{ MediaFile: *mf, Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + UserId: user.ID, Username: user.UserName, PlayerId: clientId, PlayerName: client, @@ -286,17 +314,21 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if params.State == StatePlaying { ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) } + log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) err := p.playMap.AddWithTTL(clientId, info, ttl) if err != nil { - log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } + p.enqueuePlaybackReport(ctx, info) case StateStopped: + var loadedMF *model.MediaFile if !params.IgnoreScrobble && player.ScrobbleEnabled { mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } + loadedMF = mf trackDurationMs := int64(mf.Duration * 1000) threshold := min(trackDurationMs*50/100, 240_000) if params.PositionMs >= threshold { @@ -307,6 +339,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.dispatchScrobble(ctx, mf, now) } } + stoppedInfo := PlaybackSession{ + UserId: user.ID, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + if info, getErr := p.playMap.Get(clientId); getErr == nil { + stoppedInfo.MediaFile = info.MediaFile + stoppedInfo.Start = info.Start + } else { + mf := loadedMF + if mf == nil { + var mfErr error + mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if mfErr != nil { + return mfErr + } + } + stoppedInfo.MediaFile = *mf + } + p.enqueuePlaybackReport(ctx, stoppedInfo) p.playMap.Remove(clientId) } @@ -324,77 +381,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP return nil } -func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { - p.npMu.Lock() - defer p.npMu.Unlock() - ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing - p.npQueue[playerId] = nowPlayingEntry{ - ctx: ctx, - userId: userId, - track: track, - position: position, - } - p.sendNowPlayingSignal() -} - -func (p *playTracker) sendNowPlayingSignal() { - // Don't block if the previous signal was not read yet - select { - case p.npSignal <- struct{}{}: - default: - } -} - -func (p *playTracker) nowPlayingWorker() { - defer close(p.workerDone) - for { - select { - case <-p.shutdown: - return - case <-time.After(time.Second): - case <-p.npSignal: - } - - p.npMu.Lock() - if len(p.npQueue) == 0 { - p.npMu.Unlock() - continue - } - - // Keep a copy of the entries to process and clear the queue - entries := p.npQueue - p.npQueue = make(map[string]nowPlayingEntry) - p.npMu.Unlock() - - // Process entries without holding lock - for _, entry := range entries { - p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) - } - } -} - -func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { - if t.Artist == consts.UnknownArtist { - log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) - return - } - allScrobblers := p.getActiveScrobblers() - for name, s := range allScrobblers { - if !s.IsAuthorized(ctx, userId) { - continue - } - log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) - err := s.NowPlaying(ctx, userId, t, position) - if err != nil { - log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) - continue - } - } -} - -func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { +func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { res := p.playMap.Values() - slices.SortFunc(res, func(a, b NowPlayingInfo) int { + slices.SortFunc(res, func(a, b PlaybackSession) int { return b.Start.Compare(a.Start) }) for i := range res { diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 734f8f66f..684b887fe 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -48,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore - var tracker PlayTracker + var tracker *playTracker var eventBroker *fakeEventBroker var track model.MediaFile var album model.Album @@ -71,7 +71,7 @@ var _ = Describe("PlayTracker", func() { }) eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests + tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests track = model.MediaFile{ ID: "123", @@ -96,12 +96,12 @@ var _ = Describe("PlayTracker", func() { AfterEach(func() { // Stop the worker goroutine to prevent data races between tests - tracker.(*playTracker).stopNowPlayingWorker() + tracker.stopBackgroundWorkers() }) It("does not register disabled scrobblers", func() { - Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) - Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) + Expect(tracker.builtinScrobblers).To(HaveKey("fake")) + Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) Describe("GetNowPlaying", func() { @@ -138,8 +138,8 @@ var _ = Describe("PlayTracker", func() { Describe("Expiration events", func() { It("sends event when entry expires", func() { - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond) Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) eventList := eventBroker.getEvents() evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) @@ -150,10 +150,48 @@ var _ = Describe("PlayTracker", func() { It("does not send event when disabled", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond) Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) }) + + It("sends expired playback report when session expires", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-3", + PlayerName: "test-player", + State: StatePlaying, + PositionMs: 5000, + } + _ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond) + Eventually(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).ShouldNot(BeNil()) + report := fake.LastPlaybackReport.Load() + Expect(report.State).To(Equal(StateExpired)) + Expect(report.MediaFile.ID).To(Equal("123")) + Expect(report.PlayerId).To(Equal("player-3")) + }) + + It("does not send expired report when session was already stopped", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-4", + PlayerName: "test-player", + State: StateStopped, + PositionMs: 180000, + } + _ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond) + Consistently(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).Should(BeNil()) + }) }) Describe("Submit", func() { @@ -375,7 +413,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake + tracker.builtinScrobblers["fake"] = fake }) It("broadcasts NowPlayingCount on every state change", func() { @@ -420,7 +458,7 @@ var _ = Describe("PlayTracker", func() { It("does NOT broadcast when EnableNowPlaying is false", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake + tracker.builtinScrobblers["fake"] = fake err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, @@ -697,6 +735,96 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) }) + + Describe("PlaybackReport dispatch", func() { + It("dispatches PlaybackReport for starting state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return fake.PlaybackReportCalled.Load() + }).Should(BeTrue()) + + info := fake.LastPlaybackReport.Load() + Expect(info).ToNot(BeNil()) + Expect(info.MediaFile.ID).To(Equal("123")) + Expect(info.State).To(Equal(StateStarting)) + Expect(info.PositionMs).To(Equal(int64(0))) + Expect(info.PlaybackRate).To(Equal(1.0)) + Expect(info.PlayerId).To(Equal("client-1")) + Expect(info.PlayerName).To(Equal("Test Player")) + }) + + It("dispatches PlaybackReport for playing state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePlaying)) + Expect(info.PositionMs).To(Equal(int64(30000))) + Expect(info.PlaybackRate).To(Equal(1.5)) + }) + + It("dispatches PlaybackReport for paused state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePaused)) + Expect(info.PositionMs).To(Equal(int64(45000))) + }) + + It("dispatches PlaybackReport for stopped state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StateStopped)) + Expect(info.PositionMs).To(Equal(int64(100000))) + }) + }) }) Describe("Plugin scrobbler logic", func() { @@ -712,8 +840,8 @@ var _ = Describe("PlayTracker", func() { tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) // Bypass buffering for both built-in and plugin scrobblers - tracker.(*playTracker).builtinScrobblers["fake"] = fake - tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake + tracker.builtinScrobblers["fake"] = fake + tracker.pluginScrobblers["plugin1"] = pluginFake }) It("registers and uses plugin scrobbler for NowPlaying", func() { @@ -830,7 +958,7 @@ var _ = Describe("PlayTracker", func() { }) AfterEach(func() { - pTracker.stopNowPlayingWorker() + pTracker.stopBackgroundWorkers() }) It("uses the new plugin instance after reload (simulating config update)", func() { @@ -937,15 +1065,17 @@ var _ = DescribeTable("remainingTTL", ) type fakeScrobbler struct { - Authorized bool - nowPlayingCalled atomic.Bool - ScrobbleCalled atomic.Bool - userID atomic.Pointer[string] - username atomic.Pointer[string] - track atomic.Pointer[model.MediaFile] - position atomic.Int32 - LastScrobble atomic.Pointer[Scrobble] - Error error + Authorized bool + nowPlayingCalled atomic.Bool + ScrobbleCalled atomic.Bool + PlaybackReportCalled atomic.Bool + userID atomic.Pointer[string] + username atomic.Pointer[string] + track atomic.Pointer[model.MediaFile] + position atomic.Int32 + LastScrobble atomic.Pointer[Scrobble] + LastPlaybackReport atomic.Pointer[PlaybackSession] + Error error } func (f *fakeScrobbler) GetNowPlayingCalled() bool { @@ -998,6 +1128,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) return nil } +func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + f.PlaybackReportCalled.Store(true) + if f.Error != nil { + return f.Error + } + uid := info.UserId + f.userID.Store(&uid) + f.LastPlaybackReport.Store(&info) + return nil +} + func _p(id, name string, sortName ...string) model.Participant { p := model.Participant{Artist: model.Artist{ID: id, Name: name}} if len(sortName) > 0 { @@ -1053,3 +1194,7 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { return m.wrapped.Scrobble(ctx, userId, s) } + +func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + return m.wrapped.PlaybackReport(ctx, info) +} diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go new file mode 100644 index 000000000..78ca6e0f7 --- /dev/null +++ b/core/scrobbler/playbackreport_worker.go @@ -0,0 +1,64 @@ +package scrobbler + +import ( + "context" + + "github.com/navidrome/navidrome/log" +) + +func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) { + p.prMu.Lock() + defer p.prMu.Unlock() + ctx = context.WithoutCancel(ctx) + p.prQueue = append(p.prQueue, playbackReportEntry{ + ctx: ctx, + info: info, + }) + p.sendPlaybackReportSignal() +} + +func (p *playTracker) sendPlaybackReportSignal() { + select { + case p.prSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) playbackReportWorker() { + defer close(p.prWorkerDone) + for { + select { + case <-p.shutdown: + return + case <-p.prSignal: + } + + p.prMu.Lock() + if len(p.prQueue) == 0 { + p.prMu.Unlock() + continue + } + entries := p.prQueue + p.prQueue = nil + p.prMu.Unlock() + + allScrobblers := p.getActiveScrobblers() + for _, entry := range entries { + p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers) + } + } +} + +func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) { + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, info.UserId) { + continue + } + log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs) + err := s.PlaybackReport(ctx, info) + if err != nil { + log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err) + continue + } + } +} diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index ed8a4fb6c..4918d5e8f 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -5,7 +5,7 @@ package capabilities // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. // //nd:capability name=scrobbler required=true type Scrobbler interface { @@ -20,6 +20,10 @@ type Scrobbler interface { // Scrobble submits a completed scrobble to the scrobbling service. //nd:export name=nd_scrobbler_scrobble Scrobble(ScrobbleRequest) error + + // PlaybackReport sends a playback state report to the scrobbling service. + //nd:export name=nd_scrobbler_playback_report + PlaybackReport(PlaybackReportRequest) error } // IsAuthorizedRequest is the request for authorization check. @@ -96,6 +100,26 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 8ada5f7e4..9d5cfed30 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -18,6 +18,11 @@ exports: input: $ref: '#/components/schemas/ScrobbleRequest' contentType: application/json + nd_scrobbler_playback_report: + description: PlaybackReport sends a playback state report to the scrobbling service. + input: + $ref: '#/components/schemas/PlaybackReportRequest' + contentType: application/json components: schemas: ArtistRef: @@ -59,6 +64,45 @@ components: - username - track - position + PlaybackReportRequest: + description: PlaybackReportRequest is the request for playback report notifications. + properties: + username: + type: string + description: Username is the username of the user. + track: + $ref: '#/components/schemas/TrackInfo' + description: Track is the track being played. + state: + type: string + description: State is the current playback state (starting/playing/paused/stopped/expired). + positionMs: + type: integer + format: int64 + description: PositionMs is the current playback position in milliseconds. + playbackRate: + type: number + format: float + description: PlaybackRate is the playback speed (1.0 = normal). + playerId: + type: string + description: PlayerId is the unique client identifier. + playerName: + type: string + description: PlayerName is the human-readable player name. + timestamp: + type: integer + format: int64 + description: Timestamp is the Unix timestamp when this report was generated. + required: + - username + - track + - state + - positionMs + - playbackRate + - playerId + - playerName + - timestamp ScrobbleRequest: description: ScrobbleRequest is the request for submitting a scrobble. properties: diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index d27ae3a9c..0d045e597 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -52,6 +52,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -106,7 +126,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -114,11 +134,14 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // Internal implementation holders var ( - isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) - nowPlayingImpl func(NowPlayingRequest) error - scrobbleImpl func(ScrobbleRequest) error + isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) + nowPlayingImpl func(NowPlayingRequest) error + scrobbleImpl func(ScrobbleRequest) error + playbackReportImpl func(PlaybackReportRequest) error ) // Register registers a scrobbler implementation. @@ -127,6 +150,7 @@ func Register(impl Scrobbler) { isAuthorizedImpl = impl.IsAuthorized nowPlayingImpl = impl.NowPlaying scrobbleImpl = impl.Scrobble + playbackReportImpl = impl.PlaybackReport } // NotImplementedCode is the standard return code for unimplemented functions. @@ -201,3 +225,24 @@ func _NdScrobblerScrobble() int32 { return 0 } + +//go:wasmexport nd_scrobbler_playback_report +func _NdScrobblerPlaybackReport() int32 { + if playbackReportImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input PlaybackReportRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + if err := playbackReportImpl(input); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 9e6f706ac..b35e7c40e 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -49,6 +49,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -103,7 +123,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -111,6 +131,8 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // NotImplementedCode is the standard return code for unimplemented functions. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 348460374..1e9c51375 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -62,6 +62,35 @@ pub struct NowPlayingRequest { #[serde(default)] pub position: i32, } +/// PlaybackReportRequest is the request for playback report notifications. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackReportRequest { + /// Username is the username of the user. + #[serde(default)] + pub username: String, + /// Track is the track being played. + #[serde(default)] + pub track: TrackInfo, + /// State is the current playback state (starting/playing/paused/stopped/expired). + #[serde(default)] + pub state: String, + /// PositionMs is the current playback position in milliseconds. + #[serde(default)] + pub position_ms: i64, + /// PlaybackRate is the playback speed (1.0 = normal). + #[serde(default)] + pub playback_rate: f64, + /// PlayerId is the unique client identifier. + #[serde(default)] + pub player_id: String, + /// PlayerName is the human-readable player name. + #[serde(default)] + pub player_name: String, + /// Timestamp is the Unix timestamp when this report was generated. + #[serde(default)] + pub timestamp: i64, +} /// ScrobbleRequest is the request for submitting a scrobble. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -158,7 +187,7 @@ impl Error { /// ListenBrainz, or custom scrobbling backends. /// /// All methods are required - plugins implementing this capability must provide -/// all three functions: IsAuthorized, NowPlaying, and Scrobble. +/// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. pub trait Scrobbler { /// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. fn is_authorized(&self, req: IsAuthorizedRequest) -> Result; @@ -166,6 +195,8 @@ pub trait Scrobbler { fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>; /// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>; + /// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error>; } /// Register all exports for the Scrobbler capability. @@ -197,5 +228,13 @@ macro_rules! register_scrobbler { $crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?; Ok(()) } + #[extism_pdk::plugin_fn] + pub fn nd_scrobbler_playback_report( + req: extism_pdk::Json<$crate::scrobbler::PlaybackReportRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::scrobbler::Scrobbler::playback_report(&plugin, req.into_inner())?; + Ok(()) + } }; } diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 302f5e1da..8abdccf07 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "errors" "strings" "github.com/navidrome/navidrome/core/scrobbler" @@ -16,9 +17,10 @@ const CapabilityScrobbler Capability = "Scrobbler" // Scrobbler function names (snake_case as per design) const ( - FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" - FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" - FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" + FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" + FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report" ) func init() { @@ -27,6 +29,7 @@ func init() { FuncScrobblerIsAuthorized, FuncScrobblerNowPlaying, FuncScrobblerScrobble, + FuncScrobblerPlaybackReport, ) } @@ -182,5 +185,25 @@ func mapScrobblerError(err error) error { } } +// PlaybackReport sends a playback state report to the scrobbler +func (s *ScrobblerPlugin) PlaybackReport(ctx context.Context, info scrobbler.PlaybackSession) error { + input := capabilities.PlaybackReportRequest{ + Username: info.Username, + Track: mediaFileToTrackInfo(s.plugin, &info.MediaFile), + State: info.State, + PositionMs: info.PositionMs, + PlaybackRate: info.PlaybackRate, + PlayerId: info.PlayerId, + PlayerName: info.PlayerName, + Timestamp: info.LastReport.Unix(), + } + + err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerPlaybackReport, input) + if errors.Is(err, errFunctionNotFound) || errors.Is(err, errNotImplemented) { + return nil + } + return mapScrobblerError(err) +} + // Verify interface implementation at compile time var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index c56d8a900..56a452742 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -229,6 +229,62 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }) }) + Describe("PlaybackReport", func() { + It("successfully calls the plugin", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ + ID: "track-1", + Title: "Test Song", + Album: "Test Album", + Artist: "Test Artist", + AlbumArtist: "Test Album Artist", + Duration: 180, + TrackNumber: 1, + DiscNumber: 1, + Participants: model.Participants{ + model.RoleArtist: {{Artist: model.Artist{ID: "artist-1", Name: "Test Artist"}}}, + model.RoleAlbumArtist: {{Artist: model.Artist{ID: "album-artist-1", Name: "Test Album Artist"}}}, + }, + }, + Username: "testuser", + PlayerId: "player-1", + PlayerName: "Test Player", + State: "playing", + PositionMs: 30000, + PlaybackRate: 1.0, + LastReport: time.Now(), + } + + err := s.PlaybackReport(ctxWithUser(), info) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("when plugin returns error", Ordered, func() { + var retryScrobbler scrobbler.Scrobbler + + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"}, + }, "test-scrobbler"+PackageExtension) + + var ok bool + retryScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns ErrRetryLater", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, + State: "playing", + LastReport: time.Now(), + } + err := retryScrobbler.PlaybackReport(ctxWithUser(), info) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + }) + }) + Describe("PluginNames", func() { It("returns plugin names with Scrobbler capability", func() { names := scrobblerManager.PluginNames("Scrobbler") diff --git a/plugins/testdata/test-scrobbler/main.go b/plugins/testdata/test-scrobbler/main.go index d9c142d51..a8cee4a4e 100644 --- a/plugins/testdata/test-scrobbler/main.go +++ b/plugins/testdata/test-scrobbler/main.go @@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error { return nil } +// PlaybackReport receives a playback state report. +func (t *testScrobbler) PlaybackReport(input scrobbler.PlaybackReportRequest) error { + if err := checkConfigError(); err != nil { + return err + } + + artistName := "" + if len(input.Track.Artists) > 0 { + artistName = input.Track.Artists[0].Name + } + pdk.Log(pdk.LogInfo, "PlaybackReport: "+input.Track.Title+" by "+artistName+" state="+input.State) + return nil +} + // checkConfigError checks if the plugin is configured to return an error. // If "error" config is set, it returns the appropriate ScrobblerError. // Error types: "not_authorized", "retry_later", "unrecoverable" diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 2a2338d89..0d82c8be9 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -212,7 +212,7 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.NowPlaying = &responses.NowPlaying{} var i int32 - response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.PlaybackSession) responses.NowPlayingEntry { i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index f5cf434e0..487335d1a 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -193,7 +193,7 @@ type fakePlayTracker struct { Error error } -func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { +func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) { return nil, f.Error } From a00152397e0807ec906768d79f3e619adf43b3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 2 May 2026 19:48:44 -0400 Subject: [PATCH 13/60] fix(artwork): prefer album-root images over disc-subfolder images for multi-disc albums (#5451) Fixed two bugs in album cover art resolution for multi-disc layouts: 1. compareImageFiles now sorts by path depth (shallower first) when basenames tie, so album-root images like Artist/Album/cover.jpg are preferred over disc-subfolder images like Artist/Album/CD1/cover.jpg. 2. commonParentFolder now includes the parent folder for single-disc-subfolder albums, with a Path != "." guard to avoid pulling artist-folder images. Closes #5376 --- core/artwork/e2e/album_test.go | 12 +++--- core/artwork/reader_album.go | 48 +++++++++++++--------- core/artwork/reader_album_test.go | 68 +++++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 30 deletions(-) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 3d5523afd..370844e34 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -39,7 +39,7 @@ var _ = Describe("Album artwork resolution", func() { // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder - // files first. Flip from PIt to It once it prefers shorter/parent paths. + // files first. When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { // Artist/ // └── Album/ @@ -50,7 +50,7 @@ var _ = Describe("Album artwork resolution", func() { // │ ├── 01 - Track.mp3 // │ └── cover.jpg // └── cover.jpg ← should win (album-root fallback) - PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() { + It("prefers the album-root cover over per-disc covers", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), @@ -71,7 +71,6 @@ var _ = Describe("Album artwork resolution", func() { // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; // the lexicographic full-path tiebreaker in compareImageFiles ranks // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". - // Flip from PIt to It once compareImageFiles prefers shorter/parent paths. When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { // Artist/ // └── Album/ @@ -82,7 +81,7 @@ var _ = Describe("Album artwork resolution", func() { // │ ├── 01 - Track.mp3 // │ └── folder.jpg // └── folder.jpg ← should win (album-root fallback) - PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() { + It("prefers the album-root folder.jpg over per-disc folder.jpg", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), @@ -100,15 +99,14 @@ var _ = Describe("Album artwork resolution", func() { // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder // lookup whenever an album lives entirely under a single subfolder, so an - // album-root cover is never considered. Flip from PIt to It once the guard - // accepts single-folder albums whose parent isn't already in the folder set. + // album-root cover is never considered. When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { // Artist/ // └── Album/ // ├── disc1/ // │ └── 01 - Track.mp3 // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) - PIt("uses the parent-folder cover (currently ignored — bug)", func() { + It("uses the parent-folder cover for single-disc-subfolder albums", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 8d7e14fd0..cf5497641 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -118,19 +118,22 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo folderIDSet[id] = true } - // For multi-disc albums (2+ folders), check if all folders share a common parent - // that is not already included. This finds cover art in the album root folder - // (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/"). - // We skip single-folder albums to avoid pulling images from the artist folder. + // Check if all folders share a common parent that is not already included. + // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg" + // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/"). + // For single-folder albums, the parent is only included when the folder has no + // images of its own (indicating a disc subfolder needing parent artwork). if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { - parentFolder, err := ds.Folder(ctx).Get(commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) - } else if err != nil { - return nil, nil, nil, err - } - if parentFolder != nil { - folders = append(folders, *parentFolder) + if len(folders) >= 2 || !anyFolderHasImages(folders) { + parentFolder, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + } else if err != nil { + return nil, nil, nil, err + } + if parentFolder != nil && parentFolder.Path != "." { + folders = append(folders, *parentFolder) + } } } @@ -156,10 +159,19 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return paths, imgFiles, &updatedAt, nil } +func anyFolderHasImages(folders []model.Folder) bool { + for _, f := range folders { + if len(f.ImageFiles) > 0 { + return true + } + } + return false +} + // commonParentFolder returns the shared parent folder ID when all folders have the // same parent and that parent is not already in folderIDSet. Returns "" otherwise. func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string { - if len(folders) < 2 { + if len(folders) == 0 { return "" } parentID := folders[0].ParentID @@ -174,11 +186,8 @@ func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) str return parentID } -// compareImageFiles compares two image file paths for sorting. -// It extracts the base filename (without extension) and compares case-insensitively. -// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1". -// Note: This function is called O(n log n) times during sorting, but in practice albums -// typically have only 1-20 image files, making the repeated string operations negligible. +// compareImageFiles sorts image paths by: base filename (natural order), +// then path depth (shallower first), then full path (stable tiebreaker). func compareImageFiles(a, b string) int { // Case-insensitive comparison a = strings.ToLower(a) @@ -188,9 +197,10 @@ func compareImageFiles(a, b string) int { baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) - // Compare base names first, then full paths if equal + // Compare base names first, then prefer shallower paths, then full path as tiebreaker return cmp.Or( natural.Compare(baseA, baseB), + cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")), natural.Compare(a, b), ) } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index 03412b6d9..b8f4f2dfa 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -213,9 +213,42 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(0)) }) - It("does not query parent for single-folder albums", func() { - // A single-folder album's parent is typically the artist folder, - // which should not be searched for cover art + It("does not include top-level parent for multi-folder albums", func() { + // Two album parts under the same artist folder — parent is artist-level + repo.result = []model.Folder{ + { + ID: "folder1", + Path: ".", + Name: "AlbumPart1", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: ".", + Name: "AlbumPart2", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "artistFolder", + Path: ".", + Name: "Artist", + ImageFiles: []string{"artist.jpg"}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("does not query parent for single-folder albums that already have images", func() { repo.result = []model.Folder{ { ID: "folder1", @@ -232,10 +265,37 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - // Get should not have been called (single folder, no parent lookup) Expect(repo.getCallCount).To(Equal(0)) }) + It("includes parent images for single-disc-subfolder albums", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: "Artist", + Name: "Album", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + It("propagates non-ErrNotFound errors from parent folder lookup", func() { repo.result = []model.Folder{ { From 52099ce91f3936885694749ad7a39ddf33e26925 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 3 May 2026 11:26:07 -0400 Subject: [PATCH 14/60] test: fix flaky watcher and scheduler tests on Windows CI Replace timing-sensitive time.Sleep synchronization with proper Eventually/Consistently assertions in watcher tests, and increase Eventually timeouts from 200ms to 500ms. Add FlakeAttempts(3) to the inherently timing-dependent tests. For the scheduler test, increase the Eventually timeout from 1s to 5s for the cron job execution check. --- scanner/watcher_test.go | 85 ++++++++++++++++--------------------- scheduler/scheduler_test.go | 5 ++- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index a4016d470..9795129b0 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -74,23 +74,22 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("creates separate targets for different folders", func() { + It("creates separate targets for different folders", FlakeAttempts(3), func() { // Send notifications for different folders w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"} - // Wait for watcher to process and trigger scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) - // Verify two targets + // Verify targets calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) - - // Extract folder paths folderPaths := make(map[string]bool) for _, target := range calls[0].Targets { Expect(target.LibraryID).To(Equal(1)) @@ -107,7 +106,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify the target calls := mockScanner.GetScanFoldersCalls() @@ -117,20 +116,15 @@ var _ = Describe("Watcher", func() { }) It("deduplicates folder and file within same folder", func() { - // Send notification for a folder + // Send multiple notifications for the same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send notification for same folder (as if file change was detected there) - // In practice, watchLibrary() would walk up from file path to folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send another for same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify only one target despite multiple file/folder changes calls := mockScanner.GetScanFoldersCalls() @@ -151,32 +145,27 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("resets timer on each change (debouncing)", func() { + It("resets timer on each change (debouncing)", FlakeAttempts(3), func() { // Send first notification w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time to ensure timer doesn't fire - time.Sleep(20 * time.Millisecond) - - // No scan should have been triggered yet - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + // Verify no scan fires during a window shorter than the debounce wait + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) // Send another notification (resets timer) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time again - time.Sleep(20 * time.Millisecond) + // Again, no scan should fire within a short window + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) - // Still no scan - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) - - // Wait for full timer to expire after last notification (plus margin) - time.Sleep(60 * time.Millisecond) - - // Now scan should have been triggered + // Now wait for the debounce timer to expire and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) It("triggers scan after quiet period", func() { @@ -189,7 +178,7 @@ var _ = Describe("Watcher", func() { // Wait for quiet period Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) }) @@ -211,7 +200,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should scan the library root calls := mockScanner.GetScanFoldersCalls() @@ -223,13 +212,12 @@ var _ = Describe("Watcher", func() { It("deduplicates empty and dot paths", func() { // Send notifications with empty and dot paths w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should have only one target calls := mockScanner.GetScanFoldersCalls() @@ -264,20 +252,19 @@ var _ = Describe("Watcher", func() { It("creates separate targets for different libraries", func() { // Send notifications for both libraries w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"} - // Wait for scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) - - // Verify two targets for different libraries - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) // Verify library IDs are different + calls := mockScanner.GetScanFoldersCalls() libraryIDs := make(map[int]bool) for _, target := range calls[0].Targets { libraryIDs[target.LibraryID] = true diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go index 1a134a7f3..8ae69c19b 100644 --- a/scheduler/scheduler_test.go +++ b/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler import ( "testing" + "time" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" @@ -28,7 +29,7 @@ var _ = Describe("Scheduler", func() { s.c.Stop() // Stop the scheduler after tests }) - It("adds and executes a job", func() { + It("adds and executes a job", FlakeAttempts(3), func() { done := make(chan struct{}) id, err := s.Add("@every 50ms", func() { @@ -38,7 +39,7 @@ var _ = Describe("Scheduler", func() { Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeZero()) - Eventually(done).Should(BeClosed()) + Eventually(done, 5*time.Second).Should(BeClosed()) }) It("adds a job with random ~ syntax", func() { From dd2b6865b00d669f0c6193501ccfc9934f19ccba Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 3 May 2026 12:34:00 -0400 Subject: [PATCH 15/60] chore(deps): update Go dependencies in go.mod and go.sum Signed-off-by: Deluan --- go.mod | 18 +++++++++--------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index a4c0c014b..36218ba6b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.26.0 +go 1.26 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a @@ -35,12 +35,12 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.0 - github.com/mattn/go-sqlite3 v1.14.42 + github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.2 - github.com/onsi/gomega v1.39.1 - github.com/pelletier/go-toml/v2 v2.3.0 + github.com/onsi/ginkgo/v2 v2.28.3 + github.com/onsi/gomega v1.40.0 + github.com/pelletier/go-toml/v2 v2.3.1 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.1 @@ -70,7 +70,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -81,7 +81,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -93,7 +93,7 @@ require ( github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -138,7 +138,7 @@ require ( golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 3e665ba02..90e0ec040 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= +github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE= -github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -175,8 +175,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -193,12 +193,12 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= -github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= -github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= +github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= +github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= +github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -409,8 +409,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From a34a4abbc1a79c90a7e4f535c646cb75daf12913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 4 May 2026 13:59:05 -0400 Subject: [PATCH 16/60] chore(ci): update GitHub Actions to latest major versions (#5462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): update GitHub Actions to latest major versions Update actions/cache v4→v5, actions/github-script v3→v7, actions/stale v9→v10, docker/login-action v3→v4, docker/setup-buildx-action v3→v4, and docker/metadata-action v5→v6. The github-script upgrade also migrates Octokit API calls from github.* to github.rest.* namespace (required since v5). * fix(ci): address review feedback on GitHub Actions update Pass github_token to docker/metadata-action@v6 to avoid API rate limiting. Fix github-script pagination to use the correct Octokit paginate.iterator pattern (pass endpoint method, not awaited response). --- .github/actions/prepare-docker/action.yml | 9 +++++---- .github/workflows/download-link-on-pr.yml | 13 ++++++------- .github/workflows/pipeline.yml | 2 +- .github/workflows/stale.yml | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 760a0528b..7fe0b9148 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -53,13 +53,13 @@ runs: - name: Login to Docker Hub if: inputs.hub_username != '' && inputs.hub_password != '' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ inputs.hub_username }} password: ${{ inputs.hub_password }} - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,12 +67,13 @@ runs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Extract metadata for Docker image id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: + token: ${{ inputs.github_token }} labels: | maintainer=deluan@navidrome.org images: | diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 38b7b8a86..076f963d4 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v3 + - uses: actions/github-script@v7 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml @@ -19,8 +19,7 @@ jobs: const pull_user_id = ${{github.event.sender.id}}; const issue_number = await (async () => { - const pulls = await github.pulls.list({owner, repo}); - for await (const {data} of github.paginate.iterator(pulls)) { + for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) { for (const pull of data) { if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) { return pull.number; @@ -34,7 +33,7 @@ jobs: return core.error(`No matching pull request found`); } - const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id}); + const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); if (!artifacts.length) { return core.error(`No artifacts found`); } @@ -43,12 +42,12 @@ jobs: body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } - const {data: comments} = await github.issues.listComments({repo, owner, issue_number}); + const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); - await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); + await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); } else { core.info(`Creating a comment`); - await github.issues.createComment({repo, owner, issue_number, body}); + await github.rest.issues.createComment({repo, owner, issue_number, body}); } diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 09fca2572..6f858a5a7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -145,7 +145,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 69e6ac99e..33c8fadbd 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -28,7 +28,7 @@ jobs: This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: operations-per-run: 999 days-before-issue-stale: 180 From f48416685f8b223ce4ff437629433e7b53915b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 4 May 2026 17:26:39 -0400 Subject: [PATCH 17/60] fix(artwork): fix stale cache and top-level album artwork for multi-disc albums (#5457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(artwork): include top-level album folders in parent cover art lookup The Path != "." guard added in #5451 was too aggressive — it excluded any folder with Path=".", which includes top-level album folders (not just the library root). Changed to ParentID != "" which correctly excludes only the actual library root folder. Fixes #5456 * fix: correct comment in test — album is under library root, not artist root * test: add ascii tree diagram to top-level album e2e test * test: replace internal bug references with issue link in e2e comments Signed-off-by: Deluan * test: add e2e test matching reporter's exact library layout (#5456) Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs using the reporter's actual folder names to verify artwork resolution works for non-top-level album folders too. * fix(scanner): use a syntectic admin user when no admin user is found Signed-off-by: Deluan * fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache When Phase 3 corrects an album's FolderIDs (or any other field), bump UpdatedAt to the current time. This ensures the artwork cache key changes, invalidating any stale artwork that was resolved and cached during Phase 1 when the album had incomplete folder data. * fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt semantics) and instead includes album.ImportedAt in the artwork cache key computation. Since ImportedAt is bumped to time.Now() on every album Put, any Phase 3 correction naturally invalidates cached artwork that was resolved mid-scan with incomplete folder data. * fix(artwork): simplify lastUpdate logic using TimeNewest utility Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/artwork/e2e/album_test.go | 49 ++++++++++++---- core/artwork/e2e/disc_test.go | 95 +++++++++++++++++++++++++++++++ core/artwork/reader_album.go | 10 ++-- core/artwork/reader_album_test.go | 59 ++++++++++++++++--- core/artwork/reader_disc.go | 8 +-- core/auth/auth.go | 2 +- 6 files changed, 193 insertions(+), 30 deletions(-) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 370844e34..e765e1b1b 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -37,15 +37,15 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; - // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder - // files first. + // https://github.com/navidrome/navidrome/issues/5376 + // cover.* basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { // Artist/ // └── Album/ // ├── CD1/ // │ ├── 01 - Track.mp3 - // │ └── cover.jpg ← currently wins (bug) + // │ └── cover.jpg ← should not win // ├── CD2/ // │ ├── 01 - Track.mp3 // │ └── cover.jpg @@ -68,15 +68,15 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; - // the lexicographic full-path tiebreaker in compareImageFiles ranks - // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". + // https://github.com/navidrome/navidrome/issues/5376 + // folder.jpg basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { // Artist/ // └── Album/ // ├── CD1/ // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← currently wins (bug) + // │ └── folder.jpg ← should not win // ├── CD2/ // │ ├── 01 - Track.mp3 // │ └── folder.jpg @@ -97,15 +97,14 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder - // lookup whenever an album lives entirely under a single subfolder, so an - // album-root cover is never considered. + // https://github.com/navidrome/navidrome/issues/5376 + // Single-subfolder albums must still consider the parent folder's images. When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { // Artist/ // └── Album/ // ├── disc1/ // │ └── 01 - Track.mp3 - // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) + // └── cover.jpg ← should win (parent-folder fallback) It("uses the parent-folder cover for single-disc-subfolder albums", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ @@ -119,6 +118,32 @@ var _ = Describe("Album artwork resolution", func() { }) }) + // https://github.com/navidrome/navidrome/issues/5456 + When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() { + // Album/ (top-level folder, Path=".") + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── cover.jpg ← should win (album-root) + It("prefers the album-root cover.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Album/cover.jpg": imageFile("album-root"), + "Album/CD1/folder.jpg": imageFile("disc1"), + "Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { // Artist/ // └── Album/ diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go index 7569cbc32..667079458 100644 --- a/core/artwork/e2e/disc_test.go +++ b/core/artwork/e2e/disc_test.go @@ -1,6 +1,7 @@ package artworke2e_test import ( + "fmt" "testing/fstest" "github.com/navidrome/navidrome/conf" @@ -255,6 +256,100 @@ var _ = Describe("Disc artwork resolution", func() { }) }) + // Reproduces https://github.com/navidrome/navidrome/issues/5456 + // Deeply nested layout matching the reporter's actual structure. + When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Genre/Artist/Album/ ← album root with cover.jpg + // ├── cover.jpg ← album-level cover + // ├── Disc 01 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── ... (12 discs) + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + discNames := []string{ + "Disc 01 (Birth of the Dead - The Studio Sides)", + "Disc 02 (Birth of the Dead - The Live Sides)", + "Disc 03 (The Grateful Dead)", + "Disc 04 (Anthem of the Sun)", + "Disc 05 (Aoxomoxoa)", + "Disc 06 (Live; Dead)", + "Disc 07 (Workingman's Dead)", + "Disc 08 (American Beauty)", + "Disc 09 (Grateful Dead)", + "Disc 10 (Europe '72)", + "Disc 11 (Europe '72)", + "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", + } + layout := fstest.MapFS{ + "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), + } + for i, name := range discNames { + discNum := i + 1 + prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := range discNames { + discNum := i + 1 + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), + "disc %d should use its own folder.jpg", discNum) + } + }) + }) + + // https://github.com/navidrome/navidrome/issues/5456 + // Top-level album variant — album folder at library root (Path="."). + When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Album/ (top-level, Path=".") + // ├── cover.jpg ← album-level cover + // ├── Disc 01/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── Disc 03/ + // ├── 01 - Track.mp3 + // └── folder.jpg + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + layout := fstest.MapFS{ + "Album/cover.jpg": imageFile("album-root-cover"), + } + for i := 1; i <= 3; i++ { + prefix := fmt.Sprintf("Album/Disc %02d/", i) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := 1; i <= 3; i++ { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), + "disc %d should use its own folder.jpg", i) + } + }) + }) + When("discsubtitle is set but no image filename matches the subtitle", func() { // Artist/ // └── Album/ diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index cf5497641..73ba9b5ee 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" ) @@ -53,10 +54,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar lib: lib, } a.cacheKey.artID = artID - if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { - a.cacheKey.lastUpdate = *a.updatedAt - } else { - a.cacheKey.lastUpdate = al.UpdatedAt + a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdateAt != nil { + a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) } return a, nil } @@ -131,7 +131,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo } else if err != nil { return nil, nil, nil, err } - if parentFolder != nil && parentFolder.Path != "." { + if parentFolder != nil && parentFolder.ParentID != "" { folders = append(folders, *parentFolder) } } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index b8f4f2dfa..1cf039bee 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -141,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() { ID: "parentFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImagesUpdatedAt: expectedAt, ImageFiles: []string{"cover.jpg", "back.jpg"}, } @@ -213,14 +214,14 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(0)) }) - It("does not include top-level parent for multi-folder albums", func() { - // Two album parts under the same artist folder — parent is artist-level + It("does not include library root parent for multi-folder albums", func() { + // Two album parts directly under the library root — parent is the root itself repo.result = []model.Folder{ { ID: "folder1", Path: ".", Name: "AlbumPart1", - ParentID: "artistFolder", + ParentID: "rootFolder", ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}, }, @@ -228,16 +229,17 @@ var _ = Describe("Album Artwork Reader", func() { ID: "folder2", Path: ".", Name: "AlbumPart2", - ParentID: "artistFolder", + ParentID: "rootFolder", ImagesUpdatedAt: now, ImageFiles: []string{}, }, } repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ImageFiles: []string{"artist.jpg"}, + ID: "rootFolder", + Path: "", + Name: ".", + ParentID: "", + ImageFiles: []string{"unrelated.jpg"}, } _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) @@ -248,6 +250,46 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(1)) }) + It("includes top-level album folder for multi-disc albums", func() { + // Album folder directly under library root, with disc subfolders + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Album", + Name: "Disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + { + ID: "folder2", + Path: "Album", + Name: "Disc2", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: ".", + Name: "Album", + ParentID: "rootFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(3)) + Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) + Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + It("does not query parent for single-folder albums that already have images", func() { repo.result = []model.Folder{ { @@ -283,6 +325,7 @@ var _ = Describe("Album Artwork Reader", func() { ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImagesUpdatedAt: expectedAt, ImageFiles: []string{"cover.jpg"}, } diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index de0a765f0..0f648c987 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" ) type discArtworkReader struct { @@ -105,10 +106,9 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID updatedAt: imagesUpdatedAt, } r.cacheKey.artID = artID - if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) { - r.cacheKey.lastUpdate = *r.updatedAt - } else { - r.cacheKey.lastUpdate = al.UpdatedAt + r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdatedAt != nil { + r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) } return r, nil } diff --git a/core/auth/auth.go b/core/auth/auth.go index a75111b35..7b3511bdf 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { } else { log.Error(ctx, "No admin user found!", err) } - u = &model.User{} + u = &model.User{IsAdmin: true, UserName: "admin"} } ctx = request.WithUsername(ctx, u.UserName) From 5b85b2839a432bbe521d3c56d6b4f10657af623e Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 4 May 2026 18:04:11 -0400 Subject: [PATCH 18/60] fix(scanner): fix error when importing playlists without an admin user Signed-off-by: Deluan --- scanner/phase_4_playlists.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index f726343f2..d52743966 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -50,7 +50,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { return nil } u, _ := request.UserFrom(p.ctx) - if !u.IsAdmin { + if !u.IsAdmin || u.ID == "" { log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ "Please create an admin user first, and then update the playlists for them to be imported") return nil From 39f8eec8d27b2eee3da289f4736267984f6d339a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 5 May 2026 18:49:24 -0400 Subject: [PATCH 19/60] fix(ui): add Rescan button to plugin list empty state (#5471) * feat(ui): add Rescan button to plugin list empty state When no plugins are installed and the folder watcher fails to detect new plugins, users had no way to trigger a rescan. Extract RescanButton into a shared component and render it in a custom empty state for the plugin list. * refactor(ui): address review feedback for plugin empty state - Pass label translation key directly to RA Button (auto-translates) - Use within() from Testing Library instead of querySelector for scoped queries with better error messages --- ui/src/plugin/PluginList.jsx | 46 ++++++++++++++++++++++-------- ui/src/plugin/PluginList.test.jsx | 47 ++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx index 67af85b81..346e1ceae 100644 --- a/ui/src/plugin/PluginList.jsx +++ b/ui/src/plugin/PluginList.jsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react' import { Button, Datagrid, + Empty, TextField, TopToolbar, useNotify, @@ -10,7 +11,13 @@ import { useTranslate, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' -import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core' +import { + useMediaQuery, + Tooltip, + Chip, + Typography, + Box, +} from '@material-ui/core' import { MdError, MdRefresh } from 'react-icons/md' import { List, DateField, SimpleList, useResourceRefresh } from '../common' import { httpClient } from '../dataProvider' @@ -72,8 +79,7 @@ const ManifestField = ({ source }) => { return {manifest[source] || '-'} } -const PluginListActions = () => { - const translate = useTranslate() +const RescanButton = () => { const notify = useNotify() const refresh = useRefresh() const [loading, setLoading] = useState(false) @@ -92,20 +98,37 @@ const PluginListActions = () => { }) }, [notify, refresh]) + return ( + + ) +} + +const PluginListActions = () => { return ( - + ) } +const PluginEmpty = () => { + return ( + <> + + + + + + ) +} + const PluginList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() @@ -118,6 +141,7 @@ const PluginList = (props) => { exporter={false} bulkActionButtons={false} actions={} + empty={} > {isXsmall ? ( { TopToolbar: ({ children }) => (
{children}
), + Empty: () =>
No resources
, Datagrid: ({ children }) =>
{children}
, TextField: ({ source }) => , } @@ -42,9 +49,10 @@ vi.mock('react-admin', async () => { // Mock common components vi.mock('../common', async () => { return { - List: ({ children, actions, ...props }) => ( + List: ({ children, actions, empty, ...props }) => (
{actions} + {empty &&
{empty}
} {children}
), @@ -94,14 +102,16 @@ describe('PluginList', () => { expect(screen.getByTestId('datagrid')).toBeInTheDocument() }) - it('renders the rescan button', () => { + it('renders the rescan button in the toolbar', () => { render() - expect(screen.getByTestId('rescan-button')).toBeInTheDocument() + const toolbar = screen.getByTestId('top-toolbar') + expect(within(toolbar).getByTestId('rescan-button')).toBeInTheDocument() }) it('calls rescan endpoint when rescan button is clicked', async () => { render() - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -114,7 +124,8 @@ describe('PluginList', () => { it('calls refresh after successful rescan', async () => { render() - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -127,7 +138,8 @@ describe('PluginList', () => { mockHttpClient.mockRejectedValue(new Error('Network error')) render() - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -137,4 +149,25 @@ describe('PluginList', () => { }) }) }) + + it('renders a rescan button in the empty state', () => { + render() + const emptyState = screen.getByTestId('empty-state') + expect(emptyState).toBeInTheDocument() + expect(within(emptyState).getByTestId('rescan-button')).toBeInTheDocument() + }) + + it('empty state rescan button triggers rescan', async () => { + render() + const emptyState = screen.getByTestId('empty-state') + const rescanButton = within(emptyState).getByTestId('rescan-button') + + fireEvent.click(rescanButton) + + await waitFor(() => { + expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', { + method: 'POST', + }) + }) + }) }) From 97c01c0614f7ee4ef39f0368173220e2657f89ea Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 5 May 2026 18:55:59 -0400 Subject: [PATCH 20/60] fix(plugins): add debug logs to syncPlugins for troubleshooting Log skipped entries, total entries vs plugins found, and DB state to help diagnose why sync may not find new plugins. Signed-off-by: Deluan --- plugins/manager_sync.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 2e024ca37..f97069e74 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -138,11 +138,13 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { filesOnDisk := make(map[string]string) // name -> path for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) { + log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) continue } name := strings.TrimSuffix(entry.Name(), PackageExtension) filesOnDisk[name] = filepath.Join(folder, entry.Name()) } + log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) // Get all plugins from DB repo := m.ds.Plugin(adminCtx) @@ -154,6 +156,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { for i := range dbPlugins { pluginsInDB[dbPlugins[i].ID] = &dbPlugins[i] } + log.Debug(ctx, "Plugin sync: current DB state", "pluginsInDB", len(pluginsInDB)) now := time.Now() From 5f0245ea845b398bf11deef2dcdaec4ecd97ae85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 6 May 2026 00:12:50 -0400 Subject: [PATCH 21/60] fix(server): prevent artwork throttle token starvation on slow clients (#5472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): prevent artwork throttle token starvation on slow clients Replace Chi's ThrottleBacklog middleware for artwork endpoints with a custom RequestThrottle that releases processing tokens before writing the HTTP response. Previously, a slow or stalled client could hold a throttle token indefinitely during io.Copy, exhausting all 2-4 slots and blocking artwork requests for all users (reported after 15+ days uptime). The new approach buffers artwork into memory while holding the token, releases it immediately, then writes the buffered response. A 30-second per-request write deadline (SetWriteTimeout) prevents stalled writes from blocking indefinitely. Throttle exhaustion is now logged with context for operator visibility. * refactor(server): simplify throttle to middleware with same API as Chi Restructure RequestThrottle from a DI-injected type into a drop-in middleware function with the same signature as Chi's ThrottleBacklog. Handlers are reverted to their original simple form (no throttle awareness), and the middleware is applied at route definition time just like before. This eliminates the DI dependency, removes the artworkThrottle field from both Router structs, and consolidates SetWriteTimeout into the throttle file. When limit <= 0, the middleware returns a passthrough so callers don't need a guard. Signed-off-by: Deluan * feat(server): add opt-out flag for buffered artwork throttle Add DevArtworkThrottleBuffered config (default true) that controls whether the new buffered ThrottleBacklog middleware is used. When set to false, it falls back to Chi's original middleware, giving users a safety valve in case the buffered implementation causes issues. Signed-off-by: Deluan * test(server): clean up throttle tests for clarity and speed Consolidate duplicate router setup into runTwoRequests() and slowClientTest() helpers. Replace time.Sleep-based token holding with channel synchronization, reducing suite time from ~7s to ~1.5s. Remove redundant test, fix duplicate comment block, and add comment explaining why slowTestWriter can't embed httptest.ResponseRecorder. * fix: release artwork throttle tokens on panic Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic. * fix: align buffered throttle response behavior Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling. * refactor(server): remove setWriteTimeout from throttle middleware SetWriteDeadline only constrains the server's Write syscall, not how fast the client reads from the TCP buffer. For artwork-sized responses (up to ~500KB), the kernel accepts the entire write immediately even over real network interfaces due to TCP buffer auto-tuning. Verified by testing with a stalled client over both loopback and en0 — the deadline never triggers. The actual protection comes from buffering + early token release, which is already in place. --------- Signed-off-by: Deluan --- conf/configuration.go | 2 + server/public/public.go | 11 +- server/subsonic/api.go | 11 +- server/subsonic/media_retrieval_test.go | 16 +- server/throttle_backlog.go | 150 +++++++++++++ server/throttle_backlog_test.go | 266 ++++++++++++++++++++++++ 6 files changed, 426 insertions(+), 30 deletions(-) create mode 100644 server/throttle_backlog.go create mode 100644 server/throttle_backlog_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 1c4829d82..d93024c8a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -134,6 +134,7 @@ type configOptions struct { DevArtworkMaxRequests int DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration + DevArtworkThrottleBuffered bool DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -861,6 +862,7 @@ func setViperDefaults() { viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) + viper.SetDefault("devartworkthrottlebuffered", true) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/server/public/public.go b/server/public/public.go index 5e3407c19..18867e1c4 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -5,14 +5,12 @@ import ( "path" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/ui" @@ -43,13 +41,8 @@ func (pub *Router) routes() http.Handler { r.Group(func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Group(func(r chi.Router) { - if conf.Server.DevArtworkMaxRequests > 0 { - log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, - "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", - conf.Server.DevArtworkThrottleBacklogTimeout) - r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - } + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) r.HandleFunc("/img/{id}", pub.handleImages) }) if conf.Server.EnableSharing { diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 0bbfcb83e..1ca364449 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,7 +9,6 @@ import ( "regexp" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" @@ -190,14 +189,8 @@ func (api *Router) routes() http.Handler { hr(r, "getTranscodeStream", api.GetTranscodeStream) }) r.Group(func(r chi.Router) { - // configure request throttling - if conf.Server.DevArtworkMaxRequests > 0 { - log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, - "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", - conf.Server.DevArtworkThrottleBacklogTimeout) - r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - } + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) hr(r, "getCoverArt", api.GetCoverArt) }) r.Group(func(r chi.Router) { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 589a609da..27d1edb84 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -78,16 +78,13 @@ var _ = Describe("MediaRetrievalController", func() { When("client disconnects (context is cancelled)", func() { It("should not call the service if cancelled before the call", func() { - // Create a request ctx, cancel := context.WithCancel(context.Background()) r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) - cancel() // Cancel the context before the call + cancel() - // Call the GetCoverArt method _, err := router.GetCoverArt(w, r) - // Expect no error and no call to the artwork service Expect(err).ToNot(HaveOccurred()) Expect(artwork.recvId).To(Equal("")) Expect(artwork.recvSize).To(Equal(0)) @@ -96,17 +93,14 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should not return data if cancelled during the call", func() { - // Create a request with a context that will be cancelled ctx, cancel := context.WithCancel(context.Background()) - defer cancel() // Ensure the context is cancelled after the test (best practices) + defer cancel() r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) - artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service + artwork.ctxCancelFunc = cancel - // Call the GetCoverArt method _, err := router.GetCoverArt(w, r) - // Expect no error and the service to have been called Expect(err).ToNot(HaveOccurred()) Expect(artwork.recvId).To(Equal("34")) Expect(artwork.recvSize).To(Equal(128)) @@ -344,7 +338,7 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, s c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { - c.ctxCancelFunc() // Simulate context cancellation + c.ctxCancelFunc() return nil, time.Time{}, context.Canceled } return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil @@ -363,9 +357,7 @@ func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, return data, nil } - // Hardcoded support for lyrics sorting result := slices.Clone(data) - // Sort by presence of lyrics, then by updated_at. Respect the order specified in opts. slices.SortFunc(result, func(a, b model.MediaFile) int { diff := cmp.Or( cmp.Compare(a.Lyrics, b.Lyrics), diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go new file mode 100644 index 000000000..c3672fd1e --- /dev/null +++ b/server/throttle_backlog.go @@ -0,0 +1,150 @@ +package server + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" +) + +var ( + ErrThrottleCapacityExceeded = errors.New("throttle: capacity exceeded") + ErrThrottleTimeout = errors.New("throttle: backlog timeout") +) + +type requestThrottle struct { + tokens chan struct{} + backlogTokens chan struct{} + backlogTimeout time.Duration +} + +// ThrottleBacklog creates a Chi-compatible middleware that limits concurrent +// request processing. Unlike Chi's ThrottleBacklog, it buffers the handler's +// response while holding the token, releases it, then flushes the buffer to +// the client with a write deadline. This prevents slow clients from holding +// throttle capacity. +// +// Because it buffers the entire response in memory, this middleware should only +// be used for endpoints that return small responses (e.g., artwork images). Do +// not use it for audio streaming or download endpoints. +func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler { + if limit <= 0 { + return func(next http.Handler) http.Handler { return next } + } + if !conf.Server.DevArtworkThrottleBuffered { + return middleware.ThrottleBacklog(limit, backlogLimit, backlogTimeout) + } + t := &requestThrottle{ + tokens: make(chan struct{}, limit), + backlogTokens: make(chan struct{}, limit+backlogLimit), + backlogTimeout: backlogTimeout, + } + for range limit { + t.tokens <- struct{}{} + } + for range limit + backlogLimit { + t.backlogTokens <- struct{}{} + } + return t.handler +} + +func (t *requestThrottle) handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + release, err := t.acquire(ctx) + if err != nil { + switch { + case errors.Is(err, ErrThrottleCapacityExceeded): + log.Warn(ctx, "Request throttle capacity exceeded", "path", r.URL.Path) + case errors.Is(err, ErrThrottleTimeout): + log.Warn(ctx, "Request throttle backlog timeout", "path", r.URL.Path) + } + http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + return + } + + buf := &bufferedResponseWriter{header: make(http.Header)} + func() { + defer release() + next.ServeHTTP(buf, r) + }() + + for k, v := range buf.header { + w.Header()[k] = v + } + if buf.code > 0 { + w.WriteHeader(buf.code) + } + if _, err := w.Write(buf.body.Bytes()); err != nil { + log.Warn(ctx, "Error writing throttled response", err) + } + }) +} + +func (t *requestThrottle) acquire(ctx context.Context) (release func(), err error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.backlogTokens: + default: + return nil, ErrThrottleCapacityExceeded + } + + select { + case <-t.tokens: + return t.releaseFunc(), nil + default: + } + + timer := time.NewTimer(t.backlogTimeout) + select { + case <-timer.C: + t.backlogTokens <- struct{}{} + return nil, ErrThrottleTimeout + case <-ctx.Done(): + timer.Stop() + t.backlogTokens <- struct{}{} + return nil, ctx.Err() + case <-t.tokens: + timer.Stop() + return t.releaseFunc(), nil + } +} + +func (t *requestThrottle) releaseFunc() func() { + var once sync.Once + return func() { + once.Do(func() { + t.tokens <- struct{}{} + t.backlogTokens <- struct{}{} + }) + } +} + +type bufferedResponseWriter struct { + header http.Header + body bytes.Buffer + code int +} + +func (w *bufferedResponseWriter) Header() http.Header { + return w.header +} + +func (w *bufferedResponseWriter) Write(b []byte) (int, error) { + return w.body.Write(b) +} + +func (w *bufferedResponseWriter) WriteHeader(code int) { + if w.code != 0 { + return + } + w.code = code +} diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go new file mode 100644 index 000000000..eb181b4ce --- /dev/null +++ b/server/throttle_backlog_test.go @@ -0,0 +1,266 @@ +package server + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ThrottleBacklog", func() { + It("is a passthrough when limit is 0", func() { + m := ThrottleBacklog(0, 10, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("ok")) + }) + + It("returns 429 when capacity is exceeded", func() { + _, secondStatus := runTwoRequests(ThrottleBacklog(1, 0, time.Second)) + Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) + }) + + It("returns 429 when backlog times out", func() { + _, secondStatus := runTwoRequests(ThrottleBacklog(1, 1, 50*time.Millisecond)) + Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) + }) + + It("releases capacity when the handler panics", func() { + m := ThrottleBacklog(1, 0, time.Second) + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(m) + r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { + panic("boom") + }) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/panic", nil) + r.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("ok")) + }) + + It("preserves response headers and status code", func() { + m := ThrottleBacklog(2, 0, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("body")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/jpeg")) + Expect(w.Header().Get("Cache-Control")).To(Equal("public")) + Expect(w.Body.String()).To(Equal("body")) + }) + + It("uses the first response status code", func() { + m := ThrottleBacklog(2, 0, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("body")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(w.Body.String()).To(Equal("body")) + }) + + It("never exceeds the concurrency limit", func() { + const limit = 3 + const goroutines = 20 + m := ThrottleBacklog(limit, goroutines, 5*time.Second) + + var concurrent atomic.Int32 + var maxConcurrent atomic.Int32 + + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + cur := concurrent.Add(1) + for { + old := maxConcurrent.Load() + if cur <= old || maxConcurrent.CompareAndSwap(old, cur) { + break + } + } + time.Sleep(5 * time.Millisecond) + concurrent.Add(-1) + _, _ = w.Write([]byte("ok")) + }) + + var wg sync.WaitGroup + for range goroutines { + wg.Go(func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + }) + } + + wg.Wait() + Expect(maxConcurrent.Load()).To(BeNumerically("<=", limit)) + }) + + // Regression: with only 1 token, a slow client blocking during response + // writing must NOT prevent other requests from being served. Chi's original + // ThrottleBacklog holds the token for the entire handler lifecycle including + // io.Copy, causing starvation. The buffered implementation releases it first. + Context("when a client is slow to read the response", func() { + slowClientTest := func(m func(http.Handler) http.Handler) (*chi.Mux, chan struct{}, chan struct{}) { + handlerReached := make(chan struct{}, 1) + router := chi.NewRouter() + router.Use(m) + router.Get("/test", func(w http.ResponseWriter, r *http.Request) { + select { + case handlerReached <- struct{}{}: + default: + } + _, _ = io.Copy(w, strings.NewReader("image data")) + }) + + unblocked := make(chan struct{}) + slow := newSlowTestWriter(unblocked) + + reqDone := make(chan struct{}) + go func() { + defer close(reqDone) + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(slow, req) + }() + <-handlerReached + + return router, unblocked, reqDone + } + + It("does not starve concurrent requests with buffered middleware", func() { + router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) + + Eventually(func() int { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + return w.Code + }, 2*time.Second, 10*time.Millisecond).Should(Equal(http.StatusOK)) + + close(unblocked) + Eventually(reqDone, 2*time.Second).Should(BeClosed()) + }) + + It("starves concurrent requests with Chi's original middleware", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevArtworkThrottleBuffered = false + + router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + + close(unblocked) + Eventually(reqDone, 2*time.Second).Should(BeClosed()) + }) + }) +}) + +// runTwoRequests sends two concurrent requests through a throttled router. The +// first request holds the token until the second has been dispatched. +func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) { + held := make(chan struct{}) + release := make(chan struct{}) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + select { + case held <- struct{}{}: + default: + } + <-release + _, _ = w.Write([]byte("ok")) + }) + + done := make(chan int) + go func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + done <- w.Code + }() + <-held + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + secondStatus = w.Code + + close(release) + firstStatus = <-done + return firstStatus, secondStatus +} + +// slowTestWriter implements http.ResponseWriter without embedding +// httptest.ResponseRecorder. This is necessary because ResponseRecorder +// promotes io.ReaderFrom, which io.Copy prefers over Write — bypassing +// our blocking Write and defeating the slow-client simulation. +type slowTestWriter struct { + header http.Header + body bytes.Buffer + code int + unblocked chan struct{} +} + +func newSlowTestWriter(unblocked chan struct{}) *slowTestWriter { + return &slowTestWriter{header: make(http.Header), unblocked: unblocked} +} + +func (w *slowTestWriter) Header() http.Header { return w.header } + +func (w *slowTestWriter) WriteHeader(code int) { w.code = code } + +func (w *slowTestWriter) Write(p []byte) (int, error) { + <-w.unblocked + return w.body.Write(p) +} From 82f101a7b2ae4519c2256dcd9f27473bc61544e8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 6 May 2026 00:16:09 -0400 Subject: [PATCH 22/60] ci: fix input parameter name for GitHub token Signed-off-by: Deluan --- .github/actions/prepare-docker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 7fe0b9148..b8cde4aaf 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -73,7 +73,7 @@ runs: id: meta uses: docker/metadata-action@v6 with: - token: ${{ inputs.github_token }} + github-token: ${{ inputs.github_token }} labels: | maintainer=deluan@navidrome.org images: | From 59c1454ffb1ead0f9105c55f79e914a11b616412 Mon Sep 17 00:00:00 2001 From: IEEE-754 <253034919+IEEE-754@users.noreply.github.com> Date: Wed, 6 May 2026 20:49:13 +0800 Subject: [PATCH 23/60] fix(ui): update zh-Hant.json (#5470) --- resources/i18n/zh-Hant.json | 56 ++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 93951a311..92b4af3d0 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -180,7 +180,7 @@ "name": "名稱", "transcodingId": "轉碼", "maxBitRate": "最大位元率", - "client": "客戶端", + "client": "用戶端", "userName": "使用者名稱", "lastSeen": "上次上線", "reportRealPath": "回報實際路徑", @@ -333,7 +333,7 @@ } }, "plugin": { - "name": "插件 |||| 插件", + "name": "外掛 |||| 外掛", "fields": { "id": "ID", "name": "名稱", @@ -359,7 +359,7 @@ }, "sections": { "status": "狀態", - "info": "插件資訊", + "info": "外掛資訊", "configuration": "設定", "manifest": "資訊清單", "usersPermission": "使用者權限", @@ -379,29 +379,29 @@ "rescan": "重新掃描" }, "notifications": { - "enabled": "插件已啟用", - "disabled": "插件已停用", - "updated": "插件已更新", - "error": "更新插件時發生錯誤" + "enabled": "外掛已啟用", + "disabled": "外掛已停用", + "updated": "外掛已更新", + "error": "更新外掛時發生錯誤" }, "validation": { "invalidJson": "設定必須是有效的 JSON" }, "messages": { - "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", + "configHelp": "使用鍵值對設定外掛。若外掛無需設定則留空。", "clickPermissions": "點擊權限以查看詳細資訊", "noConfig": "無設定", - "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", + "allUsersHelp": "啟用後,外掛將可存取所有使用者,包含未來建立的使用者。", "noUsers": "未選擇使用者", "permissionReason": "原因", - "usersRequired": "此插件需要存取使用者資訊。請選擇插件可存取的使用者,或啟用「允許所有使用者」。", - "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", + "usersRequired": "此外掛需要存取使用者資訊。請選擇外掛可存取的使用者,或啟用「允許所有使用者」。", + "allLibrariesHelp": "啟用後,外掛將可存取所有媒體庫,包含未來建立的媒體庫。", "noLibraries": "未選擇媒體庫", - "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", + "librariesRequired": "此外掛需要存取媒體庫資訊。請選擇外掛可存取的媒體庫,或啟用「允許所有媒體庫」。", "requiredHosts": "必要的 Hosts", "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", - "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。" + "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", + "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -452,7 +452,7 @@ "delete": "刪除", "edit": "編輯", "export": "匯出", - "list": "列表", + "list": "清單", "refresh": "重新整理", "remove_filter": "清除此條件", "remove": "移除", @@ -497,9 +497,9 @@ "upload_single": "拖曳單個圖片上傳或點擊選擇一個" }, "references": { - "all_missing": "未找到參考數據", - "many_missing": "至少有一條參考數據不再可用", - "single_missing": "關聯的參考數據不再可用" + "all_missing": "未找到參考資料", + "many_missing": "至少有一條參考資料不再可用", + "single_missing": "關聯的參考資料不再可用" }, "password": { "toggle_visible": "隱藏密碼", @@ -514,7 +514,7 @@ "delete_content": "您確定要刪除該項目?", "delete_title": "刪除 %{name} #%{id}", "details": "詳細資訊", - "error": "發生客戶端錯誤,您的請求無法完成", + "error": "發生用戶端錯誤,您的請求無法完成", "invalid_form": "提交內容無效,請檢查錯誤", "loading": "正在載入頁面,請稍候", "no": "否", @@ -564,19 +564,19 @@ "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?", "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知", "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", - "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄", - "lastfmLinkFailure": "無法連接 Last.fm", - "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄", - "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接", + "lastfmLinkSuccess": "已成功連結 Last.fm 並開啟音樂記錄", + "lastfmLinkFailure": "無法連結 Last.fm", + "lastfmUnlinkSuccess": "已取消與 Last.fm 的連結並停用音樂記錄", + "lastfmUnlinkFailure": "無法取消與 Last.fm 的連結", "openIn": { "lastfm": "在 Last.fm 中開啟", "musicbrainz": "在 MusicBrainz 中開啟" }, "lastfmLink": "查看更多…", - "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", - "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", + "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連結 ListenBrainz 並開啟音樂記錄", + "listenBrainzLinkFailure": "無法連結 ListenBrainz:%{error}", + "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連結並停用音樂記錄", + "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連結", "downloadOriginalFormat": "下載原始格式", "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} \ No newline at end of file +} From b18dfb474aa52dc3f155d1ad17508a3d8592e4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 6 May 2026 10:03:24 -0400 Subject: [PATCH 24/60] fix(transcoding): don't apply server-side override on getTranscodeDecision (#5473) * fix(transcoding): don't apply server-side transcoding override on getTranscodeDecision The getTranscodeDecision endpoint was incorrectly applying server-side player transcoding overrides (forced format and MaxBitRate cap), which replaced the client's declared capabilities with synthetic profiles. This caused the endpoint to ignore what the client can actually play and return decisions for formats the client never requested (e.g. AAC when the client only supports FLAC/opus/mp3). The override is now gated behind an ApplyServerOverride flag in TranscodeOptions, which is only set by the legacy stream endpoint where this behavior is expected. Signed-off-by: Deluan * refactor: move server-side transcoding override to ResolveRequest Moved the server-side player transcoding override logic (forced format and MaxBitRate cap) from MakeDecision into ResolveRequest, where the legacy stream context is handled. This makes MakeDecision a pure function that only operates on the ClientInfo it receives, removing the ApplyServerOverride flag and all context-sniffing from the decision engine. Tests moved accordingly to legacy_client_test.go. * test(e2e): update transcode decision tests for server override removal Updated e2e tests to reflect that getTranscodeDecision no longer applies server-side player overrides (MaxBitRate cap and forced transcoding profile). The player MaxBitRate tests now verify the endpoint ignores the player cap and relies solely on client-declared capabilities. * test(e2e): assert opus default bitrate when player cap is ignored Added bitrate assertion to verify the player MaxBitRate cap is truly ignored: the target bitrate should be the opus format default (128kbps), not the player cap (320kbps). --------- Signed-off-by: Deluan --- core/stream/decider.go | 12 --- core/stream/decider_test.go | 135 +------------------------- core/stream/legacy_client.go | 16 ++- core/stream/legacy_client_test.go | 104 ++++++++++++++++++++ server/e2e/subsonic_transcode_test.go | 95 ++++-------------- 5 files changed, 141 insertions(+), 221 deletions(-) diff --git a/core/stream/decider.go b/core/stream/decider.go index cde12f0f3..d6e48497c 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -59,18 +59,6 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, decision.SourceStream = buildSourceStream(mf, probe) src := &decision.SourceStream - // Check for server-side player transcoding override - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { - if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { - modified := *clientInfo - modified.MaxAudioBitrate = player.MaxBitRate - clientInfo = &modified - log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) - } - } - log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 8b58f3323..f74953258 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1042,8 +1042,8 @@ var _ = Describe("Decider", func() { }) }) - Context("Server-side player transcoding override", func() { - It("forces transcoding when override targets a different format", func() { + Context("Server-side context is ignored by MakeDecision", func() { + It("ignores transcoding override in context", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) ci := &ClientInfo{ Name: "TestClient", @@ -1051,148 +1051,21 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } - // Set server override in context overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("allows direct play when source matches forced format and bitrate is within cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - Expect(decision.CanTranscode).To(BeFalse()) - }) - - It("transcodes when source bitrate exceeds the forced cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("uses player MaxBitRate over transcoding DefaultBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(320)) - }) - - It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) - Expect(decision.TargetBitrate).To(Equal(160)) - }) - - It("does not apply override when no transcoding is in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - // No override in context — client profiles used as-is - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) - }) - - Context("Player MaxBitRate cap", func() { - It("applies player MaxBitRate cap when client has no limit", func() { + It("ignores player MaxBitRate in context", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ Name: "TestClient", DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) - - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - // Source bitrate 1000 > player cap 320, so direct play is not possible - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - // Lossless→lossy should use MaxAudioBitrate (320) as target, not format default - Expect(decision.TargetBitrate).To(Equal(320)) - }) - - It("uses client limit when it is more restrictive than player MaxBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) - ci := &ClientInfo{ - Name: "TestClient", - MaxAudioBitrate: 256, - MaxTranscodingAudioBitrate: 256, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) - - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - // Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins - Expect(decision.TargetBitrate).To(Equal(256)) - }) - - It("does not cap when player MaxBitRate is 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - } - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index d6e929ec8..9dd6179a0 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -7,12 +7,11 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) // buildLegacyClientInfo translates legacy Subsonic stream/download parameters // into a ClientInfo for use with MakeDecision. -// It does NOT read request.TranscodingFrom(ctx) — that is handled by -// MakeDecision's applyServerOverride. func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { ci := &ClientInfo{Name: "legacy"} @@ -65,6 +64,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + + // Apply server-side player transcoding override before making the decision + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { + if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { + modified := *clientInfo + modified.MaxAudioBitrate = player.MaxBitRate + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + } + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) if err != nil { log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index de1eb1339..ce7b38650 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -187,6 +188,109 @@ var _ = Describe("ResolveRequest", func() { Expect(req.Offset).To(Equal(30)) }) + Context("Server-side player transcoding override", func() { + It("forces transcoding when override targets a different format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) + + It("allows direct play when source matches forced format and bitrate is within cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("transcodes when source bitrate exceeds the forced cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) + + It("uses player MaxBitRate over transcoding DefaultBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(320)) + }) + + It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) + Expect(req.BitRate).To(Equal(160)) + }) + + It("does not apply override when no transcoding is in context", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + }) + + Context("Player MaxBitRate cap", func() { + It("applies player MaxBitRate cap when client has no limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(320)) + }) + + It("uses client limit when it is more restrictive than player MaxBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(256)) + }) + + It("does not cap when player MaxBitRate is 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + }) + Context("fallback for unknown format", func() { It("falls back to DefaultDownsamplingFormat", func() { DeferCleanup(configtest.SetupConfig()) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index 6041cd013..ae3d6208c 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -396,68 +396,30 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate cap", func() { - It("forces transcode when source bitrate exceeds player MaxBitRate", func() { + Describe("player MaxBitRate cap is ignored", func() { + It("allows direct play even when source bitrate exceeds player MaxBitRate", func() { setPlayerMaxBitRate(320) // 320 kbps cap - // FLAC is 900kbps, client has no bitrate limit but player cap is 320 + // FLAC is 900kbps, player cap is 320, but getTranscodeDecision + // ignores server-side overrides — client profiles are used as-is resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) - // Target bitrate should be capped at player's 320kbps = 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("does not affect direct play when source bitrate is under player MaxBitRate", func() { - setPlayerMaxBitRate(500) // 500 kbps cap - - // MP3 is 320kbps, under the 500kbps player cap → direct play - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) }) - It("uses client limit when more restrictive than player MaxBitRate", func() { - setPlayerMaxBitRate(500) // 500 kbps player cap - - // Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500 - // FLAC is 900kbps → exceeds both limits → transcode - resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Client limit (320kbps) is more restrictive → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("uses player MaxBitRate when more restrictive than client limit", func() { + It("uses only client limit, not player MaxBitRate", func() { setPlayerMaxBitRate(192) // 192 kbps player cap // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 - // FLAC is 900kbps → transcode at 192kbps + // but getTranscodeDecision ignores player cap → client limit (320kbps) applies resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Player limit (192kbps) is more restrictive → 192000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) - }) - - It("has no effect when player MaxBitRate is 0", func() { - setPlayerMaxBitRate(0) // No player cap - - // FLAC with flac+mp3 client → direct play (no bitrate constraint) - resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + // Only client limit (320kbps) applies → 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) }) @@ -513,56 +475,37 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate + client limits combined", func() { - It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() { + Describe("player MaxBitRate is ignored by getTranscodeDecision", func() { + It("does not inject maxAudioBitrate from player cap", func() { setPlayerMaxBitRate(320) // opusTranscodeClient has no client bitrate limits - // Player cap injects maxAudioBitrate=320 - // FLAC (900kbps) → exceeds 320 → transcode to opus - // Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320 + // Player cap is 320, but getTranscodeDecision ignores it + // FLAC (900kbps) → can't direct play → transcode to opus using format default resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // maxAudioBitrate=320 used as fallback → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + // Bitrate should be opus format default (128kbps), not player cap (320kbps) + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) }) - It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() { + It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() { setPlayerMaxBitRate(320) - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate - // Player cap injects maxAudioBitrate=320 - // FLAC (900kbps) → exceeds 320 → transcode to mp3 - // Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps) + // Player cap is 320, but getTranscodeDecision ignores it + // Only client maxTranscodingAudioBitrate=192 applies resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxTranscodingAudioBitrate=192 is preferred → 192000 bps + // maxTranscodingAudioBitrate=192 → 192000 bps Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) - - It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() { - setPlayerMaxBitRate(128) - - // Get decision: FLAC (900kbps) with player cap 128 → transcode - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Stream using the token - w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) }) }) From 569de4cd23b491e2719eba57fc25d61d0be9e305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 6 May 2026 11:03:11 -0400 Subject: [PATCH 25/60] fix(test): prevent flaky deadlock in throttle backlog test (#5474) The `held` channel in `runTwoRequests` was unbuffered, creating a race condition with the `select/default` send in the handler. Under CI load (slow runner, -race, -shuffle=on), the handler goroutine could reach the select before the test goroutine blocked on `<-held`, causing the send to silently fall through to `default` and deadlocking both goroutines permanently. Buffer the channel (capacity 1) so the send always succeeds regardless of goroutine scheduling order. --- server/throttle_backlog_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go index eb181b4ce..4d8567db5 100644 --- a/server/throttle_backlog_test.go +++ b/server/throttle_backlog_test.go @@ -209,7 +209,7 @@ var _ = Describe("ThrottleBacklog", func() { // runTwoRequests sends two concurrent requests through a throttled router. The // first request holds the token until the second has been dispatched. func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) { - held := make(chan struct{}) + held := make(chan struct{}, 1) release := make(chan struct{}) r := chi.NewRouter() r.Use(m) From f12e75aa11079f094d8874e0d91f6e37126a33e2 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 10 May 2026 18:34:45 -0300 Subject: [PATCH 26/60] feat(subsonic): add groupings field to OpenSubsonic Child response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the ID3 grouping tag in OpenSubsonic responses as an array of strings, per opensubsonic/open-subsonic-api#232. The grouping tag was already being extracted and stored in MediaFile.Tags via mappings.yaml aliases (GRP1, GROUPING, ©grp, wm/contentgroupdescription), so this change only adds the field to the response struct and populates it in both song and album child builders. Signed-off-by: Deluan --- server/subsonic/helpers.go | 2 ++ .../Responses AlbumList with OS data should match .JSON | 5 ++++- .../Responses AlbumList with OS data should match .XML | 1 + ...ponses AlbumWithSongsID3 with data should match .JSON | 9 +++++++-- ...sponses AlbumWithSongsID3 with data should match .XML | 2 ++ .../Responses Child with data should match .JSON | 9 +++++++-- .../Responses Child with data should match .XML | 2 ++ ...es Child without data should match OpenSubsonic .JSON | 3 ++- server/subsonic/responses/responses.go | 1 + server/subsonic/responses/responses_test.go | 3 +++ 10 files changed, 31 insertions(+), 6 deletions(-) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 74d57ade4..8a9eb7110 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -266,6 +266,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op child.BitDepth = int32(mf.BitDepth) child.Genres = toItemGenres(mf.Genres) child.Moods = mf.Tags.Values(model.TagMood) + child.Groupings = mf.Tags.Values(model.TagGrouping) child.DisplayArtist = mf.Artist child.Artists = artistRefs(mf.Participants[model.RoleArtist]) child.DisplayAlbumArtist = mf.AlbumArtist @@ -375,6 +376,7 @@ func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubson child.MusicBrainzId = al.MbzAlbumID child.Genres = toItemGenres(al.Genres) child.Moods = al.Tags.Values(model.TagMood) + child.Groupings = al.Tags.Values(model.TagGrouping) child.DisplayArtist = al.AlbumArtist child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist]) child.DisplayAlbumArtist = al.AlbumArtist diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON index 8491a577b..9d9ae2195 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -56,7 +56,10 @@ "displayAlbumArtist": "Display album artist", "contributors": [], "displayComposer": "", - "explicitStatus": "explicit" + "explicitStatus": "explicit", + "groupings": [ + "Soundtrack" + ] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML index 5d9e83f96..d39fe2e7d 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML @@ -9,6 +9,7 @@ + Soundtrack diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 07678407a..a07914344 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -165,7 +165,11 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ] }, { "id": "2", @@ -210,7 +214,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index f7b23cb4e..6c96391bc 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -32,6 +32,8 @@ + Soundtrack + Live diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index d20a6d48c..4c0ea6c68 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -110,7 +110,11 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ] }, { "id": "", @@ -141,7 +145,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ], "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 1d307b0b9..ddceb67d4 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -24,6 +24,8 @@ + Soundtrack + Live diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON index 25284295e..9a9ab1ff6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -28,7 +28,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ], "id": "", diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b0b2b8752..4165669df 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -189,6 +189,7 @@ type OpenSubsonicChild struct { Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"` DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"` ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` + Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"` } type Songs struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ee98a3daa..0b72fab19 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -224,6 +224,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1", "ISRC-2"}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, + Groupings: []string{"Soundtrack", "Live"}, ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ @@ -320,6 +321,7 @@ var _ = Describe("Responses", func() { Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, + Groupings: []string{"Soundtrack", "Live"}, ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", @@ -424,6 +426,7 @@ var _ = Describe("Responses", func() { ItemGenre{Name: "Genre 2"}, }, Moods: []string{"mood1", "mood2"}, + Groupings: []string{"Soundtrack"}, DisplayArtist: "Display artist", Artists: Array[ArtistID3Ref]{ ArtistID3Ref{Id: "artist-1", Name: "Artist 1"}, From e55a35544bcf14f2f494edfdd39cccc28bef9e93 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 11 May 2026 20:33:14 -0300 Subject: [PATCH 27/60] chore(deps): update TagLib to 2.3 Signed-off-by: Deluan --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 36218ba6b..7fe9ba59d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a require ( github.com/Masterminds/squirrel v1.5.4 @@ -53,7 +53,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.11.0 + github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 diff --git a/go.sum b/go.sum index 90e0ec040..29eec8620 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,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.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw= -github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4= 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= @@ -279,8 +279,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= -github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= From 2b3b879c57d3560bdaecf4559869b60254981e16 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 11 May 2026 20:34:04 -0300 Subject: [PATCH 28/60] chore(deps): update dependencies to latest versions Signed-off-by: Deluan --- go.mod | 26 +++++++++++++------------- go.sum | 52 ++++++++++++++++++++++++++-------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index 7fe9ba59d..6a0acf2d6 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.1.0 + github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 @@ -58,12 +58,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.39.0 - golang.org/x/net v0.53.0 + golang.org/x/image v0.40.0 + golang.org/x/net v0.54.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.0 + golang.org/x/sys v0.44.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -81,7 +81,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect github.com/ebitengine/purego v0.10.0 // indirect - github.com/fsnotify/fsnotify v1.10.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -89,11 +89,11 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -133,10 +133,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index 29eec8620..0c550e47d 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= -github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -106,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -167,8 +167,8 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= -github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= @@ -316,17 +316,17 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -338,8 +338,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -364,11 +364,11 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= +golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -377,8 +377,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -389,8 +389,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -400,8 +400,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 24e526e09ac5a2d3254dbbafa80bb70203a6b21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 13 May 2026 17:17:20 -0300 Subject: [PATCH 29/60] fix(transcoding): place -ss before -i for fast input seeking (#5492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ffmpeg -ss (seek/offset) parameter before -i in all transcoding commands so ffmpeg uses input seeking instead of output seeking. Per the ffmpeg docs, placing -ss before -i seeks at the demuxer level by keyframe (very fast), and since FFmpeg 2.1 it is also frame-accurate when transcoding. The previous placement after -i caused ffmpeg to decode and discard all audio up to the seek point, which was unnecessarily slow — especially problematic for lengthy files (4+ hours). Both code paths are updated: buildDynamicArgs (for default formats) and createFFmpegCommand (for custom templates without %t). A database migration updates existing default commands in the transcoding table. --- consts/consts.go | 8 +-- core/ffmpeg/ffmpeg.go | 19 +++++-- core/ffmpeg/ffmpeg_test.go | 19 ++++--- .../20260513173954_move_ss_before_input.go | 55 +++++++++++++++++++ tests/mock_transcoding_repo.go | 4 +- 5 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 db/migrations/20260513173954_move_ss_before_input.go diff --git a/consts/consts.go b/consts/consts.go index bf32006d6..edd8f2b54 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -153,25 +153,25 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 80790c8d6..3225ff150 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -394,12 +395,13 @@ func isDefaultCommand(format, command string) bool { // including all transcoding parameters (bitrate, sample rate, channels). func buildDynamicArgs(opts TranscodeOptions) []string { cmdPath, _ := ffmpegCmd() - args := []string{cmdPath, "-i", opts.FilePath} + args := []string{cmdPath} if opts.Offset > 0 { args = append(args, "-ss", strconv.Itoa(opts.Offset)) } + args = append(args, "-i", opts.FilePath) args = append(args, "-map", "0:a:0") if codec, ok := formatCodecMap[opts.Format]; ok { @@ -491,11 +493,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string for _, s := range fixCmd(cmd) { if strings.Contains(s, "%s") { + if offset > 0 && !strings.Contains(cmd, "%t") { + // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) + // instead of decoding all frames up to the offset (slow). + insertAt := len(args) + for i := len(args) - 1; i >= 0; i-- { + if args[i] == "-i" { + insertAt = i + break + } + } + args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset)) + } s = strings.ReplaceAll(s, "%s", path) args = append(args, s) - if offset > 0 && !strings.Contains(cmd, "%t") { - args = append(args, "-ss", strconv.Itoa(offset)) - } } else { s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset)) s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 1649015d9..562fd9100 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -47,15 +47,15 @@ var _ = Describe("ffmpeg", func() { }) Context("when command has time offset param", func() { It("creates a valid command line with offset", func() { - args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"})) + args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) Context("when command does not have time offset param", func() { - It("adds time offset after the input file name", func() { + It("adds time offset before the input file name", func() { args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"})) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) }) @@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() { Describe("isDefaultCommand", func() { It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) }) It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) }) It("returns false for a custom command", func() { Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) @@ -165,8 +165,9 @@ var _ = Describe("ffmpeg", func() { Offset: 30, }) Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.mp3", + "ffmpeg", "-ss", "30", + "-i", "/music/file.mp3", "-map", "0:a:0", "-c:a", "libmp3lame", "-b:a", "192k", diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go new file mode 100644 index 000000000..c16583aa0 --- /dev/null +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -0,0 +1,55 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput) +} + +// ssSeekPairs maps old commands (output seeking) to new commands (input seeking). +// Index 0 = old (after -i), index 1 = new (before -i). +var ssSeekPairs = [][2]string{ + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + }, +} + +func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 796e84111..641daca8a 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -19,9 +19,9 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil case "flac": - return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil case "aac": - return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil + return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil default: return nil, model.ErrNotFound } From 8f0b4930ff8835cd5a730e984a9a33a9e9463fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 13 May 2026 17:44:22 -0300 Subject: [PATCH 30/60] refactor(conf): replace eager dir creation with lazy Dir type (#5495) * feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan --- cmd/backup.go | 4 +- cmd/svc.go | 8 +- conf/configtest/configtest.go | 6 +- conf/configuration.go | 76 +++++++-------- conf/configuration_test.go | 17 +--- conf/dir.go | 76 +++++++++++++++ conf/dir_test.go | 127 ++++++++++++++++++++++++++ core/artwork/benchmark_e2e_test.go | 2 +- core/artwork/e2e/suite_test.go | 2 +- core/artwork/reader_artist_test.go | 2 +- core/artwork/reader_radio_test.go | 2 +- core/image_upload_test.go | 2 +- core/metrics/insights.go | 12 +-- core/playlists/playlists_test.go | 4 +- core/stream/media_streamer_test.go | 5 +- db/backup.go | 8 +- db/backup_test.go | 4 +- db/db.go | 2 + go.mod | 2 +- model/artist_test.go | 2 +- model/image.go | 2 +- model/radio_test.go | 2 +- persistence/artist_repository_test.go | 2 +- plugins/host_artwork_test.go | 2 +- plugins/host_cache_test.go | 2 +- plugins/host_config_test.go | 2 +- plugins/host_kvstore.go | 2 +- plugins/host_kvstore_test.go | 6 +- plugins/host_library_test.go | 4 +- plugins/host_scheduler_test.go | 2 +- plugins/host_subsonicapi_test.go | 2 +- plugins/host_taskqueue.go | 2 +- plugins/host_taskqueue_test.go | 8 +- plugins/host_users_test.go | 2 +- plugins/host_websocket_test.go | 2 +- plugins/manager.go | 14 +-- plugins/manager_watcher.go | 4 +- plugins/plugins_suite_test.go | 4 +- resources/embed.go | 2 +- scanner/external.go | 4 +- server/nativeapi/config.go | 2 +- utils/cache/benchmark_test.go | 2 +- utils/cache/file_caches.go | 2 +- utils/cache/file_caches_test.go | 4 +- 44 files changed, 317 insertions(+), 126 deletions(-) create mode 100644 conf/dir.go create mode 100644 conf/dir_test.go diff --git a/cmd/backup.go b/cmd/backup.go index ab73f7537..c02f3a19f 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -75,7 +75,7 @@ var ( func runBackup(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } idx := strings.LastIndex(conf.Server.DbPath, "?") @@ -104,7 +104,7 @@ func runBackup(ctx context.Context) { func runPrune(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } if backupCount != -1 { diff --git a/cmd/svc.go b/cmd/svc.go index 89ca08056..cc8d6bb54 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service { options["Restart"] = "on-failure" options["SuccessExitStatus"] = "1 2 8 SIGKILL" options["UserService"] = false - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() options["SystemdScript"] = systemdScript if conf.Server.LogFile != "" { options["LogOutput"] = false } else { options["LogOutput"] = true - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() } svcConfig := &service.Config{ UserName: installUser, @@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command { println("Installing service with:") println(" working directory: " + executablePath()) println(" music folder: " + conf.Server.MusicFolder) - println(" data folder: " + conf.Server.DataFolder) + println(" data folder: " + conf.Server.DataFolder.String()) if conf.Server.LogFile != "" { println(" log file: " + conf.Server.LogFile) } else { - println(" logs folder: " + conf.Server.DataFolder) + println(" logs folder: " + conf.Server.DataFolder.String()) } if cfgFile != "" { conf.Server.ConfigFile, err = filepath.Abs(cfgFile) diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go index b947e6263..cd0ac41ed 100644 --- a/conf/configtest/configtest.go +++ b/conf/configtest/configtest.go @@ -2,9 +2,7 @@ package configtest import "github.com/navidrome/navidrome/conf" +// TODO Remove this redirection and call SnapshotConfig directly from tests func SetupConfig() func() { - oldValues := *conf.Server - return func() { - conf.Server = &oldValues - } + return conf.SnapshotConfig() } diff --git a/conf/configuration.go b/conf/configuration.go index d93024c8a..6fff1641a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -2,6 +2,7 @@ package conf import ( "cmp" + "encoding/json" "fmt" "net/url" "os" @@ -14,6 +15,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/dustin/go-humanize" "github.com/go-viper/encoding/ini" + "github.com/go-viper/mapstructure/v2" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -29,8 +31,8 @@ type configOptions struct { UnixSocketPerm string EnforceNonRootUser bool MusicFolder string - DataFolder string - CacheFolder string + DataFolder Dir + CacheFolder Dir DbPath string LogLevel string LogFile string @@ -229,7 +231,7 @@ type jukeboxOptions struct { type backupOptions struct { Count int - Path string + Path Dir Schedule string } @@ -247,7 +249,7 @@ type inspectOptions struct { type pluginsOptions struct { Enabled bool - Folder string + Folder Dir CacheSize string AutoReload bool LogLevel string @@ -287,6 +289,22 @@ var ( hooks []func() ) +// SnapshotConfig returns a function that restores Server to its current state. +// Uses JSON round-tripping so Dir fields get fresh sync.Once values. +func SnapshotConfig() func() { + snapshot, err := json.Marshal(Server) + if err != nil { + panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err)) + } + return func() { + var restored configOptions + if err := json.Unmarshal(snapshot, &restored); err != nil { + panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err)) + } + Server = &restored + } +} + func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() @@ -307,7 +325,13 @@ func Load(noConfigDump bool) { mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - err := viper.Unmarshal(&Server) + err := viper.Unmarshal(&Server, viper.DecodeHook( + mapstructure.ComposeDecodeHookFunc( + mapstructure.TextUnmarshallerHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + )) if err != nil { logFatal("Error parsing config:", err) } @@ -317,48 +341,28 @@ func Load(noConfigDump bool) { logFatal(err) } - err = os.MkdirAll(Server.DataFolder, os.ModePerm) - if err != nil { - logFatal("Error creating data path:", err) - } - - if Server.CacheFolder == "" { - Server.CacheFolder = filepath.Join(Server.DataFolder, "cache") - } - err = os.MkdirAll(Server.CacheFolder, os.ModePerm) - if err != nil { - logFatal("Error creating cache path:", err) - } - - err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) - if err != nil { - logFatal("Error creating artwork path:", err) + if Server.CacheFolder.String() == "" { + Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache")) } if Server.Plugins.Enabled { - if Server.Plugins.Folder == "" { - Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") - } - err = os.MkdirAll(Server.Plugins.Folder, 0700) - if err != nil { - logFatal("Error creating plugins path:", err) + if Server.Plugins.Folder.String() == "" { + Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700) + } else { + Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700) } } Server.ConfigFile = viper.GetViper().ConfigFileUsed() if Server.DbPath == "" { - Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) - } - - if Server.Backup.Path != "" { - err = os.MkdirAll(Server.Backup.Path, os.ModePerm) - if err != nil { - logFatal("Error creating backup path:", err) - } + Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath) } out := os.Stderr if Server.LogFile != "" { + if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { + logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) + } out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) @@ -636,7 +640,7 @@ func validateScanSchedule() error { } func validateBackupSchedule() error { - if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { + if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { Server.Backup.Schedule = "" return nil } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 5d4e73fad..9c25a0d19 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -186,27 +186,12 @@ var _ = Describe("Configuration", func() { }).To(PanicWith(ContainSubstring("Error reading config file"))) }) - It("is called when DataFolder is not writable", func() { - viper.SetDefault("datafolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating data path"))) - }) - - It("is called when CacheFolder is not writable", func() { - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("cachefolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating cache path"))) - }) - It("is called when LogFile path is not writable", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt")) Expect(func() { conf.Load(true) - }).To(PanicWith(ContainSubstring("Error opening log file"))) + }).To(PanicWith(ContainSubstring("Error creating log file directory"))) }) It("is called when BaseURL is invalid", func() { diff --git a/conf/dir.go b/conf/dir.go new file mode 100644 index 000000000..8ed43039b --- /dev/null +++ b/conf/dir.go @@ -0,0 +1,76 @@ +package conf + +import ( + "fmt" + "os" + "sync" +) + +// Dir wraps a directory path and lazily creates the directory on first use. +// The directory is created at most once; if creation fails, the error is +// permanently cached (sync.Once semantics). Dir is not safe for mutation +// after Path() has been called. +type Dir struct { + path string + perm os.FileMode + once sync.Once + err error +} + +// NewDir creates a new Dir with the given path and default permissions (os.ModePerm). +func NewDir(path string) Dir { + return Dir{path: path, perm: os.ModePerm} +} + +// NewDirWithPerm creates a new Dir with the given path and permissions. +func NewDirWithPerm(path string, perm os.FileMode) Dir { + return Dir{path: path, perm: perm} +} + +// String returns the raw path without creating the directory. Satisfies fmt.Stringer. +func (d *Dir) String() string { + return d.path +} + +// Path creates the directory on first call (via sync.Once) and returns the path. +func (d *Dir) Path() (string, error) { + d.once.Do(func() { + if d.path == "" { + return + } + d.err = os.MkdirAll(d.path, d.perm) + if d.err != nil { + d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err) + } + }) + return d.path, d.err +} + +// MustPath calls Path() and calls logFatal on error. +func (d *Dir) MustPath() string { + path, err := d.Path() + if err != nil { + logFatal("creating directory:", err) + } + return path +} + +// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) +// prints the path string instead of the internal struct fields. +func (d Dir) GoString() string { //nolint:govet + return fmt.Sprintf("%q", d.path) +} + +// MarshalText returns the raw path bytes. No side effects. +func (d *Dir) MarshalText() ([]byte, error) { + return []byte(d.path), nil +} + +// UnmarshalText sets the path from bytes. No side effects. +func (d *Dir) UnmarshalText(text []byte) error { + d.path = string(text) + if d.perm == 0 { + d.perm = os.ModePerm + } + return nil +} diff --git a/conf/dir_test.go b/conf/dir_test.go new file mode 100644 index 000000000..2dd4250bc --- /dev/null +++ b/conf/dir_test.go @@ -0,0 +1,127 @@ +package conf_test + +import ( + "os" + + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Dir", func() { + Describe("NewDir", func() { + It("creates a Dir with the given path without side effects", func() { + d := conf.NewDir("/some/path") + Expect(d.String()).To(Equal("/some/path")) + }) + }) + + Describe("String", func() { + It("returns the raw path without creating the directory", func() { + d := conf.NewDir("/nonexistent/path/that/should/not/be/created") + Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created")) + }) + }) + + Describe("Path", func() { + It("creates the directory and returns the path on first call", func() { + dir := GinkgoT().TempDir() + target := dir + "/subdir/nested" + d := conf.NewDir(target) + + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("returns the same result on subsequent calls (sync.Once)", func() { + dir := GinkgoT().TempDir() + target := dir + "/once" + d := conf.NewDir(target) + + path1, err1 := d.Path() + path2, err2 := d.Path() + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(path1).To(Equal(path2)) + }) + + It("returns an error when directory cannot be created", func() { + f := GinkgoT().TempDir() + blocker := f + "/blocker" + By("creating a file that blocks directory creation") + Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed()) + invalid := blocker + "/subdir" + + d := conf.NewDir(invalid) + _, pathErr := d.Path() + Expect(pathErr).To(HaveOccurred()) + }) + + It("returns empty path and no error for empty path", func() { + d := conf.NewDir("") + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(BeEmpty()) + }) + }) + + Describe("MustPath", func() { + It("returns the path when directory is created successfully", func() { + dir := GinkgoT().TempDir() + target := dir + "/mustpath" + d := conf.NewDir(target) + + path := d.MustPath() + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("calls logFatal on error", func() { + var fatalMsg []any + restore := conf.SetLogFatal(func(args ...any) { + fatalMsg = args + panic("logFatal called") + }) + DeferCleanup(restore) + + f := GinkgoT().TempDir() + "/blocker" + Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed()) + invalid := f + "/subdir" + + d := conf.NewDir(invalid) + Expect(func() { d.MustPath() }).To(Panic()) + Expect(fatalMsg).ToNot(BeEmpty()) + }) + }) + + Describe("MarshalText", func() { + It("returns the raw path bytes without side effects", func() { + d := conf.NewDir("/marshal/path") + b, err := d.MarshalText() + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(Equal("/marshal/path")) + }) + }) + + Describe("UnmarshalText", func() { + It("sets the path from bytes without side effects", func() { + d := conf.NewDir("") + err := d.UnmarshalText([]byte("/unmarshal/path")) + Expect(err).ToNot(HaveOccurred()) + Expect(d.String()).To(Equal("/unmarshal/path")) + }) + + It("allows round-trip marshal/unmarshal", func() { + d1 := conf.NewDir("/round/trip") + b, err := d1.MarshalText() + Expect(err).ToNot(HaveOccurred()) + + var d2 conf.Dir + err = d2.UnmarshalText(b) + Expect(err).ToNot(HaveOccurred()) + Expect(d2.String()).To(Equal(d1.String())) + }) + }) +}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index c27964018..393cbb473 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID // Configure cache conf.Server.ImageCacheSize = cacheSize - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) conf.Server.CoverArtQuality = 75 conf.Server.CoverArtPriority = "cover.*" diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 9ce0edb8b..733e2e98c 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -63,7 +63,7 @@ func setupHarness() { // Reuse the suite-level DB path so the singleton connection keeps working // across specs (see suiteDBTempDir comment). conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) conf.Server.MusicFolder = fakeLibPath conf.Server.DevExternalScanner = false conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index e2a1f2094..50ca3a2ce 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) // Create the artwork/artist directory Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go index 1f5bc9084..37ce1d827 100644 --- a/core/artwork/reader_radio_test.go +++ b/core/artwork/reader_radio_test.go @@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) diff --git a/core/image_upload_test.go b/core/image_upload_test.go index d13a04775..265f60a95 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) svc = core.NewImageUploadService() }) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f069d3fb6..bcd0343c2 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.OS.Containerized = consts.InContainer // Install info - packageFilename := filepath.Join(conf.Server.DataFolder, ".package") + packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package") packageFileData, err := os.ReadFile(packageFilename) if err == nil { data.OS.Package = string(packageFileData) @@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data { // FS info data.FS.Music = getFSInfo(conf.Server.MusicFolder) - data.FS.Data = getFSInfo(conf.Server.DataFolder) - if conf.Server.CacheFolder != "" { - data.FS.Cache = getFSInfo(conf.Server.CacheFolder) + data.FS.Data = getFSInfo(conf.Server.DataFolder.String()) + if conf.Server.CacheFolder.String() != "" { + data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String()) } - if conf.Server.Backup.Path != "" { - data.FS.Backup = getFSInfo(conf.Server.Backup.Path) + if conf.Server.Backup.Path.String() != "" { + data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String()) } // Config info diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 52d5c88d8..95d7b3e6a 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -307,7 +307,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, @@ -371,7 +371,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create a real image file on disk imgDir := filepath.Join(tmpDir, "artwork", "playlist") diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 1bc21e239..1bbf868fa 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -23,7 +23,8 @@ var _ = Describe("MediaStreamer", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches") + cacheDir, _ := os.MkdirTemp("", "file_caches") + conf.Server.CacheFolder = conf.NewDir(cacheDir) conf.Server.TranscodingCacheSize = "100MB" ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ @@ -34,7 +35,7 @@ var _ = Describe("MediaStreamer", func() { streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { - _ = os.RemoveAll(conf.Server.CacheFolder) + _ = os.RemoveAll(conf.Server.CacheFolder.String()) }) Context("NewStream", func() { diff --git a/db/backup.go b/db/backup.go index a34255d7e..806bef8e2 100644 --- a/db/backup.go +++ b/db/backup.go @@ -27,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05" func backupPath(t time.Time) string { return filepath.Join( - conf.Server.Backup.Path, + conf.Server.Backup.Path.MustPath(), fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), ) } @@ -117,7 +117,11 @@ func Restore(ctx context.Context, path string) error { } func Prune(ctx context.Context) (int, error) { - files, err := os.ReadDir(conf.Server.Backup.Path) + backupDir, err := conf.Server.Backup.Path.Path() + if err != nil { + return 0, fmt.Errorf("backup directory not available: %w", err) + } + files, err := os.ReadDir(backupDir) if err != nil { return 0, fmt.Errorf("unable to read database backup entries: %w", err) } diff --git a/db/backup_test.go b/db/backup_test.go index aec43446d..5e8f877e6 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -60,7 +60,7 @@ var _ = Describe("database backups", func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) @@ -118,7 +118,7 @@ var _ = Describe("database backups", func() { BeforeEach(func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) diff --git a/db/db.go b/db/db.go index 0945d1a00..168c12122 100644 --- a/db/db.go +++ b/db/db.go @@ -38,6 +38,8 @@ func Db() *sql.DB { if Path == ":memory:" { Path = "file::memory:?cache=shared&_foreign_keys=on" conf.Server.DbPath = Path + } else { + conf.Server.DataFolder.MustPath() } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) diff --git a/go.mod b/go.mod index 6a0acf2d6..937cffbd5 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 @@ -84,7 +85,6 @@ require ( github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect diff --git a/model/artist_test.go b/model/artist_test.go index 5a24504eb..db897d3d5 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -14,7 +14,7 @@ var _ = Describe("Artist", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/model/image.go b/model/image.go index 68d8ae64c..30307fcea 100644 --- a/model/image.go +++ b/model/image.go @@ -13,5 +13,5 @@ func UploadedImagePath(entityType, filename string) string { if filename == "" { return "" } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) + return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename) } diff --git a/model/radio_test.go b/model/radio_test.go index dc421454e..860331f17 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -26,7 +26,7 @@ var _ = Describe("Radio", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index e2904466c..076a9da3b 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -840,7 +840,7 @@ var _ = Describe("ArtistRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) ctx := request.WithUser(GinkgoT().Context(), adminUser) repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 151a0d03c..ed8a0e810 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -47,7 +47,7 @@ var _ = Describe("ArtworkService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Initialize auth (required for token generation) diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 0f55bcfda..cf3973fc4 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -343,7 +343,7 @@ var _ = Describe("CacheService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index bd3368a67..b296d29fb 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -57,7 +57,7 @@ func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, t // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index c3f6ec734..2224b7485 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -54,7 +54,7 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm } // Create plugin data directory - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) } diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index e5d467f79..109ae8131 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -34,7 +34,7 @@ var _ = Describe("KVStoreService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create service with 1KB limit for testing maxSize := "1KB" @@ -705,9 +705,9 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 5746a3bed..67f5f9b0f 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -263,7 +263,7 @@ var _ = Describe("LibraryService", Ordered, func() { // the service registration and configuration without full plugin execution DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) // Create mock &tests.MockLibraryRepo{} mockLibRepo := &tests.MockLibraryRepo{} @@ -357,7 +357,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin and library diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index 334d9b738..ca53aed56 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -51,7 +51,7 @@ var _ = Describe("SchedulerService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Create mock scheduler and timer registry diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 607f3a64b..6f7ff4dd3 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -44,7 +44,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock router and data store diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index 9f2ed85f6..eff73c822 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -82,7 +82,7 @@ type taskQueueServiceImpl struct { // newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) } diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index c3ab8d119..8a58f1eb4 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -40,7 +40,7 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create a mock manager with context managerCtx, cancel := context.WithCancel(ctx) @@ -853,10 +853,10 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") - conf.Server.DataFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(filepath.Join(tmpDir, "cache")) + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 1c0de7d03..42f6a3032 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -484,7 +484,7 @@ func createTestUsers(mockUserRepo *tests.MockedUserRepo) { // setupTestUsersConfig sets up common plugin configuration func setupTestUsersConfig(tmpDir string) { conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 83fca9898..e41cfbb82 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -51,7 +51,7 @@ var _ = Describe("WebSocketService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/manager.go b/plugins/manager.go index 0e9419bfd..67e0ee987 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "path/filepath" "runtime" "sync" @@ -124,7 +123,7 @@ func (m *Manager) Start(ctx context.Context) error { m.ctx, m.cancel = context.WithCancel(ctx) // Initialize wazero compilation cache for better performance - cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins") + cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) var err error @@ -134,17 +133,12 @@ func (m *Manager) Start(ctx context.Context) error { return fmt.Errorf("creating wazero compilation cache: %w", err) } - folder := conf.Server.Plugins.Folder - if folder == "" { + if conf.Server.Plugins.Folder.String() == "" { log.Debug(ctx, "No plugins folder configured") return nil } - // Create plugins folder if it doesn't exist - if err := os.MkdirAll(folder, 0755); err != nil { - log.Error(ctx, "Failed to create plugins folder", "folder", folder, err) - return fmt.Errorf("creating plugins folder: %w", err) - } + folder := conf.Server.Plugins.Folder.MustPath() log.Info(ctx, "Starting plugin manager", "folder", folder) @@ -431,7 +425,7 @@ func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON s // This synchronizes the database with the filesystem, discovering new plugins, // updating changed ones, and removing deleted ones. func (m *Manager) RescanPlugins(ctx context.Context) error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return fmt.Errorf("plugins folder not configured") } diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index 4f266bda1..b7022b46e 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -19,7 +19,7 @@ const debounceDuration = 2 * time.Second // startWatcher starts the file watcher for the plugins folder. // It watches for CREATE, WRITE, and REMOVE events on .wasm files. func (m *Manager) startWatcher() error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return nil } @@ -146,7 +146,7 @@ func (m *Manager) processPluginEvent(pluginName string) { delete(m.debounceTimers, pluginName) m.debounceMu.Unlock() - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() ndpPath := filepath.Join(folder, pluginName+PackageExtension) action := determinePluginAction(ndpPath) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 1799ba3ce..bb081988e 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -48,7 +48,7 @@ func TestPlugins(t *testing.T) { // Set CacheFolder globally so all tests (including those using // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = sharedCacheDir + conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) @@ -126,7 +126,7 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugins diff --git a/resources/embed.go b/resources/embed.go index 0386e6f79..040bb5d84 100644 --- a/resources/embed.go +++ b/resources/embed.go @@ -16,6 +16,6 @@ var embedFS embed.FS func FS() fs.FS { return merge.FS{ Base: embedFS, - Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")), + Overlay: os.DirFS(path.Join(conf.Server.DataFolder.String(), "resources")), } } diff --git a/scanner/external.go b/scanner/external.go index 29ca90be6..393a9278c 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -45,8 +45,8 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod "scan", "--nobanner", "--subprocess", "--configfile", conf.Server.ConfigFile, - "--datafolder", conf.Server.DataFolder, - "--cachefolder", conf.Server.CacheFolder, + "--datafolder", conf.Server.DataFolder.String(), + "--cachefolder", conf.Server.CacheFolder.String(), } // Add targets if provided diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index 02626a4ee..cfecfa663 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -97,7 +97,7 @@ func getConfig(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Marshal the actual configuration struct to preserve original field names - configBytes, err := json.Marshal(*conf.Server) + configBytes, err := json.Marshal(conf.Server) if err != nil { log.Error(ctx, "Error marshaling config", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index 1fe448f84..e3fc08eda 100644 --- a/utils/cache/benchmark_test.go +++ b/utils/cache/benchmark_test.go @@ -28,7 +28,7 @@ func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileC b.Fatal(err) } b.Cleanup(configtest.SetupConfig()) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 5edc533f8..9788926d5 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -262,7 +262,7 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) h := fscache.NewLRUHaunterStrategy(lru) - cacheFolder = filepath.Join(conf.Server.CacheFolder, cacheFolder) + cacheFolder = filepath.Join(conf.Server.CacheFolder.MustPath(), cacheFolder) var fs *spreadFS log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size)) diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 72f4463d1..9a9a9444f 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -28,14 +28,14 @@ var _ = Describe("File Caches", func() { configtest.SetupConfig() _ = os.RemoveAll(tmpDir) }) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) }) Describe("NewFileCache", func() { It("creates the cache folder", func() { Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil()) - _, err := os.Stat(filepath.Join(conf.Server.CacheFolder, "test")) + _, err := os.Stat(filepath.Join(conf.Server.CacheFolder.String(), "test")) Expect(os.IsNotExist(err)).To(BeFalse()) }) From 725f6ab34b1588880624af9ea64eb00bed75bcdc Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 18 May 2026 09:31:57 -0300 Subject: [PATCH 31/60] feat(i18n): add Estonian translation file Signed-off-by: Deluan --- resources/i18n/et.json | 723 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 723 insertions(+) create mode 100644 resources/i18n/et.json diff --git a/resources/i18n/et.json b/resources/i18n/et.json new file mode 100644 index 000000000..d511c1246 --- /dev/null +++ b/resources/i18n/et.json @@ -0,0 +1,723 @@ +{ + "languageName": "eesti keel", + "resources": { + "song": { + "name": "Laul |||| Laulud", + "fields": { + "albumArtist": "Albumi esitaja", + "duration": "Kestus", + "trackNumber": "Nr", + "playCount": "Esituskordi", + "title": "Pealkiri", + "artist": "Esitaja", + "album": "Album", + "path": "Faili asukoht", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "size": "Faili suurus", + "updatedAt": "Uuendatud", + "bitRate": "Bitikiirus", + "discSubtitle": "Plaadi alapealkiri", + "starred": "Märgi lemmikuks", + "comment": "Kommentaar", + "rating": "Hinnang", + "quality": "Kvaliteet", + "bpm": "BPM", + "playDate": "Viimati esitatud", + "channels": "Kanaleid", + "createdAt": "Lisamise kuupäev", + "grouping": "Rühmitamine", + "mood": "Meeleolu", + "participants": "Täiendavad osalejad", + "tags": "Täiendavad sildid", + "mappedTags": "Tuvastatud sildid", + "rawTags": "Sildid töötlemata vaates", + "bitDepth": "Bitisügavus", + "sampleRate": "Diskreetmisagedus", + "missing": "Puudub", + "libraryName": "Kogumik", + "composer": "Helilooja", + "disc": "%{discNumber}. plaat", + "albumGain": "Albumikohane esitusvaljuse tundlikkus", + "trackGain": "Rajakohane esitusvaljuse tundlikkus" + }, + "actions": { + "addToQueue": "Esita hiljem", + "playNow": "Esita kohe", + "addToPlaylist": "Lisa esitusloendisse", + "shuffleAll": "Sega kõik", + "download": "Laadi alla", + "playNext": "Esita järgmisena", + "info": "Loo teave", + "showInPlaylist": "Näita esitusloendis", + "instantMix": "Kohene miks" + } + }, + "album": { + "name": "Album |||| Albumid", + "fields": { + "albumArtist": "Albumi esitaja", + "artist": "Esitaja", + "duration": "Kestus", + "songCount": "laulu", + "playCount": "Esituskordi", + "name": "Nimi", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "updatedAt": "Uuendatud", + "comment": "Kommentaar", + "rating": "Hinnangud", + "createdAt": "Lisamise kuupäev", + "size": "Suurus", + "originalDate": "Originaal", + "releaseDate": "Avaldatud", + "releases": "Väljalase ||| Väljalasked", + "released": "Avaldatud", + "recordLabel": "Plaadifirma", + "catalogNum": "Tunnus kataloogides", + "releaseType": "Tüüp", + "grouping": "Grupeerimine", + "media": "Meedium", + "mood": "Meeleolu", + "date": "Salvestuskuupäev", + "missing": "Puudu", + "libraryName": "Kogumik" + }, + "actions": { + "playAll": "Esita", + "playNext": "Esita järgmisena", + "addToQueue": "Esita hiljem", + "shuffle": "Sega lood", + "addToPlaylist": "Lisa esitusloendisse", + "download": "Laadi alla", + "info": "Albumi teave", + "share": "Jaga" + }, + "lists": { + "all": "Kõik", + "random": "Juhuslik", + "recentlyAdded": "Hiljuti lisatud", + "recentlyPlayed": "Hiljuti esitatud", + "mostPlayed": "Enimesitatud", + "starred": "Lemmikud", + "topRated": "Kõrgeima hinnanguga" + } + }, + "artist": { + "name": "Esitaja |||| Esitajad", + "fields": { + "name": "Nimi", + "albumCount": "Albumeid", + "songCount": "Lugusid", + "playCount": "Esituskordi", + "rating": "Hinnang", + "genre": "Žanr", + "size": "Suurus", + "role": "Roll", + "missing": "Puudub" + }, + "roles": { + "albumartist": "Albumi esitaja ||| Albumi esitajad", + "artist": "Esitaja ||| Esitajad", + "composer": "Helilooja ||| Heliloojad", + "conductor": "Dirigent ||| Dirigendid", + "lyricist": "Laulusõnade autor ||| Laulusõnade autorid", + "arranger": "Seade autor ||| Seade autorid", + "producer": "Produtsent ||| Produtsendid", + "director": "Lavastaja ||| Lavastajad", + "engineer": "Helirežissöör ||| Helirežissöörid", + "mixer": "Miksija ||| Miksijad", + "remixer": "Remiksija ||| Remiksijad", + "djmixer": "DJ-versiooni remiksija ||| DJ-versiooni remiksijad", + "performer": "Esineja ||| Esinejad", + "maincredit": "Albumi esitaja või Esitaja ||| Albumi esitajad või Esitajad" + }, + "actions": { + "shuffle": "Sega", + "radio": "Raadio", + "topSongs": "Populaarsed lood" + } + }, + "user": { + "name": "Kasutaja |||| Kasutajad", + "fields": { + "userName": "Kasutajanimi", + "isAdmin": "On peakasutaja", + "lastLoginAt": "Viimane sisselogimine", + "updatedAt": "Uuendatud", + "name": "Nimi", + "password": "Salasõna", + "createdAt": "Loodud", + "changePassword": "Kas soovid salasõna muuta?", + "currentPassword": "Senine salasõna", + "newPassword": "Uus salasõna", + "token": "Tunnusluba", + "lastAccessAt": "Viimasti avatud", + "libraries": "Kogumikud" + }, + "helperTexts": { + "name": "Sinu nime muudatused on näha järgmisel sisselogimisel", + "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikus või jäta vaikimisi väärtuse kasutamiseks tühjaks" + }, + "notifications": { + "created": "Kasutaja on lisatud", + "updated": "Kasutaja andmed on uuendatud", + "deleted": "Kasutaja on kustutatud" + }, + "message": { + "listenBrainzToken": "Sisesta oma ListenBrainzi tunnusluba.", + "clickHereForToken": "Tunnusloa saamiseks klõpsi siin", + "selectAllLibraries": "Vali kõik kogumikud", + "adminAutoLibraries": "Peakasutajatel on automaatselt ligipääs kõikidele kogumikele" + }, + "validation": { + "librariesRequired": "Vähemalt üks kogumik peab olema valitud muude, kui peakasutajate jaoks" + } + }, + "player": { + "name": "Meediaesitaja |||| Meediaesitajad", + "fields": { + "name": "Nimi", + "transcodingId": "Teisendamine", + "maxBitRate": "Maksimaalne bitikiirus", + "client": "Klient", + "userName": "Kasutajanimi", + "lastSeen": "Viimati nähtud", + "reportRealPath": "Teata tegelikust asukohast", + "scrobbleEnabled": "Saada kraasimisandmed välistesse teenustesse" + } + }, + "transcoding": { + "name": "Teisendamine |||| Teisendamised", + "fields": { + "name": "Nimi", + "targetFormat": "Sihtvorming", + "defaultBitRate": "Vaikimisi bitikiirus", + "command": "Käsk" + } + }, + "playlist": { + "name": "Esitusloend ||| Esitusloendid", + "fields": { + "name": "Nimi", + "duration": "Kestus", + "ownerName": "Omanik", + "public": "Avalik", + "updatedAt": "Muudetud", + "createdAt": "Loodud", + "songCount": "Lood", + "comment": "Kommentaar", + "sync": "Automaatne import", + "path": "Impordi siit" + }, + "actions": { + "selectPlaylist": "Valo esitusloend:", + "addNewPlaylist": "Loo „%{name}\"“", + "export": "Ekspordi", + "makePublic": "Muuda avalikuks", + "makePrivate": "Muuda privaatseks", + "saveQueue": "Salvesta esitusjärjekord esitusloendina", + "searchOrCreate": "Otsi esitusloendeid või uue loomiseks sisesta nimi...", + "pressEnterToCreate": "Uue esitusloendi lisamiseks vajuta sisestusklahvi", + "removeFromSelection": "Eemalda valikust" + }, + "message": { + "duplicate_song": "Lisa topeltlood", + "song_exist": "Tundub, et oled esitusloendisse lisamas topeltkirjeid. Kas tahad nii jätkata või soovid topeltkirjed vahele jätta?", + "noPlaylistsFound": "Esitusloendeid ei leidu", + "noPlaylists": "Esitusloendeid pole saadaval" + } + }, + "radio": { + "name": "Raadio ||| Raadiod", + "fields": { + "name": "Nimi", + "streamUrl": "Voogedastuse võrguaadress", + "homePageUrl": "Avalehe võrguaadress", + "updatedAt": "Uuendatud", + "createdAt": "Lisatud" + }, + "actions": { + "playNow": "Esita kohe" + } + }, + "share": { + "name": "Jagamine ||| Jagamised", + "fields": { + "username": "Seda jagas", + "url": "Võrguaadress", + "description": "Kirjeldus", + "contents": "Sisu", + "expiresAt": "Aegub", + "lastVisitedAt": "Viimati vaadatud", + "visitCount": "Külastusi", + "format": "Vorming", + "maxBitRate": "Maksimaalne bitikiirus", + "updatedAt": "Muudetud", + "createdAt": "Lisatud", + "downloadable": "Kas lubad allalaadimised?" + } + }, + "missing": { + "name": "Puuduv fail ||| Puuduvad failid", + "fields": { + "path": "Asukoht", + "size": "Suurus", + "updatedAt": "Kadumise aeg", + "libraryName": "Kogumik" + }, + "actions": { + "remove": "Eemalda", + "remove_all": "Eemalda kõik" + }, + "notifications": { + "removed": "Puuduv(ad) fail(id) on eemaldatud" + }, + "empty": "Puuduvaid faile pole" + }, + "library": { + "name": "Kogumik ||| Kogumikud", + "fields": { + "name": "Nimi", + "path": "Asukoht", + "remotePath": "Asukoht kaugseadmes", + "lastScanAt": "Viimane skaneerimine", + "songCount": "Lood", + "albumCount": "Albumid", + "artistCount": "Esitajad", + "totalSongs": "Lood", + "totalAlbums": "Albumid", + "totalArtists": "Esitajad", + "totalFolders": "Kaustad", + "totalFiles": "Failid", + "totalMissingFiles": "Puuduvad failid", + "totalSize": "Kogumaht", + "totalDuration": "Kestus", + "defaultNewUsers": "Vaikimisi väärtus uutele kasutajatele", + "createdAt": "Lisatud", + "updatedAt": "Muudetud" + }, + "sections": { + "basic": "Põhiteave", + "statistics": "Statistika" + }, + "actions": { + "scan": "Skaneeri kogumikku", + "manageUsers": "Halda kasutajate õigusi", + "viewDetails": "Vaata üksikasju", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täismahuline skaneerimine" + }, + "notifications": { + "created": "Kogumiku loomine õnnestus", + "updated": "Kogumiku uuendamine õnnestus", + "deleted": "Kogumiku kustutamine õnnestus", + "scanStarted": "Kogumiku skaneerimine algas", + "scanCompleted": "Kogumiku skaneerimine lõppes", + "quickScanStarted": "Kiirskaneerimine algas", + "fullScanStarted": "Täismahuline skaneerimine algas", + "scanError": "Viga skaneerimise käivitamisel. Lisateavet leiad logidest" + }, + "validation": { + "nameRequired": "Pead sisestama kogumiku nime", + "pathRequired": "Pead sisestama kogumiku asukoha", + "pathNotDirectory": "Kogumiku asukoht peab olema kaust", + "pathNotFound": "Kogumiku asukoha kausta ei leidu", + "pathNotAccessible": "Kogumiku asukoha kaust pole ligipääsetav", + "pathInvalid": "Vigane kogumiku asukoha kaust" + }, + "messages": { + "deleteConfirm": "Kas oled kindel, et soovid selle kogumiku kustutada? Samaga eemaldad ka kõik seotud andmed ja kasutajate ligipääsu.", + "scanInProgress": "Skaneerimine on pooleli...", + "noLibrariesAssigned": "Selle kasutajaga pole veel ühtegi kogumikku seotud" + } + }, + "plugin": { + "name": "Lisamoodul |||| Lisamoodulid", + "fields": { + "id": "Tunnus", + "name": "Nimi", + "description": "Kirjeldus", + "version": "Versioon", + "author": "Autor", + "website": "Veebisait", + "permissions": "Õigused", + "enabled": "Kasutusel", + "status": "Olek", + "path": "Asukoht", + "lastError": "Viga", + "hasError": "Viga", + "updatedAt": "Uuendatud", + "createdAt": "Paigaldatud", + "configKey": "Võti", + "configValue": "Väärtus", + "allUsers": "Luba kõiki kasutajaid", + "selectedUsers": "Valitud kasutajad", + "allLibraries": "Luba kõik kogumikud", + "selectedLibraries": "Valitud kogumikud", + "allowWriteAccess": "Luba kirjutusõigused" + }, + "sections": { + "status": "Olek", + "info": "Lisamooduli teave", + "configuration": "Seadistus", + "manifest": "Manifest", + "usersPermission": "Kasutajate õigused", + "libraryPermission": "Kogumike õigused" + }, + "status": { + "enabled": "Kasutusel", + "disabled": "Pole kasutusel" + }, + "actions": { + "enable": "Võta kasutusele", + "disable": "Eemalda kasutuselt", + "disabledDueToError": "Enne kasutuselevõtmist paranda viga", + "disabledUsersRequired": "Enne kasutuselevõtmist vali kasutajad", + "disabledLibrariesRequired": "Enne kasutuselevõtmist vali kogumikud", + "addConfig": "Lisa seadistus", + "rescan": "Skaneeri uuesti" + }, + "notifications": { + "enabled": "Lisamoodul on kasutusel", + "disabled": "Lisamoodul pole kasutusel", + "updated": "Lisamoodul on uuendatud", + "error": "Viga lisamooduli uuendamisel" + }, + "validation": { + "invalidJson": "Seadistus peab olema koostatud korrektses JSON-vormingus" + }, + "messages": { + "configHelp": "Seadista lisamoodulit võti-väärtus paaride abil. Kui lisamoodul seadistamist ei vaja, siis jäta tühjaks.", + "clickPermissions": "Üksikasjade vaatamiseks klõpsa õigust", + "noConfig": "Ühtegi seadistust pole määratud", + "allUsersHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kasutajatele, sealhulgas tulevikus loodavatele.", + "noUsers": "Ühtegi kasutajat pole valitud", + "permissionReason": "Põhjus", + "usersRequired": "See lisamoodul vajab ligipääsu kasutajate teabele. Vali kasutajad, millele ta ligi peaks saama või vali „Kõik kasutajad“.", + "allLibrariesHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kogumikele, sealhulgas tulevikus loodavatele.", + "noLibraries": "Ühtegi kogumikku pole valitud", + "librariesRequired": "See lisamoodul vajab ligipääsu kogumiku teabele. Vali kogumikud, millele ta ligi peaks saama või vali „Kõik kogumikud“.", + "requiredHosts": "Nõutavad hostid", + "configValidationError": "Seadistuse õigsuse kontrollimine ei õnnestunud:", + "schemaRenderError": "Seadistuste vormi lugemine ja töötlemine ei õnnestunud. Lisamooduli ülesehitus/skeem võib olla vigane.", + "allowWriteAccessHelp": "Kui valik on kasutusel, siis lisamoodul võib muuta vaid faile kogumike kaustades. Vaikimisi on lisamoodulitel vaid lugemisõigus." + }, + "placeholders": { + "configKey": "võti", + "configValue": "väärtus" + } + } + }, + "ra": { + "auth": { + "welcome1": "Aitäh, et paigaldasite Navidrome'i!", + "welcome2": "Alustamiseks lisa peakasutaja", + "confirmPassword": "Korda salasõna", + "buttonCreateAdmin": "Loo admin", + "auth_check_error": "Jätkamiseks palun logi sisse", + "user_menu": "Profiil", + "username": "Kasutajanimi", + "password": "Salasõna", + "sign_in": "Logi sisse", + "sign_in_error": "Tuvastamine ei toiminud, palun proovi uuesti", + "logout": "Logi välja", + "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda" + }, + "validation": { + "invalidChars": "Palun kasutage ainult tähti ja numbreid", + "passwordDoesNotMatch": "Salasõnad ei kattu", + "required": "Nõutav", + "minLength": "Pikkus peab olema vähemalt %{min} tähemärki", + "maxLength": "Pikkus ei tohi olla üle %{max} tähemärgi", + "minValue": "Väärtus peab olema vähemalt %{min}", + "maxValue": "Väärtus ei tohi olla enam, kui %{max}", + "number": "Sisend peab olema number", + "email": "Sisend peab korrektne e-posti aadress", + "oneOf": "Väärtus peab olema üks järgnevaist: %{options}", + "regex": "Väärtus peab vastama kindlale vormingule (regulaaravaldis): %{pattern}", + "unique": "Sisend peab olema unikaalne", + "url": "Sisend peab olema korrektne võrguaadress" + }, + "action": { + "add_filter": "Lisa filter", + "add": "Lisa", + "back": "Mine tagasi", + "bulk_actions": "1 objekt on valitud |||| %{smart_count} objekti on valitud", + "cancel": "Katkesta", + "clear_input_value": "Eemalda väärtus", + "clone": "Klooni", + "confirm": "Kinnita", + "create": "Loo", + "delete": "Kustuta", + "edit": "Muuda", + "export": "Ekspordi", + "list": "Loend", + "refresh": "Uuenda andmed", + "remove_filter": "Eemalda see filter", + "remove": "Eemalda", + "save": "Salvesta", + "search": "Otsi", + "show": "Näita", + "sort": "Järjesta", + "undo": "Võta tegevus tagasi", + "expand": "Laienda", + "close": "Sulge", + "open_menu": "Ava menüü", + "close_menu": "Sulge menüü", + "unselect": "Eemalda valik", + "skip": "Jäta vahele", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Jaga", + "download": "Laadi alla" + }, + "boolean": { + "true": "Jah", + "false": "Ei" + }, + "page": { + "create": "Loo %{name}", + "dashboard": "Töölaud", + "edit": "%{name} #%{id}", + "error": "Midagi läks valesti", + "list": "%{name}", + "loading": "Laadin", + "not_found": "Ei leidu", + "show": "%{name} #%{id}", + "empty": "Nimi on veel puudu - %{name}.", + "invite": "Kas sa sooviksid ühe sellise lisada?" + }, + "input": { + "file": { + "upload_several": "Lohista üleslaadimiseks mõned failid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks fail või vali ta failivalijast." + }, + "image": { + "upload_several": "Lohista üleslaadimiseks mõned pildid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks pilt või vali ta failivalijast." + }, + "references": { + "all_missing": "Viitenumbrite andmeid ei leidu.", + "many_missing": "Vähemalt üks seotud viide ei tundu enam olema saadaval.", + "single_missing": "Seotud viide ei tundu enam olema saadaval." + }, + "password": { + "toggle_visible": "Peida salasõna", + "toggle_hidden": "Näita salasõna" + } + }, + "message": { + "about": "Teave", + "are_you_sure": "Kas oled kindel?", + "bulk_delete_content": "Kas sa oled kindel, et soovid kustutada selle objekti - %{name}? |||| Kas sa oled kindel, et soovid kustutada need %{smart_count} objekti?", + "bulk_delete_title": "Kustuta %{name} |||| Kustuta %{name} - %{smart_count} kirjet", + "delete_content": "Kas oled kindel, et soovid selle objekti kustutada?", + "delete_title": "Kustuta %{name} #%{id}", + "details": "Üksikasjad", + "error": "Tekkis klientrakenduse viga ja päringut polnud võimalik lõpetada.", + "invalid_form": "Vormi andmed pole õiged. Palun kontrolli sisestusi", + "loading": "Leht on just laadimisel, palun oota hetke", + "no": "Ei", + "not_found": "Sa kas sisestasid vigase võrguaadressi või klõpsisid vigast linki.", + "yes": "Jah", + "unsaved_changes": "Mõned sinu muudatused pole salvestatud. Kas sa soovid neist loobuda?" + }, + "navigation": { + "no_results": "Tulemusi ei leidu", + "no_more_results": "Lehe number %{page} on väljaspool etteantud piire. Proovi eelmist lehte.", + "page_out_of_boundaries": "Lehe number %{page} on väljaspool etteantud piire", + "page_out_from_end": "Viimasest lehest ei saa edasi minna", + "page_out_from_begin": "Esimese lehe ette ei saa minna", + "page_range_info": "%{offsetBegin}-%{offsetEnd} - kokku %{total}", + "page_rows_per_page": "Kirjeid lehel:", + "next": "Edasi", + "prev": "Tagasi", + "skip_nav": "Mine sisu juurde" + }, + "notification": { + "updated": "Objekt on uuendatud |||| %{smart_count} objekti on uuendatud", + "created": "Objekt on loodud", + "deleted": "Objekt on kustutatud |||| %{smart_count} objekti on kustutatud", + "bad_item": "Vigane objekt", + "item_doesnt_exist": "Objekti pole olemas", + "http_error": "Viga suhtlemisel serveriga", + "data_provider_error": "Andmeteenusepakkuja viga. Lisateavet leiad brauseri konsoolist.", + "i18n_error": "Vastava keele tõlget ei saa laadida", + "canceled": "Tegevus on tühistatud", + "logged_out": "Sinu sessioon on lõppenud, palun ühenda uuesti.", + "new_version": "Uus versioon on saadaval! Palun laadi see vaade uuesti." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Kuvatavad veerud", + "layout": "Paigutus", + "grid": "Ruudustik", + "table": "Tabel" + } + }, + "message": { + "note": "MÄRGE", + "transcodingDisabled": "Transkodeeringu seadistuse muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovite muuta või lisada transkodeerimisega seotud seadistusi, taaskäivitage server %{config} valikuga.", + "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese transkodeerimisseadistuste jooksutada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult transkodeerimisseadete muutmiseks.", + "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse", + "noPlaylistsAvailable": "Pole saadaval", + "delete_user_title": "Kustuta kasutaja „%{name}“", + "delete_user_content": "Kas oled kindel, et soovid selle kasutaja ja kõik tema andmed (sh esitusloendid ja eelistused) kustutada?", + "notifications_blocked": "Sa oled selle saidi teavitused veebibrauseri seadistusest keelanud", + "notifications_not_available": "See veebibrauser kas ei toeta töölauateavitusi või sa ei kasuta Navidrome'i üle https-protokolli", + "lastfmLinkSuccess": "Last.fm-i seos on lisatud ja kraasimine on lülitatud sisse", + "lastfmLinkFailure": "Last.fm-i seose lisamine ei õnnestunud", + "lastfmUnlinkSuccess": "Last.fm-i seos on eemaldatud ja kraasimine on lülitatud välja", + "lastfmUnlinkFailure": "Last.fm-i seose eemaldamine ei õnnestunud", + "openIn": { + "lastfm": "Ava Last.fm-is", + "musicbrainz": "Ava MusicBrainzis" + }, + "lastfmLink": "Lisateave...", + "listenBrainzLinkSuccess": "ListenBrainzi seos on lisatud ja kraasimine on lülitatud sisse kasutajana: %{user}", + "listenBrainzLinkFailure": "ListenBrainzi seose lisamine ei õnnestunud: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainzi seos on eemaldatud ja kraasimine on lülitatud välja", + "listenBrainzUnlinkFailure": "ListenBrainzi seose eemaldamine ei õnnestunud", + "downloadOriginalFormat": "Laadi alla algses vormingus", + "shareOriginalFormat": "Jaga algses vormingus", + "shareDialogTitle": "Jaga - %{resource} „%{name}“", + "shareBatchDialogTitle": "Jaga - %{resource} |||| Jaga %{smart_count} kirjet - %{resource}", + "shareSuccess": "Võrguaadress on kopeeritud lõikelauale: %{url}", + "shareFailure": "Viga %{url} võrguaadressi kopeerimisel lõikelauale", + "downloadDialogTitle": "Laadi alla - %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopeeri lõikelauale: Ctrl+C, sisestusklahv", + "remove_missing_title": "Eemalda puuduvad failid", + "remove_missing_content": "Kas sa oled kindel, et soovid valitud puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "remove_all_missing_title": "Eemalda kõik puuduvad failid", + "remove_all_missing_content": "Kas sa oled kindel, et soovid kõik puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "noSimilarSongsFound": "Sarnaseid lugusid ei leidu", + "noTopSongsFound": "Populaarsemaid lugusid ei leidu", + "startingInstantMix": "Laadin kohest miksi...", + "uploadCover": "Laadi kaanepilt üles", + "removeCover": "Eemalda kaanepilt", + "coverUploaded": "Kaanepilt on uuendatud", + "coverRemoved": "Kaanepilt on eemaldatud", + "coverUploadError": "Viga kaanepildi üleslaadimisel", + "coverRemoveError": "Viga kaanepildi eemaldamisel" + }, + "menu": { + "library": "Kogumik", + "settings": "Seaded", + "version": "Versioon", + "theme": "Teema", + "personal": { + "name": "Isiklik", + "options": { + "theme": "Teema", + "language": "Keel", + "defaultView": "Vaikimisi vaade", + "desktop_notifications": "Teavitused töölaual", + "lastfmScrobbling": "Kraasi Last.fm-i teenusesse", + "listenBrainzScrobbling": "Kraasi ListenBrainzi teenusesse", + "replaygain": "Esitusvaljuse tundlikkuse režiim", + "preAmp": "Esitusvaljuse tundlikkuse eelvõimendus (dB)", + "gain": { + "none": "Pole kasutusel", + "album": "Kasuta albumikohast esitusvaljuse tundlikkust", + "track": "Kasuta lookohast esitusvaljuse tundlikkust" + }, + "lastfmNotConfigured": "Last.fm-i API-võti pole seadistatud" + } + }, + "albumList": "Albumid", + "about": "Rakenduse teave", + "playlists": "Esitusloendid", + "sharedPlaylists": "Jagatud esitusloendid", + "librarySelector": { + "allLibraries": "Kõik kogumikud (%{count})", + "multipleLibraries": "%{selected} / %{total} kogumikest", + "selectLibraries": "Vali kogumikud", + "none": "Puudub" + } + }, + "player": { + "playListsText": "Esitusjärjekord", + "openText": "Ava", + "closeText": "Sulge", + "notContentText": "Muusikat pole", + "clickToPlayText": "Klõpsa esitamiseks", + "clickToPauseText": "Klõpsa peatamiseks", + "nextTrackText": "Järgmine lugu", + "previousTrackText": "Eelmine lugu", + "reloadText": "Laadi uuesti", + "volumeText": "Helivaljus", + "toggleLyricText": "Näita/peida laulusõnad", + "toggleMiniModeText": "Minimeeri", + "destroyText": "Hävita", + "downloadText": "Laadi alla", + "removeAudioListsText": "Kustuta heliloendid", + "clickToDeleteText": "„%{name}“ kustutamiseks klõpsa", + "emptyLyricText": "Laulusõnu pole", + "playModeText": { + "order": "Oma järjekorras", + "orderLoop": "Korda", + "singleLoop": "Korda üks kord", + "shufflePlay": "Sega lood" + } + }, + "about": { + "links": { + "homepage": "Avaleht", + "source": "Lähtekood", + "featureRequests": "Arendusettepanekud", + "lastInsightsCollection": "Viimati kogutud statistika", + "insights": { + "disabled": "Pole kasutusel", + "waiting": "Ootel" + } + }, + "tabs": { + "about": "Teave", + "config": "Seadistus" + }, + "config": { + "configName": "Seadistuse nimi", + "environmentVariable": "Keskkonnamuutuja", + "currentValue": "Praegune väärtus", + "configurationFile": "Seadistusfail", + "exportToml": "Ekspordi seadistused (TOML-failina)", + "exportSuccess": "Seadistused on eksporditud lõikelauale TOML-failina", + "exportFailed": "Seadistuse kopeerimine ei õnnestunud", + "devFlagsHeader": "Arendusparameetrid (võivad muutuda või sootuks kaduda)", + "devFlagsComment": "Need on katselised seadistused, mis võivad tulevastest versioonidest kaduda", + "downloadToml": "Laadi seadistused alla (TOML-failina)" + } + }, + "activity": { + "title": "Tegevus", + "totalScanned": "Kokku skaneeritud kaustu", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täisskaneerimine", + "serverUptime": "Serveri katkematu tööaeg", + "serverDown": "POLE VÕRGUS", + "scanType": "Tüüp", + "status": "Skaneerimisviga", + "elapsedTime": "Möödunud aeg", + "selectiveScan": "Valikuline" + }, + "help": { + "title": "Navidrome'i kiirklahvid", + "hotkeys": { + "show_help": "Näita seda abiteavet", + "toggle_menu": "Lülita menüü külgriba sisse/välja", + "toggle_play": "Esita / Peata esitus", + "prev_song": "Eelmine lugu", + "next_song": "Järgmine lugu", + "vol_up": "Heli valjemaks", + "vol_down": "Heli vaiksemaks", + "toggle_love": "Lisa see lugu lemmikute hulka", + "current_song": "Mine esitamisel loo juurde" + } + }, + "nowPlaying": { + "title": "Hetkel esitamisel", + "empty": "Mitte midagi pole hetkel esitamisel", + "minutesAgo": "%{smart_count} minut tagasi |||| %{smart_count} minutit tagasi" + } +} \ No newline at end of file From 339a6271f112fe10cab7d792aa3678d3065288a0 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 18 May 2026 12:50:30 +0000 Subject: [PATCH 32/60] chore(subsonic): only log response body on send error when at trace level or higher (#5501) --- server/subsonic/api.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 1ca364449..fbab02794 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -375,6 +375,10 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub } if _, err := w.Write(response); err != nil { //nolint:gosec - log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) + if log.IsGreaterOrEqualTo(log.LevelTrace) { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) + } else { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, err) + } } } From 23252e06388ed3d200ebb8a54f21399e50ce26f9 Mon Sep 17 00:00:00 2001 From: VirtualWolf Date: Wed, 20 May 2026 00:01:41 +1000 Subject: [PATCH 33/60] fix(ui): updated the AMusic theme to use the correct text colour for primary confirmation buttons (#5509) --- ui/src/themes/amusic.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js index 74f7d3fd4..55205baf3 100644 --- a/ui/src/themes/amusic.js +++ b/ui/src/themes/amusic.js @@ -192,6 +192,11 @@ export default { paddingBottom: '1rem', }, }, + RaConfirm: { + confirmPrimary: { + color: '#fff', + }, + }, RaDeleteWithConfirmButton: { deleteButton: { color: '#fff !important', From a84f092d006ec80e9bd940af31a1fe235c1f5100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 19 May 2026 14:23:38 -0300 Subject: [PATCH 34/60] fix(subsonic): require admin access for Subsonic management endpoints (#5510) * fix: require admin for radio mutations Subsonic internet radio station mutation endpoints are admin-only in the Subsonic and OpenSubsonic specs, but the router only required an authenticated player. Add a reusable Subsonic admin middleware and apply it to create, update, and delete radio routes while leaving the list endpoint available to authenticated users. Cover the middleware and router behavior with unit and e2e tests. * fix: streamline admin-only routes for internet radio station management Signed-off-by: Deluan * fix: use admin-only middleware for starting scans Signed-off-by: Deluan * test: align start scan authorization coverage StartScan authorization now lives in the shared Subsonic admin middleware instead of the handler. Remove the obsolete direct handler unit assertion so the package tests reflect the route-level guard covered by middleware and e2e tests. * fix: require admin for getUsers The Subsonic getUsers endpoint exposes user-list semantics and should use the same shared admin middleware as other admin-only management endpoints. Apply the route-level guard while leaving getUser unchanged, and update the multi-user e2e coverage to expect regular users to receive an authorization failure. * test: cover admin-only Subsonic access Add e2e coverage that admins can still call getUsers after the route-level guard and that regular authenticated users can still list internet radio stations. These cases capture the access boundaries raised during PR review. --------- Signed-off-by: Deluan --- server/e2e/subsonic_multiuser_test.go | 18 +++++--- server/e2e/subsonic_radio_test.go | 53 ++++++++++++++++++++++++ server/subsonic/api.go | 13 +++--- server/subsonic/library_scanning.go | 4 -- server/subsonic/library_scanning_test.go | 23 ---------- server/subsonic/middlewares.go | 17 ++++++++ server/subsonic/middlewares_test.go | 30 ++++++++++++++ 7 files changed, 121 insertions(+), 37 deletions(-) diff --git a/server/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go index 4a5c35a7e..d8c5d3689 100644 --- a/server/e2e/subsonic_multiuser_test.go +++ b/server/e2e/subsonic_multiuser_test.go @@ -60,15 +60,23 @@ var _ = Describe("Multi-User Isolation", Ordered, func() { }) }) - Describe("getUsers for regular user", func() { - It("returns only the requesting user's info", func() { - resp := doReqWithUser(regularUser, "getUsers") + Describe("getUsers authorization", func() { + It("succeeds for admin user", func() { + resp := doReqWithUser(adminUser, "getUsers") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Users).ToNot(BeNil()) Expect(resp.Users.User).To(HaveLen(1)) - Expect(resp.Users.User[0].Username).To(Equal("regular")) - Expect(resp.Users.User[0].AdminRole).To(BeFalse()) + Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) + Expect(resp.Users.User[0].AdminRole).To(BeTrue()) + }) + + It("fails for regular user because getUsers is admin-only", func() { + resp := doReqWithUser(regularUser, "getUsers") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) }) }) }) diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go index ce64c31a1..cd778fa79 100644 --- a/server/e2e/subsonic_radio_test.go +++ b/server/e2e/subsonic_radio_test.go @@ -46,6 +46,30 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(radioID).ToNot(BeEmpty()) }) + It("getInternetRadioStations remains available to regular users", func() { + resp := doReqWithUser(regularUser, "getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + + It("createInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "createInternetRadioStation", + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + It("updateInternetRadioStation modifies the station", func() { resp := doReq("updateInternetRadioStation", "id", radioID, @@ -64,6 +88,35 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com")) }) + It("updateInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "updateInternetRadioStation", + "id", radioID, + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) + Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) + }) + + It("deleteInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].ID).To(Equal(radioID)) + }) + It("deleteInternetRadioStation removes it", func() { resp := doReq("deleteInternetRadioStation", "id", radioID) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index fbab02794..7d15125b6 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -171,12 +171,12 @@ func (api *Router) routes() http.Handler { r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getUser", api.GetUser) - h(r, "getUsers", api.GetUsers) + h(r.With(adminOnly), "getUsers", api.GetUsers) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getScanStatus", api.GetScanStatus) - h(r, "startScan", api.StartScan) + h(r.With(adminOnly), "startScan", api.StartScan) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -195,10 +195,13 @@ func (api *Router) routes() http.Handler { }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) - h(r, "createInternetRadioStation", api.CreateInternetRadio) - h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) h(r, "getInternetRadioStations", api.GetInternetRadios) - h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + r.Group(func(r chi.Router) { + r.Use(adminOnly) + h(r, "createInternetRadioStation", api.CreateInternetRadio) + h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) + h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + }) }) if conf.Server.EnableSharing { r.Group(func(r chi.Router) { diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index bac27f821..e6f64456d 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -40,10 +40,6 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorGeneric, "Internal error") } - if !loggedUser.IsAdmin { - return nil, newError(responses.ErrorAuthorizationFail) - } - p := req.Params(r) fullScan := p.BoolOr("fullScan", false) diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index c62c156bc..771fc3352 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -23,29 +23,6 @@ var _ = Describe("LibraryScanning", func() { }) Describe("StartScan", func() { - It("requires admin authentication", func() { - // Create non-admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "user-id", - IsAdmin: false, - }) - - // Create request - r := httptest.NewRequest("GET", "/rest/startScan", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should return authorization error - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail)) - }) - It("triggers a full scan with no parameters", func() { // Create admin user ctx := request.WithUser(context.Background(), model.User{ diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 5832bb1de..837852d18 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -155,6 +155,23 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } } +func adminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + loggedUser, ok := request.UserFrom(r.Context()) + if !ok { + sendError(w, r, newError(responses.ErrorGeneric, "Internal error")) + return + } + + if !loggedUser.IsAdmin { + sendError(w, r, newError(responses.ErrorAuthorizationFail)) + return + } + + next.ServeHTTP(w, r) + }) +} + func validateCredentials(user *model.User, pass, token, salt, jwt string) error { valid := false diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index aba14a0aa..3f8c07a56 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -308,6 +308,36 @@ var _ = Describe("Middlewares", func() { }) }) + Describe("AdminOnly", func() { + It("passes admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + }) + + It("rejects non-admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="50"`)) + Expect(next.called).To(BeFalse()) + }) + + It("returns an internal error when user is missing from context", func() { + r := newGetRequest() + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="0"`)) + Expect(next.called).To(BeFalse()) + }) + }) + Describe("GetPlayer", func() { var mockedPlayers *mockPlayers var r *http.Request From 545a9ecc3c52f6523c10dbd178ca5f94f55c4288 Mon Sep 17 00:00:00 2001 From: nenadjokic Date: Tue, 19 May 2026 19:50:34 +0200 Subject: [PATCH 35/60] fix(ui): update Serbian translation (#5444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): correct grammar errors in Serbian (sr) translation Eight objective grammatical / lexical errors in `resources/i18n/sr.json`. No stylistic or strategic re-wording — only corrections where the current string is grammatically wrong, contains a non-word, or breaks plural / case agreement. | Key | Before | After | Why | | --- | --- | --- | --- | | `resources.song.fields.bitDepth` | `Битова` | `Битска дубина` | "Битова" is genitive plural of "bit" ("of bits") and drops the "depth" semantics. Adjective+noun shape matches `Битски проток` already used for the adjacent "Bit rate" field | | `resources.song.fields.channels` | `Канала` | `Канали` | "Канала" is genitive plural ("of channels"); a column header needs nominative plural "Канали" | | `resources.radio.name` plural | `Радији` | `Радио-станице` | "Радији" is not a valid plural of "Радио" in Serbian. The standard plural for radio stations is "Радио-станице" | | `ra.input.file.upload_*` and `ra.input.image.upload_*` | `Упустите фајлове / слике …` | `Превуците фајлове / слике …` | "Упустити" means "to engage in / to indulge", not "to drop". For drag-and-drop UIs the standard Serbian verb is "Превуците" ("Drag") | | `ra.navigation.prev` | `Претход` | `Претх.` | "Претход" is not a word — looks like a truncated "Претходна" missing the period. Restored as a proper abbreviation | | `about.links.featureRequests` | `Захтеви за функцијама` | `Захтеви за функције` | Wrong case. Serbian "захтев за X" takes accusative ("захтев за помоћ"), not instrumental ("за функцијама") | | `player.clickToPauseText` / `clickToPlayText` | `Кликни за паузирање / пуштање` | `Кликните за паузирање / пуштање` | The rest of the file uses formal plural imperative ("Кликните…"). Only these two used the singular informal "Кликни", which broke the consistent register | JSON validated with `python3 -m json.tool`. No keys added, removed, or re-ordered — diff is purely value substitution. Stylistic and lexical modernization (e.g. "Уметник" → "Извођач" for music artist, filling the missing `library` / `plugin` / `nowPlaying` blocks added in upstream en.json) intentionally left for a follow-up PR after this baseline of objective fixes lands. * fix(i18n): fill missing keys in Serbian (sr) translation — 100% coverage The Serbian translation was at 70% of upstream `en.json` (389 of 553 keys). This commit fills all 164 missing keys, bringing coverage to 100%. The file is also re-ordered to match `en.json`'s key sequence so that future translation drift is easy to detect by diffing the two files side-by-side. This is the same regeneration pattern used by the previous maintainer's PR #3941. ## What was missing | Block | Keys | Notes | | --- | ---: | --- | | `resources.plugin` | 58 | Whole Plugin system block — settings, config schema, permissions, notifications | | `resources.library` | 43 | Whole Library management block — fields, scan actions, validation, notifications | | `about.config` + `about.tabs` | 12 | Configuration export feature (TOML) | | `message.*` | 11 | Cover-art upload/remove + Instant Mix + remove-all-missing | | `resources.song` | 9 | composer, sample rate, gain fields, instant mix, show-in-playlist | | `resources.playlist` | 6 | search-or-create UX, save-queue-to-playlist | | `resources.artist` | 5 | top songs / shuffle / radio actions, missing field, maincredit role | | `resources.user` | 5 | Per-user library access controls | | `menu.librarySelector` | 4 | Multi-library selector | | `activity` | 4 | selectiveScan, scanType, status, elapsedTime | | `nowPlaying` | 3 | Now Playing widget (title / empty / minutesAgo plural) | | `resources.album` | 2 | libraryName, missing | | `resources.missing` | 2 | remove_all action, libraryName field | ## Translation conventions followed - Stuck with the existing terminology already in `sr.json` for consistency — e.g. `Уметник` for "Artist", `Плејлиста` for "Playlist", `Жетон` for "Token". Whether `Уметник` → `Извођач` (music-context "performer") is a worthwhile rename is a separate question that deserves its own PR with a focused review surface; not in scope here. - Cyrillic throughout (matches `languageName: "српски"`). - Variable interpolation (`%{var}`) preserved exactly. - Pluralisation separator (` |||| `) preserved on plural-aware keys (`nowPlaying.minutesAgo`, `resources.library.name`, `resources.plugin.name`, `resources.artist.roles.maincredit`). ## Validation - JSON validated with `python3 -m json.tool` - Key-count parity check: 553 keys in `en.json` → 553 keys in `sr.json`, zero missing, zero extra. - Diff is +408 / -200 (line moves due to canonical ordering plus the net 164 new translations). All existing translations preserved verbatim. ## Builds on PR #5444 This branch sits on top of `i18n-sr-grammar-fixes` (PR #5444). If that PR merges first, this one auto-rebases cleanly. If this one merges first, PR #5444 has trivial conflicts in the same 7 strings (all already resolved here as part of regeneration). --- resources/i18n/sr.json | 654 +++++++++++++++++++++++++++-------------- 1 file changed, 431 insertions(+), 223 deletions(-) diff --git a/resources/i18n/sr.json b/resources/i18n/sr.json index 1cf7e39e7..cf0fc5d2f 100644 --- a/resources/i18n/sr.json +++ b/resources/i18n/sr.json @@ -4,45 +4,54 @@ "song": { "name": "Песма |||| Песме", "fields": { - "album": "Албум", "albumArtist": "Уметник албума", - "artist": "Уметник", - "bitDepth": "Битова", - "bitRate": "Битски проток", - "bpm": "BPM", - "channels": "Канала", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "discSubtitle": "Поднаслов диска", "duration": "Трајање", + "trackNumber": "#", + "playCount": "Пуштано", + "title": "Наслов", + "artist": "Уметник", + "composer": "Композитор", + "album": "Албум", + "path": "Путања фајла", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "size": "Величина фајла", + "updatedAt": "Ажурирано", + "bitRate": "Битски проток", + "bitDepth": "Битска дубина", + "sampleRate": "Учестаност узорковања", + "albumGain": "Појачање албума", + "trackGain": "Појачање нумере", + "channels": "Канали", + "disc": "Диск %{discNumber}", + "discSubtitle": "Поднаслов диска", + "starred": "Омиљено", + "comment": "Коментар", + "rating": "Рејтинг", + "quality": "Квалитет", + "bpm": "BPM", + "playDate": "Последње пуштано", + "createdAt": "Датум додавања", "grouping": "Груписање", - "mappedTags": "Мапиране ознаке", "mood": "Расположење", "participants": "Додатни учесници", - "path": "Путања фајла", - "playCount": "Пуштано", - "playDate": "Последње пуштано", - "quality": "Квалитет", - "rating": "Рејтинг", - "rawTags": "Сирове ознаке", - "size": "Величина фајла", - "starred": "Омиљено", "tags": "Додатне ознаке", - "title": "Наслов", - "trackNumber": "#", - "updatedAt": "Ажурирано", - "year": "Година" + "mappedTags": "Мапиране ознаке", + "rawTags": "Сирове ознаке", + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", - "playNext": "Пусти наредно", "playNow": "Пусти одмах", - "shuffleAll": "Измешај све" + "addToPlaylist": "Додај у плејлисту", + "showInPlaylist": "Прикажи у плејлисти", + "shuffleAll": "Измешај све", + "download": "Преузми", + "playNext": "Пусти наредно", + "info": "Прикажи инфо", + "instantMix": "Инстант микс" } }, "album": { @@ -50,46 +59,48 @@ "fields": { "albumArtist": "Уметник албума", "artist": "Уметник", - "catalogNum": "Каталошки број", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "date": "Датум снимања", "duration": "Трајање", + "songCount": "Песме", + "playCount": "Пуштано", + "size": "Величина", + "name": "Назив", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "date": "Датум снимања", + "originalDate": "Оригинално", + "releaseDate": "Објављено", + "releases": "Издање|||| Издања", + "released": "Објављено", + "updatedAt": "Ажурирано", + "comment": "Коментар", + "rating": "Рејтинг", + "createdAt": "Датум додавања", + "recordLabel": "Издавачка кућа", + "catalogNum": "Каталошки број", + "releaseType": "Тип", "grouping": "Груписање", "media": "Медијум", "mood": "Расположење", - "name": "Назив", - "originalDate": "Оригинално", - "playCount": "Пуштано", - "rating": "Рејтинг", - "recordLabel": "Издавачка кућа", - "releaseDate": "Објављено", - "releaseType": "Тип", - "released": "Објављено", - "releases": "Издање|||| Издања", - "size": "Величина", - "songCount": "Песме", - "updatedAt": "Ажурирано", - "year": "Година" + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", - "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", "playAll": "Пусти", "playNext": "Пусти наредно", + "addToQueue": "Пусти касније", "share": "Дели", - "shuffle": "Измешај" + "shuffle": "Измешај", + "addToPlaylist": "Додај у плејлисту", + "download": "Преузми", + "info": "Прикажи инфо" }, "lists": { "all": "Све", - "mostPlayed": "Најчешће пуштано", "random": "Насумично", "recentlyAdded": "Додато недавно", "recentlyPlayed": "Пуштано недавно", + "mostPlayed": "Најчешће пуштано", "starred": "Омиљено", "topRated": "Најбоље рангирано" } @@ -97,116 +108,136 @@ "artist": { "name": "Уметник |||| Уметници", "fields": { - "albumCount": "Број албума", - "genre": "Жанр", "name": "Назив", + "albumCount": "Број албума", + "songCount": "Број песама", + "size": "Величина", "playCount": "Пуштано", "rating": "Рејтинг", + "genre": "Жанр", "role": "Улога", - "size": "Величина", - "songCount": "Број песама" + "missing": "Недостаје" }, "roles": { "albumartist": "Уметник албума |||| Уметници албума", - "arranger": "Аранжер |||| Аранжери", "artist": "Уметник |||| Уметници", "composer": "Композитор |||| Композитори", "conductor": "Диригент |||| Диригенти", - "director": "Режисер |||| Режисери", - "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", - "engineer": "Инжењер |||| Инжењери", "lyricist": "Текстописац |||| Текстописци", - "mixer": "Миксер |||| Миксери", - "performer": "Извођач |||| Извођачи", + "arranger": "Аранжер |||| Аранжери", "producer": "Продуцент |||| Продуценти", - "remixer": "Ремиксер |||| Ремиксери" + "director": "Режисер |||| Режисери", + "engineer": "Инжењер |||| Инжењери", + "mixer": "Миксер |||| Миксери", + "remixer": "Ремиксер |||| Ремиксери", + "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", + "performer": "Извођач |||| Извођачи", + "maincredit": "Уметник албума или уметник |||| Уметници албума или уметници" + }, + "actions": { + "topSongs": "Најбоље песме", + "shuffle": "Измешај", + "radio": "Радио" } }, "user": { "name": "Корисник |||| Корисници", "fields": { - "changePassword": "Измени лозинку?", - "createdAt": "Креирана", - "currentPassword": "Текућа лозинка", + "userName": "Корисничко име", "isAdmin": "Да ли је Админ", - "lastAccessAt": "Последњи приступ", "lastLoginAt": "Последња пријава", - "name": "Назив", - "newPassword": "Нова лозинка", - "password": "Лозинка", - "token": "Жетон", + "lastAccessAt": "Последњи приступ", "updatedAt": "Ажурирано", - "userName": "Корисничко име" + "name": "Назив", + "password": "Лозинка", + "createdAt": "Креирана", + "changePassword": "Измени лозинку?", + "currentPassword": "Текућа лозинка", + "newPassword": "Нова лозинка", + "token": "Жетон", + "libraries": "Библиотеке" }, "helperTexts": { - "name": "Измене вашег имена ће постати видљиве након следеће пријаве" + "name": "Измене вашег имена ће постати видљиве након следеће пријаве", + "libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке" }, "notifications": { "created": "Корисник креиран", - "deleted": "Корисник обрисан", - "updated": "Корисник ажуриран" + "updated": "Корисник ажуриран", + "deleted": "Корисник обрисан" + }, + "validation": { + "librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори" }, "message": { + "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.", "clickHereForToken": "Кликните овде да преузмете свој жетон", - "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон." + "selectAllLibraries": "Изабери све библиотеке", + "adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама" } }, "player": { "name": "Плејер |||| Плејери", "fields": { - "client": "Клијент", - "lastSeen": "Последњи пут виђен", - "maxBitRate": "Макс. битски проток", "name": "Назив", - "reportRealPath": "Пријављуј реалну путању", - "scrobbleEnabled": "Шаљи скроблове на спољне сервисе", "transcodingId": "Транскодирање", - "userName": "Корисничко име" + "maxBitRate": "Макс. битски проток", + "client": "Клијент", + "userName": "Корисничко име", + "lastSeen": "Последњи пут виђен", + "reportRealPath": "Пријављуј реалну путању", + "scrobbleEnabled": "Шаљи скроблове на спољне сервисе" } }, "transcoding": { "name": "Транскодирање |||| Транскодирања", "fields": { - "command": "Команда", - "defaultBitRate": "Подразумевани битски проток", "name": "Назив", - "targetFormat": "Циљни формат" + "targetFormat": "Циљни формат", + "defaultBitRate": "Подразумевани битски проток", + "command": "Команда" } }, "playlist": { "name": "Плејлиста |||| Плејлисте", "fields": { - "comment": "Коментар", - "createdAt": "Креирана", - "duration": "Трајање", "name": "Назив", + "duration": "Трајање", "ownerName": "Власник", - "path": "Увоз из", "public": "Јавна", + "updatedAt": "Ажурирано", + "createdAt": "Креирана", "songCount": "Песме", + "comment": "Коментар", "sync": "Ауто-увоз", - "updatedAt": "Ажурирано" + "path": "Увоз из" }, "actions": { + "selectPlaylist": "Изабери плејлисту", "addNewPlaylist": "Креирај „%{name}”", "export": "Извези", - "makePrivate": "Учини приватном", + "saveQueue": "Сачувај ред у плејлисту", "makePublic": "Учини јавном", - "selectPlaylist": "Изабери плејлисту" + "makePrivate": "Учини приватном", + "searchOrCreate": "Претражите плејлисте или унесите назив за нову…", + "pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту", + "removeFromSelection": "Уклони из избора" }, "message": { "duplicate_song": "Додај дуплиране песме", - "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?" + "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?", + "noPlaylistsFound": "Нема пронађених плејлиста", + "noPlaylists": "Нема доступних плејлиста" } }, "radio": { - "name": "Радио |||| Радији", + "name": "Радио |||| Радио-станице", "fields": { - "createdAt": "Креирана", - "homePageUrl": "URL почетне странице", "name": "Назив", "streamUrl": "URL тока", - "updatedAt": "Ажурирано" + "homePageUrl": "URL почетне странице", + "updatedAt": "Ажурирано", + "createdAt": "Креирана" }, "actions": { "playNow": "Пусти одмах" @@ -215,18 +246,18 @@ "share": { "name": "Дељење |||| Дељења", "fields": { - "contents": "Садржај", - "createdAt": "Креирано", + "username": "Поделио", + "url": "URL", "description": "Опис", "downloadable": "Допушта се преузимање?", + "contents": "Садржај", "expiresAt": "Истиче", - "format": "Формат", "lastVisitedAt": "Последњи пут посећено", + "visitCount": "Број посета", + "format": "Формат", "maxBitRate": "Макс. битски проток", "updatedAt": "Ажурирано", - "url": "URL", - "username": "Поделио", - "visitCount": "Број посета" + "createdAt": "Креирано" }, "notifications": {}, "actions": {} @@ -237,111 +268,246 @@ "fields": { "path": "Путања", "size": "Величина", + "libraryName": "Библиотека", "updatedAt": "Нестао дана" }, "actions": { - "remove": "Уклони" + "remove": "Уклони", + "remove_all": "Уклони све" }, "notifications": { "removed": "Фајл који недостаје, или више њих, је уклоњен" } + }, + "library": { + "name": "Библиотека |||| Библиотеке", + "fields": { + "name": "Назив", + "path": "Путања", + "remotePath": "Удаљена путања", + "lastScanAt": "Последње скенирање", + "songCount": "Песме", + "albumCount": "Албуми", + "artistCount": "Уметници", + "totalSongs": "Песме", + "totalAlbums": "Албуми", + "totalArtists": "Уметници", + "totalFolders": "Фасцикле", + "totalFiles": "Фајлови", + "totalMissingFiles": "Фајлови који недостају", + "totalSize": "Укупна величина", + "totalDuration": "Трајање", + "defaultNewUsers": "Подразумевано за нове кориснике", + "createdAt": "Креирана", + "updatedAt": "Ажурирана" + }, + "sections": { + "basic": "Основне информације", + "statistics": "Статистика" + }, + "actions": { + "scan": "Скенирај библиотеку", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "manageUsers": "Управљај приступом корисника", + "viewDetails": "Прикажи детаље" + }, + "notifications": { + "created": "Библиотека је успешно креирана", + "updated": "Библиотека је успешно ажурирана", + "deleted": "Библиотека је успешно обрисана", + "scanStarted": "Скенирање библиотеке је покренуто", + "quickScanStarted": "Брзо скенирање је покренуто", + "fullScanStarted": "Комплетно скенирање је покренуто", + "scanError": "Грешка при покретању скенирања. Проверите дневнике.", + "scanCompleted": "Скенирање библиотеке је завршено" + }, + "validation": { + "nameRequired": "Назив библиотеке је обавезан", + "pathRequired": "Путања библиотеке је обавезна", + "pathNotDirectory": "Путања библиотеке мора да буде фасцикла", + "pathNotFound": "Путања библиотеке није пронађена", + "pathNotAccessible": "Путања библиотеке није доступна", + "pathInvalid": "Неисправна путања библиотеке" + }, + "messages": { + "deleteConfirm": "Да ли сте сигурни да желите да обришете ову библиотеку? Ово ће да уклони све повезане податке и приступ корисника.", + "scanInProgress": "Скенирање је у току…", + "noLibrariesAssigned": "Овом кориснику нема додељених библиотека" + } + }, + "plugin": { + "name": "Додатак |||| Додаци", + "fields": { + "id": "ИД", + "name": "Назив", + "description": "Опис", + "version": "Верзија", + "author": "Аутор", + "website": "Веб-сајт", + "permissions": "Дозволе", + "enabled": "Омогућено", + "status": "Статус", + "path": "Путања", + "lastError": "Грешка", + "hasError": "Грешка", + "updatedAt": "Ажурирано", + "createdAt": "Инсталирано", + "configKey": "Кључ", + "configValue": "Вредност", + "allUsers": "Дозволи свим корисницима", + "selectedUsers": "Изабрани корисници", + "allLibraries": "Дозволи све библиотеке", + "selectedLibraries": "Изабране библиотеке", + "allowWriteAccess": "Дозволи приступ за упис" + }, + "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": "Конфигуришите додатак користећи парове кључ-вредност. Оставите празно ако додатак не захтева конфигурацију.", + "configValidationError": "Провера исправности конфигурације није успела:", + "schemaRenderError": "Не може да се прикаже образац за конфигурацију. Шема додатка можда није исправна.", + "clickPermissions": "Кликните на дозволу за детаље", + "noConfig": "Конфигурација није постављена", + "allUsersHelp": "Када је омогућено, додатак ће имати приступ свим корисницима, укључујући оне који буду креирани у будућности.", + "noUsers": "Нема изабраних корисника", + "permissionReason": "Разлог", + "usersRequired": "Овај додатак захтева приступ информацијама о корисницима. Изаберите којим корисницима додатак може да приступи, или омогућите „Дозволи свим корисницима”.", + "allLibrariesHelp": "Када је омогућено, додатак ће имати приступ свим библиотекама, укључујући оне које буду креиране у будућности.", + "noLibraries": "Нема изабраних библиотека", + "librariesRequired": "Овај додатак захтева приступ информацијама о библиотекама. Изаберите којим библиотекама додатак може да приступи, или омогућите „Дозволи све библиотеке”.", + "allowWriteAccessHelp": "Када је омогућено, додатак може да мења фајлове у фасциклама библиотеке. Подразумевано, додаци имају приступ само за читање.", + "requiredHosts": "Потребни хостови" + }, + "placeholders": { + "configKey": "кључ", + "configValue": "вредност" + } } }, "ra": { "auth": { - "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", - "buttonCreateAdmin": "Креирај админа", + "welcome1": "Хвала што сте инсталирали Navidrome!", + "welcome2": "За почетак, креирајте админ корисника", "confirmPassword": "Потврдите лозинку", - "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите", - "logout": "Одјави се", + "buttonCreateAdmin": "Креирај админа", + "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", + "user_menu": "Профил", + "username": "Корисничко име", "password": "Лозинка", "sign_in": "Пријави се", "sign_in_error": "Потврда идентитета није успела, покушајте поново", - "user_menu": "Профил", - "username": "Корисничко име", - "welcome1": "Хвала што сте инсталирали Navidrome!", - "welcome2": "За почетак, креирајте админ корисника" + "logout": "Одјави се", + "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите" }, "validation": { - "email": "Мора да буде исправна и-мејл адреса", "invalidChars": "Молимо вас да користите само слова и цифре", - "maxLength": "Мора да буде %{max} карактера или мање", - "maxValue": "Мора да буде %{max} или мање", - "minLength": "Мора да буде барем %{min} карактера", - "minValue": "Мора да буде барем %{min}", - "number": "Мора да буде број", - "oneOf": "Мора да буде једно од: %{options}", "passwordDoesNotMatch": "Лозинка се не подудара", - "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "required": "Неопходно", + "minLength": "Мора да буде барем %{min} карактера", + "maxLength": "Мора да буде %{max} карактера или мање", + "minValue": "Мора да буде барем %{min}", + "maxValue": "Мора да буде %{max} или мање", + "number": "Мора да буде број", + "email": "Мора да буде исправна и-мејл адреса", + "oneOf": "Мора да буде једно од: %{options}", + "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "unique": "Мора да буде јединствено", "url": "Мора да буде исправна URL адреса" }, "action": { - "add": "Додај", "add_filter": "Додај филтер", + "add": "Додај", "back": "Иди назад", "bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки", "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Откажи", "clear_input_value": "Обриши вредност", "clone": "Клонирај", - "close": "Затвори", - "close_menu": "Затвори мени", "confirm": "Потврди", "create": "Креирај", "delete": "Обриши", - "download": "Преузми", "edit": "Уреди", - "expand": "Развиј", "export": "Извези", "list": "Листа", - "open_menu": "Отвори мени", "refresh": "Освежи", - "remove": "Уклони", "remove_filter": "Уклони овај филтер", + "remove": "Уклони", "save": "Сачувај", "search": "Тражи", - "share": "Дели", "show": "Прикажи", - "skip": "Прескочи", "sort": "Сортирај", "undo": "Поништи", - "unselect": "Уклони избор" + "expand": "Развиј", + "close": "Затвори", + "open_menu": "Отвори мени", + "close_menu": "Затвори мени", + "unselect": "Уклони избор", + "skip": "Прескочи", + "share": "Дели", + "download": "Преузми" }, "boolean": { - "false": "Не", - "true": "Да" + "true": "Да", + "false": "Не" }, "page": { "create": "Креирај %{name}", "dashboard": "Контролна табла", "edit": "%{name} #%{id}", - "empty": "Још увек нема %{name}.", "error": "Нешто је пошло наопако", - "invite": "Желите ли да се дода?", "list": "%{name}", "loading": "Учитава се", "not_found": "Није пронађено", - "show": "%{name} #%{id}" + "show": "%{name} #%{id}", + "empty": "Још увек нема %{name}.", + "invite": "Желите ли да се дода?" }, "input": { "file": { - "upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете." + "upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете." }, "image": { - "upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите слику да се отпреми, или кликните да је изаберете." - }, - "password": { - "toggle_hidden": "Прикажи лозинку", - "toggle_visible": "Сакриј лозинку" + "upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците слику да се отпреми, или кликните да је изаберете." }, "references": { "all_missing": "Не могу да се нађу подаци референци.", "many_missing": "Изгледа да барем једна од придружених референци више није доступна.", "single_missing": "Изгледа да придружена референца више није доступна." + }, + "password": { + "toggle_visible": "Сакриј лозинку", + "toggle_hidden": "Прикажи лозинку" } }, "message": { @@ -357,161 +523,203 @@ "loading": "Страница се учитава, сачекајте мало", "no": "Не", "not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.", - "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?", - "yes": "Да" + "yes": "Да", + "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?" }, "navigation": { - "next": "Наредна", - "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "no_results": "Није пронађен ниједан резултат", - "page_out_from_begin": "Не може да се иде испред странице 1", - "page_out_from_end": "Не може да се иде након последње странице", + "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "page_out_of_boundaries": "Број странице %{page} је ван опсега", + "page_out_from_end": "Не може да се иде након последње странице", + "page_out_from_begin": "Не може да се иде испред странице 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}", "page_rows_per_page": "Ставки по страници:", - "prev": "Претход", + "next": "Наредна", + "prev": "Претх.", "skip_nav": "Прескочи на садржај" }, "notification": { - "bad_item": "Неисправни елемент", - "canceled": "Акција је отказана", + "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано", "created": "Елемент је креиран", - "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", - "http_error": "Грешка у комуникацији са сервером", - "i18n_error": "Не могу да се учитају преводи за наведени језик", + "bad_item": "Неисправни елемент", "item_doesnt_exist": "Елемент не постоји", + "http_error": "Грешка у комуникацији са сервером", + "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", + "i18n_error": "Не могу да се учитају преводи за наведени језик", + "canceled": "Акција је отказана", "logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.", - "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.", - "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано" + "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор." }, "toggleFieldsMenu": { "columnsToDisplay": "Колоне за приказ", - "grid": "Мрежа", "layout": "Распоред", + "grid": "Мрежа", "table": "Табела" } }, "message": { - "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", - "delete_user_title": "Брисање корисника ’%{name}’", - "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", - "downloadOriginalFormat": "Преузми у оригиналном формату", - "lastfmLink": "Прочитај још...", - "lastfmLinkFailure": "Last.fm није могао да се повеже", - "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", - "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", - "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", - "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", - "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", - "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", - "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", - "noPlaylistsAvailable": "Није доступна ниједна", + "uploadCover": "Отпреми омот", + "removeCover": "Уклони омот", + "coverUploaded": "Омот је ажуриран", + "coverRemoved": "Омот је уклоњен", + "coverUploadError": "Грешка при отпремању омота", + "coverRemoveError": "Грешка при уклањању омота", "note": "НАПОМЕНА", + "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", + "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.", + "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", + "noSimilarSongsFound": "Нису пронађене сличне песме", + "startingInstantMix": "Учитава се инстант микс…", + "noTopSongsFound": "Нису пронађене најбоље песме", + "noPlaylistsAvailable": "Није доступна ниједна", + "delete_user_title": "Брисање корисника ’%{name}’", + "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", + "remove_missing_title": "Уклони фајлове који недостају", + "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", + "remove_all_missing_title": "Уклони све фајлове који недостају", + "remove_all_missing_content": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", "notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења", "notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола", + "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", + "lastfmLinkFailure": "Last.fm није могао да се повеже", + "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", + "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", + "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", + "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", "openIn": { "lastfm": "Отвори у Last.fm", "musicbrainz": "Отвори у MusicBrainz" }, - "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", - "remove_missing_title": "Уклони фајлове који недостају", + "lastfmLink": "Прочитај још...", + "shareOriginalFormat": "Подели у оригиналном формату", + "shareDialogTitle": "Подели %{resource} ’%{name}’", "shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}", "shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер", - "shareDialogTitle": "Подели %{resource} ’%{name}’", - "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", - "shareOriginalFormat": "Подели у оригиналном формату", "shareSuccess": "URL је копиран у клипборд: %{url}", - "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", - "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", - "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања." + "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", + "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", + "downloadOriginalFormat": "Преузми у оригиналном формату" }, "menu": { - "about": "О", - "albumList": "Албуми", "library": "Библиотека", + "librarySelector": { + "allLibraries": "Све библиотеке (%{count})", + "multipleLibraries": "%{selected} од %{total} библиотека", + "selectLibraries": "Изабери библиотеке", + "none": "Ниједна" + }, + "settings": "Подешавања", + "version": "Верзија", + "theme": "Тема", "personal": { "name": "Лична", "options": { + "theme": "Тема", + "language": "Језик", "defaultView": "Подразумевани поглед", "desktop_notifications": "Десктоп обавештења", - "gain": { - "album": "Користи Album појачање", - "none": "Искључено", - "track": "Користи Track појачање" - }, - "language": "Језик", "lastfmNotConfigured": "Није подешен Last.fm API-кључ", "lastfmScrobbling": "Скроблуј на Last.fm", "listenBrainzScrobbling": "Скроблуј на ListenBrainz", - "preAmp": "ReplayGain претпојачање (dB)", "replaygain": "ReplayGain режим", - "theme": "Тема" + "preAmp": "ReplayGain претпојачање (dB)", + "gain": { + "none": "Искључено", + "album": "Користи Album појачање", + "track": "Користи Track појачање" + } } }, + "albumList": "Албуми", "playlists": "Плејлисте", - "settings": "Подешавања", "sharedPlaylists": "Дељене плејлисте", - "theme": "Тема", - "version": "Верзија" + "about": "О" }, "player": { - "clickToDeleteText": "Кликните да обришете %{name}", - "clickToPauseText": "Кликни за паузирање", - "clickToPlayText": "Кликни за пуштање", + "playListsText": "Ред за пуштање", + "openText": "Отвори", "closeText": "Затвори", + "notContentText": "Нема музике", + "clickToPlayText": "Кликните за пуштање", + "clickToPauseText": "Кликните за паузирање", + "nextTrackText": "Наредна нумера", + "previousTrackText": "Претходна нумера", + "reloadText": "Поново учитај", + "volumeText": "Јачина", + "toggleLyricText": "Укљ./Искљ. стихове", + "toggleMiniModeText": "Умањи", "destroyText": "Уништи", "downloadText": "Преузми", + "removeAudioListsText": "Обриши аудио листе", + "clickToDeleteText": "Кликните да обришете %{name}", "emptyLyricText": "Нема стихова", - "nextTrackText": "Наредна нумера", - "notContentText": "Нема музике", - "openText": "Отвори", - "playListsText": "Ред за пуштање", "playModeText": { "order": "По редоследу", "orderLoop": "Понови", - "shufflePlay": "Измешај", - "singleLoop": "Понови једну" - }, - "previousTrackText": "Претходна нумера", - "reloadText": "Поново учитај", - "removeAudioListsText": "Обриши аудио листе", - "toggleLyricText": "Укљ./Искљ. стихове", - "toggleMiniModeText": "Умањи", - "volumeText": "Јачина" + "singleLoop": "Понови једну", + "shufflePlay": "Измешај" + } }, "about": { "links": { - "featureRequests": "Захтеви за функцијама", "homepage": "Почетна страница", + "source": "Изворни кôд", + "featureRequests": "Захтеви за функције", + "lastInsightsCollection": "Последња колекција увида", "insights": { "disabled": "Искључено", "waiting": "Чека се" - }, - "lastInsightsCollection": "Последња колекција увида", - "source": "Изворни кôд" + } + }, + "tabs": { + "about": "О програму", + "config": "Конфигурација" + }, + "config": { + "configName": "Назив конфигурације", + "environmentVariable": "Променљива окружења", + "currentValue": "Тренутна вредност", + "configurationFile": "Конфигурациони фајл", + "exportToml": "Извези конфигурацију (TOML)", + "downloadToml": "Преузми конфигурацију (TOML)", + "exportSuccess": "Конфигурација је извезена у клипборд у TOML формату", + "exportFailed": "Копирање конфигурације није успело", + "devFlagsHeader": "Развојне заставице (подложне промени или уклањању)", + "devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама" } }, "activity": { - "fullScan": "Комплетно скенирање", - "quickScan": "Брзо скенирање", - "serverDown": "ВАН МРЕЖЕ", - "serverUptime": "Сервер се извршава", "title": "Активност", - "totalScanned": "Укупан број скенираних фолдера" + "totalScanned": "Укупан број скенираних фолдера", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "selectiveScan": "Селективно", + "serverUptime": "Сервер се извршава", + "serverDown": "ВАН МРЕЖЕ", + "scanType": "Последње скенирање", + "status": "Грешка скенирања", + "elapsedTime": "Протекло време" + }, + "nowPlaying": { + "title": "Сада се пушта", + "empty": "Ништа се не пушта", + "minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута" }, "help": { "title": "Navidrome пречице", "hotkeys": { - "current_song": "Иди на текућу песму", - "next_song": "Наредна песма", - "prev_song": "Претходна песма", "show_help": "Прикажи ову помоћ", - "toggle_love": "Додај ову нумеру у омиљене", "toggle_menu": "Укљ./Искљ. бочну траку менија", "toggle_play": "Пусти / Паузирај", + "prev_song": "Претходна песма", + "next_song": "Наредна песма", + "current_song": "Иди на текућу песму", + "vol_up": "Појачај", "vol_down": "Утишај", - "vol_up": "Појачај" + "toggle_love": "Додај ову нумеру у омиљене" } } } From efe9291db0159b925a03e8daed5e5e9b1b55fe36 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 19 May 2026 18:02:29 -0300 Subject: [PATCH 36/60] refactor: multiple syntax updates for Go 1.26 Signed-off-by: Deluan --- .gitignore | 3 +- adapters/gotaglib/end_to_end_test.go | 10 ++-- core/external/provider.go | 4 +- core/external/provider_artistimage_test.go | 6 +-- .../external/provider_updatealbuminfo_test.go | 5 +- .../provider_updateartistinfo_test.go | 7 ++- core/ffmpeg/ffmpeg.go | 3 +- core/inspect.go | 3 +- core/lyrics/lyrics_test.go | 7 ++- core/lyrics/sources_test.go | 13 +++-- core/playback/mpv/mpv.go | 3 +- core/playlists/parse_nsp.go | 3 +- core/playlists/playlists_test.go | 15 ++---- core/playlists/rest_adapter_test.go | 3 +- core/scrobbler/play_tracker_test.go | 3 +- core/share.go | 4 +- model/artwork_id_test.go | 3 +- model/lyrics_test.go | 52 ++++++++----------- model/metadata/map_mediafile_test.go | 5 +- model/metadata/metadata_test.go | 15 +++--- persistence/artist_repository.go | 3 +- persistence/genre_repository.go | 3 +- persistence/persistence_suite_test.go | 14 ++--- persistence/playqueue_repository.go | 3 +- persistence/radio_repository_test.go | 3 +- plugins/host_kvstore_test.go | 9 ++-- plugins/host_library_test.go | 45 ++++++---------- plugins/host_websocket.go | 3 +- plugins/manifest_test.go | 3 +- plugins/package_test.go | 3 +- scanner/controller.go | 3 +- scanner/external.go | 3 +- server/auth.go | 3 +- server/nativeapi/artists.go | 6 +-- server/nativeapi/queue_test.go | 23 ++++---- server/public/handle_streams_test.go | 6 +-- server/subsonic/api_test.go | 3 +- server/subsonic/browsing.go | 3 +- server/subsonic/helpers.go | 7 ++- server/subsonic/media_retrieval_test.go | 3 +- server/subsonic/playlists.go | 3 +- server/subsonic/responses/responses_test.go | 23 ++++---- server/subsonic/sharing.go | 8 +-- server/subsonic/users.go | 3 +- tests/mock_user_repo.go | 5 +- utils/cache/cached_http_client.go | 3 +- utils/cache/simple_cache.go | 3 +- utils/chrono/meter.go | 4 +- utils/gg/gg.go | 5 -- utils/gg/gg_test.go | 15 +----- utils/req/req.go | 6 +-- 51 files changed, 143 insertions(+), 251 deletions(-) diff --git a/.gitignore b/.gitignore index 73475a53a..fc8eaac69 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ AGENTS.md *.wasm *.ndp openspec/ +.agents go.work* -.worktrees/ \ No newline at end of file +.worktrees/ diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index 4a93f5b83..e7dd18ac1 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -8,7 +8,6 @@ import ( "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" ) @@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") - return &mf + return new(metadata.ToMediaFile(1, "folderID")) } BeforeEach(func() { @@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() { 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)), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)), ) }) @@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() { DisplayTitle: "", Lang: code, Line: []model.Line{ - {Start: gg.P(int64(0)), Value: "This is"}, - {Start: gg.P(int64(2500)), Value: secondLine}, + {Start: new(int64(0)), Value: "This is"}, + {Start: new(int64(2500)), Value: secondLine}, }, Offset: nil, Synced: true, diff --git a/core/external/provider.go b/core/external/provider.go index 4f3295cc7..74dab4972 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl return album, err } - album.ExternalInfoUpdatedAt = P(time.Now()) + album.ExternalInfoUpdatedAt = new(time.Now()) album.ExternalUrl = info.URL if info.Description != "" { @@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = P(time.Now()) + artist.ExternalInfoUpdatedAt = new(time.Now()) err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 37d3fd81a..79612d651 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -272,12 +272,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns cached URL and does not call agent when info is not expired", func() { // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - recentTime := time.Now().Add(-1 * time.Minute) cachedArtist := &model.Artist{ ID: "artist-cached", Name: "Cached Artist", LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: &recentTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), } mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") @@ -304,12 +303,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns stale URL and enqueues refresh when info is expired", func() { // Arrange conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - expiredTime := time.Now().Add(-1 * time.Hour) staleArtist := &model.Artist{ ID: "artist-expired", Name: "Expired Artist", LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: &expiredTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), } mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 3dd8a587a..21824c93f 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://cached.com/album", Description: "Cached Desc", LargeImageUrl: "http://cached.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) @@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://expired.com/album", Description: "Expired Desc", LargeImageUrl: "http://expired.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index cc9506d1f..d783128fb 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ExternalUrl: "http://cached.url", Biography: "Cached Bio", LargeImageUrl: "http://cached_large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-similar-present", Name: "Similar Present"}, {ID: "ar-similar-absent", Name: "Similar Absent"}, @@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-expired", Name: "Expired Artist", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), SimilarArtists: model.Artists{ {ID: "ar-exp-similar", Name: "Expired Similar"}, }, @@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-similar-test", Name: "Similar Test Artist", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-sim-present", Name: "Similar Present"}, {ID: "", Name: "Similar Absent Raw"}, diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 3225ff150..a3f6cd7d2 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -326,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error { func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()) if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" { errMsg += ": " + stderrOutput diff --git a/core/inspect.go b/core/inspect.go index 751cf063f..01ec33760 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" - . "github.com/navidrome/navidrome/utils/gg" ) type InspectOutput struct { @@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: P(md.ToMediaFile(libraryId, folderId)), + MappedTags: new(md.ToMediaFile(libraryId, folderId)), } return result, nil diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 7e837782e..9ab732ad1 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,15 +31,15 @@ var _ = Describe("sources", func() { Lang: "eng", Line: []model.Line{ { - Start: gg.P(int64(18800)), + Start: new(int64(18800)), Value: "We're no strangers to love", }, { - Start: gg.P(int64(22801)), + Start: new(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: gg.P(int64(-100)), + Offset: new(int64(-100)), Synced: true, }, } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index b3d502101..d1aefcb5d 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -74,15 +73,15 @@ var _ = Describe("sources", func() { Lang: "eng", Line: []model.Line{ { - Start: gg.P(int64(18800)), + Start: new(int64(18800)), Value: "We're no strangers to love", }, { - Start: gg.P(int64(22801)), + Start: new(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: gg.P(int64(-100)), + Offset: new(int64(-100)), Synced: true, }, })) @@ -122,7 +121,7 @@ var _ = Describe("sources", func() { // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) }) @@ -137,9 +136,9 @@ var _ = Describe("sources", func() { // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) - Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) + Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) }) }) diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index 035e18dd5..6696eca2a 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error { func (j *Executor) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go index 56c80a950..a5b8b7c02 100644 --- a/core/playlists/parse_nsp.go +++ b/core/playlists/parse_nsp.go @@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R } err = json.Unmarshal(input, nsp) if err != nil { - var syntaxErr *json.SyntaxError - if errors.As(err, &syntaxErr) { + if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok { line, col := getPositionFromOffset(input, syntaxErr.Offset) return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 95d7b3e6a..f849a0a21 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() { It("allows owner to update their playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("allows admin to update any playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("denies non-owner, non-admin from updating", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("returns error when playlist not found", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(Equal(model.ErrNotFound)) }) @@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() { It("allows metadata updates on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Smart" - err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) }) diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 90d22327a..68461b259 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() { It("clears server-managed fields to prevent injection via REST API", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) - now := time.Now() pls := &model.Playlist{ Name: "Legit Playlist", Comment: "A comment", @@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() { Sync: true, UploadedImage: "injected-image-path", ExternalImageURL: "http://evil.example.com/ssrf", - EvaluatedAt: &now, + EvaluatedAt: new(time.Now()), } _, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 684b887fe..5383244cd 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -1133,8 +1133,7 @@ func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession if f.Error != nil { return f.Error } - uid := info.UserId - f.userID.Store(&uid) + f.userID.Store(new(info.UserId)) f.LastPlaybackReport.Store(&info) return nil } diff --git a/core/share.go b/core/share.go index a6d06a018..eb9b63ae9 100644 --- a/core/share.go +++ b/core/share.go @@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error if !expiresAt.IsZero() && expiresAt.Before(time.Now()) { return nil, model.ErrExpired } - share.LastVisitedAt = P(time.Now()) + share.LastVisitedAt = new(time.Now()) share.VisitCount++ err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") @@ -95,7 +95,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { } s.ID = id if V(s.ExpiresAt).IsZero() { - s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration)) + s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index b634e7cbc..ad66f7bb5 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -11,8 +11,7 @@ import ( var _ = Describe("ArtworkID", func() { Describe("NewArtworkID()", func() { It("creates a valid parseable ArtworkID", func() { - now := time.Now() - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now) + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 382976872..644b85ad2 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -8,14 +8,13 @@ import ( var _ = Describe("ToLyrics", func() { It("should parse tags with spaces", func() { - num := int64(1551) lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Lang).To(Equal("eng")) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.DisplayArtist).To(Equal("An artist")) Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(&num)) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) }) It("Should ignore bad offset", func() { @@ -25,39 +24,36 @@ var _ = Describe("ToLyrics", func() { }) It("should accept lines with no text and weird times", func() { - a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60) lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Hi there"}, - {Start: &b, Value: ""}, - {Start: &c, Value: "Test"}, - {Start: &d, Value: "late"}, + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, })) }) It("Should support multiple timestamps per line", func() { - a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51) lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Repeated"}, - {Start: &c, Value: ""}, - {Start: &d, Value: ""}, + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, })) }) It("Should support parsing multiline string", func() { - a, b := int64(0), int64(10*60*1000+1) lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is\na multiline\n\n[:0] string"}, - {Start: &b, Value: "This is\nalso one"}, + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, })) }) @@ -71,49 +67,45 @@ var _ = Describe("ToLyrics", func() { }) It("Allows timestamp in middle of line if also at beginning", func() { - a, b := int64(0), int64(1000) lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is [00:00:00] be a synced file"}, - {Start: &b, Value: "Line 2"}, + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, })) }) It("Ignores lines in synchronized lyric prior to first timestamp", func() { - a := int64(0) lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Text"}, + {Start: new(int64(0)), Value: "Text"}, })) }) It("Handles all possible ms cases", func() { - a, b, c := int64(1), int64(10), int64(100) lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "a"}, - {Start: &b, Value: "b"}, - {Start: &c, Value: "c"}, + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, })) }) It("Properly sorts repeated lyrics out of order", func() { - a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51) lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Test"}, - {Start: &c, Value: "Not repeated"}, - {Start: &d, Value: "Repeated"}, - {Start: &e, Value: "Test"}, + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, })) }) }) diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index e3adf3fae..16142f526 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -108,8 +107,8 @@ var _ = Describe("ToMediaFile", func() { expected := model.LyricList{ {Lang: "eng", Line: []model.Line{ - {Value: "This is", Start: P(int64(0))}, - {Value: "English SYLT", Start: P(int64(2500))}, + {Value: "This is", Start: new(int64(0))}, + {Value: "English SYLT", Start: new(int64(2500))}, }, Synced: true}, {Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false}, } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 663e306c4..c9acdbce9 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -274,8 +273,8 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.2dB", "1.2dB", gg.P(1.2)), + Entry("0", "0", new(0.0)), + Entry("1.2dB", "1.2dB", new(1.2)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), Entry("NaN", "NaN", nil), @@ -285,9 +284,9 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_peak", tagValue) Expect(mf.RGTrackPeak).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.0", "1.0", gg.P(1.0)), - Entry("0.5", "0.5", gg.P(0.5)), + Entry("0", "0", new(0.0)), + Entry("1.0", "1.0", new(1.0)), + Entry("0.5", "0.5", new(0.5)), Entry("Invalid dB suffix", "0.7dB", nil), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), @@ -299,8 +298,8 @@ var _ = Describe("Metadata", func() { Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(5.0)), - Entry("-3776", "-3776", gg.P(-9.75)), + Entry("0", "0", new(5.0)), + Entry("-3776", "-3776", new(-9.75)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), ) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index e75a0e58c..cfdc499e0 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -219,7 +218,7 @@ func (r *artistRepository) Exists(id string) (bool, error) { func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { dba := &dbArtist{Artist: a} - dba.CreatedAt = P(time.Now()) + dba.CreatedAt = new(time.Now()) dba.UpdatedAt = dba.CreatedAt _, err := r.put(dba.ID, dba, colsToUpdate...) return err diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 53f324bf4..22443284f 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -14,9 +14,8 @@ type genreRepository struct { } func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { - genreFilter := model.TagGenre return &genreRepository{ - baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter), + baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)), } } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ebc247d77..abc5c4b6a 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -103,7 +102,7 @@ var ( songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Path: p("kraft/radio/antenna.mp3"), - RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), + RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ ID: "1005", @@ -162,8 +161,6 @@ func p(path string) string { return filepath.FromSlash(path) } -// Initialize test DB -// TODO Load this data setup from file(s) var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) @@ -187,8 +184,7 @@ var _ = BeforeSuite(func() { alr := NewAlbumRepository(ctx, conn).(*albumRepository) for i := range testAlbums { - a := testAlbums[i] - err := alr.Put(&a) + err := alr.Put(new(testAlbums[i])) if err != nil { panic(err) } @@ -196,8 +192,7 @@ var _ = BeforeSuite(func() { arr := NewArtistRepository(ctx, conn) for i := range testArtists { - a := testArtists[i] - err := arr.Put(&a) + err := arr.Put(new(testArtists[i])) if err != nil { panic(err) } @@ -243,8 +238,7 @@ var _ = BeforeSuite(func() { rar := NewRadioRepository(ctx, conn) for i := range testRadios { - r := testRadios[i] - err := rar.Put(&r) + err := rar.Put(new(testRadios[i])) if err != nil { panic(err) } diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index c952b42b1..ba69ec746 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -89,8 +89,7 @@ func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue err := r.queryOne(sel, &res) - q := r.toModel(&res) - return &q, err + return new(r.toModel(&res)), err } func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 88a31ac49..828af8043 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -34,8 +34,7 @@ var _ = Describe("RadioRepository", func() { } for i := range testRadios { - r := testRadios[i] - err := repo.Put(&r) + err := repo.Put(new(testRadios[i])) if err != nil { panic(err) } diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 109ae8131..997409146 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -37,8 +37,7 @@ var _ = Describe("KVStoreService", func() { conf.Server.DataFolder = conf.NewDir(tmpDir) // Create service with 1KB limit for testing - maxSize := "1KB" - service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) }) @@ -253,8 +252,7 @@ var _ = Describe("KVStoreService", func() { // Close and reopen the service (simulating restart) Expect(service.Close()).To(Succeed()) - maxSize := "1KB" - service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -452,8 +450,7 @@ var _ = Describe("KVStoreService", func() { closeCtx, closeCancel := context.WithCancel(ctx) defer closeCancel() - maxSize := "1KB" - svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize}) + svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) // Insert an expired key so cleanup has work to do diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 67f5f9b0f..eb5b17a02 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -35,8 +35,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetLibrary", func() { It("should return library metadata without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 1, @@ -67,8 +66,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return library metadata with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 2, @@ -93,8 +91,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return error for non-existent library", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl) mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{}) @@ -107,8 +104,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetAllLibraries", func() { It("should return all libraries without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -130,8 +126,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return all libraries with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -152,10 +147,8 @@ var _ = Describe("LibraryService", Ordered, func() { }) Describe("Library Access Filtering", func() { - It("should only return libraries in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should only return libraries in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -173,10 +166,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results[0].Name).To(Equal("Jazz")) }) - It("should return error when getting a library not in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should return error when getting a library not in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -192,10 +183,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(err.Error()).To(ContainSubstring("not accessible")) }) - It("should allow access to a library in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should allow access to a library in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -211,10 +200,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(result.Name).To(Equal("Jazz")) }) - It("should return empty list when no libraries are allowed and allLibraries is false", func() { - reason := "test" - // No libraries allowed - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl) + It("should return empty list when no libraries are allowed and allLibraries is false", func() { // No libraries allowed + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -229,10 +216,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results).To(HaveLen(0)) }) - It("should return all libraries when allLibraries is true regardless of allowed list", func() { - reason := "test" - // allLibraries=true should ignore the allowed list - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl) + It("should return all libraries when allLibraries is true regardless of allowed list", func() { // allLibraries=true should ignore the allowed list + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{1}, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 74238a422..eef1e6236 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -302,8 +302,7 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { closeCode := websocket.CloseNoStatusReceived closeReason := "" - var ce *websocket.CloseError - if errors.As(err, &ce) { + if ce, ok := errors.AsType[*websocket.CloseError](err); ok { closeCode = ce.Code closeReason = ce.Text } diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index c45a480eb..0d371d14a 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -140,11 +140,10 @@ var _ = Describe("Manifest", func() { }) It("returns true when threads feature has a reason", func() { - reason := "Required for concurrent processing" m := &Manifest{ Experimental: &Experimental{ Threads: &ThreadsFeature{ - Reason: &reason, + Reason: new("Required for concurrent processing"), }, }, } diff --git a/plugins/package_test.go b/plugins/package_test.go index fa76ddd94..3d3fec022 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -135,12 +135,11 @@ var _ = Describe("ndpPackage", func() { Describe("readManifest", func() { It("should read only the manifest without loading wasm", func() { ndpPath := filepath.Join(tmpDir, "test.ndp") - desc := "A test plugin" manifest := &Manifest{ Name: "Test Plugin", Author: "Test Author", Version: "1.0.0", - Description: &desc, + Description: new("A test plugin"), } wasmBytes := make([]byte, 1024*1024) // 1MB of zeros diff --git a/scanner/controller.go b/scanner/controller.go index 94248ffd0..175b92e26 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -17,7 +17,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/pl" "golang.org/x/time/rate" ) @@ -38,7 +37,7 @@ func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, br devExternalScanner: conf.Server.DevExternalScanner, } if !c.devExternalScanner { - c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) + c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) } return c } diff --git a/scanner/external.go b/scanner/external.go index 393a9278c..7f573fe3e 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -97,8 +97,7 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) { if err := cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr)) } else { _ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err)) diff --git a/server/auth.go b/server/auth.go index a7edaab0a..6a25f1406 100644 --- a/server/auth.go +++ b/server/auth.go @@ -135,7 +135,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error { log.Warn(ctx, "Creating initial user", "user", username) - now := time.Now() caser := cases.Title(language.Und) initialUser := model.User{ ID: id.NewRandom(), @@ -144,7 +143,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password Email: "", NewPassword: password, IsAdmin: true, - LastLoginAt: &now, + LastLoginAt: new(time.Now()), } err := ds.User(ctx).Put(&initialUser) if err != nil { diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index 1b78bb93e..daa918d00 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -45,8 +45,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { return err } ar.UploadedImage = filename - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } @@ -65,8 +64,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { return err } ar.UploadedImage = "" - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go index ef971ee68..0aad09718 100644 --- a/server/nativeapi/queue_test.go +++ b/server/nativeapi/queue_test.go @@ -9,7 +9,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,7 +31,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("POST /queue", func() { It("saves the queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -50,7 +49,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("saves an empty queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))} + payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -63,7 +62,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (negative)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -75,7 +74,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (too large)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -97,7 +96,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -166,7 +165,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("PUT /queue", func() { It("updates the queue fields", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}} - payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))} + payload := updateQueuePayload{Current: new(2), Position: new(int64(20))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -184,7 +183,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates only ids", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -198,7 +197,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates ids and current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -213,7 +212,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when new ids invalidate current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -225,7 +224,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when current out of bounds", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} - payload := updateQueuePayload{Current: gg.P(3)} + payload := updateQueuePayload{Current: new(3)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -246,7 +245,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Position: gg.P(int64(10))} + payload := updateQueuePayload{Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 222d5ef1a..f43d75a26 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -89,7 +88,7 @@ var _ = Describe("encodeMediafileShare", func() { }) It("includes the share ID in the token", func() { - exp := P(time.Now().Add(time.Hour)) + exp := new(time.Now().Add(time.Hour)) s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} token := encodeMediafileShare(s, "mf-999") info, err := decodeStreamInfo(token) @@ -164,8 +163,7 @@ var _ = Describe("handleStream", func() { It("returns 410 when share has been set to expired", func() { shareRepo.ID = "share123" - expired := time.Now().Add(-time.Hour) - shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))} claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreatePublicToken(claims) diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index f3053c8af..a2db4a0af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "golang.org/x/net/context" @@ -136,7 +135,7 @@ var _ = Describe("sendResponse", func() { It("should return a fail response", func() { payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}} // An +Inf value will cause an error when marshalling to JSON - payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))} + payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))} q := r.URL.Query() q.Add("f", "json") r.URL.RawQuery = q.Encode() diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 5b9c4f3c9..817238aaf 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -256,8 +256,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { } response := newResponse() - child := childFromMediaFile(ctx, *mf) - response.Song = &child + response.Song = new(childFromMediaFile(ctx, *mf)) return response, nil } diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 8a9eb7110..0b18b1bc7 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -217,7 +216,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = P(mf.BirthTime) + child.Created = new(mf.BirthTime) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -346,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre child.CoverArt = al.CoverArtID().String() - child.Created = P(albumCreatedAt(al)) + child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) @@ -442,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = P(albumCreatedAt(album)) + dir.Created = new(albumCreatedAt(album)) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 27d1edb84..12c0dff56 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -294,7 +294,6 @@ var _ = Describe("MediaRetrievalController", func() { response, err := router.GetLyricsBySongId(r) Expect(err).ToNot(HaveOccurred()) - offset := int64(-100) compareResponses(response.LyricsList, responses.LyricsList{ StructuredLyrics: responses.StructuredLyrics{ { @@ -312,7 +311,7 @@ var _ = Describe("MediaRetrievalController", func() { Value: "You know the rules and so do I", }, }, - Offset: &offset, + Offset: new(int64(-100)), }, }, }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index a8c3da68c..7101f9f15 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -169,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 0b72fab19..3166df875 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -94,11 +93,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]Artist, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = Artist{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", } @@ -133,11 +131,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -158,11 +155,10 @@ var _ = Describe("Responses", func() { Context("with OpenSubsonic data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -211,12 +207,11 @@ var _ = Describe("Responses", func() { BeforeEach(func() { response.Directory = &Directory{Id: "1", Name: "N"} child := make([]Child, 2) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) child[0] = Child{ Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: &t, + Duration: 146, BitRate: 320, Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), } child[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, @@ -225,7 +220,7 @@ var _ = Describe("Responses", func() { BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ {Id: "1", Name: "artist1"}, @@ -246,7 +241,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } child[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.Directory.Child = child }) @@ -322,7 +317,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", Artists: []ArtistID3Ref{ @@ -342,7 +337,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } songs[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.AlbumWithSongsID3.AlbumID3 = album response.AlbumWithSongsID3.Song = songs @@ -804,7 +799,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { response.PlayQueueByIndex.Username = "user1" - response.PlayQueueByIndex.CurrentIndex = gg.P(0) + response.PlayQueueByIndex.CurrentIndex = new(0) response.PlayQueueByIndex.Position = 243 response.PlayQueueByIndex.Changed = time.Time{} response.PlayQueueByIndex.ChangedBy = "a_client" diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 9cc8d7097..a9ccfdca4 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -58,12 +58,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), ResourceIDs: strings.Join(ids, ","), } @@ -90,13 +88,11 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ ID: id, Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), } err = repo.(rest.Persistable).Update(id, share) diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 8b7406b60..acf8de3e8 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -46,8 +46,7 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorAuthorizationFail) } response := newResponse() - user := buildUserResponse(loggedUser) - response.User = &user + response.User = new(buildUserResponse(loggedUser)) return response, nil } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index cc05829f6..7c7dadbc4 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -7,7 +7,6 @@ import ( "time" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" ) func CreateMockUserRepo() *MockedUserRepo { @@ -84,7 +83,7 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastLoginAt = gg.P(time.Now()) + usr.LastLoginAt = new(time.Now()) return nil } } @@ -94,7 +93,7 @@ func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastAccessAt = gg.P(time.Now()) + usr.LastAccessAt = new(time.Now()) return nil } } diff --git a/utils/cache/cached_http_client.go b/utils/cache/cached_http_client.go index 94d33100b..4eed243dd 100644 --- a/utils/cache/cached_http_client.go +++ b/utils/cache/cached_http_client.go @@ -75,8 +75,7 @@ func (c *HTTPClient) serializeReq(req *http.Request) string { } if req.Body != nil { bodyData, _ := io.ReadAll(req.Body) - bodyStr := base64.StdEncoding.EncodeToString(bodyData) - data.Body = &bodyStr + data.Body = new(base64.StdEncoding.EncodeToString(bodyData)) } j, _ := json.Marshal(&data) return string(j) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index 2f0ff4764..eb3c99995 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -9,7 +9,6 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - . "github.com/navidrome/navidrome/utils/gg" ) type SimpleCache[K comparable, V any] interface { @@ -119,7 +118,7 @@ func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Dur func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() - c.evictionDeadline.Store(P(time.Now().Add(evictionTimeout))) + c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout))) } } diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go index 7b4786ed5..2a249f455 100644 --- a/utils/chrono/meter.go +++ b/utils/chrono/meter.go @@ -2,8 +2,6 @@ package chrono import ( "time" - - . "github.com/navidrome/navidrome/utils/gg" ) // Meter is a simple stopwatch @@ -13,7 +11,7 @@ type Meter struct { } func (m *Meter) Start() { - m.mark = P(time.Now()) + m.mark = new(time.Now()) } func (m *Meter) Stop() time.Duration { diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 208fe2952..674cacf20 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -1,11 +1,6 @@ // Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog package gg -// P returns a pointer to the input value -func P[T any](v T) *T { - return &v -} - // V returns the value of the input pointer, or a zero value if the input pointer is nil. func V[T any](p *T) T { if p == nil { diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index 1d6dff484..a2dd8154f 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -16,22 +16,9 @@ func TestGG(t *testing.T) { } var _ = Describe("GG", func() { - Describe("P", func() { - It("returns a pointer to the input value", func() { - v := 123 - Expect(gg.P(123)).To(Equal(&v)) - }) - - It("returns nil if the input value is zero", func() { - v := 0 - Expect(gg.P(0)).To(Equal(&v)) - }) - }) - Describe("V", func() { It("returns the value of the input pointer", func() { - v := 123 - Expect(gg.V(&v)).To(Equal(123)) + Expect(gg.V(new(123))).To(Equal(123)) }) It("returns a zero value if the input pointer is nil", func() { diff --git a/utils/req/req.go b/utils/req/req.go index 6b6135e1a..2757fc3f5 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -38,8 +38,7 @@ func (r *Values) String(param string) (string, error) { func (r *Values) StringPtr(param string) *string { var v *string if _, exists := r.URL.Query()[param]; exists { - s := r.URL.Query().Get(param) - v = &s + v = new(r.URL.Query().Get(param)) } return v } @@ -48,8 +47,7 @@ func (r *Values) BoolPtr(param string) *bool { var v *bool if _, exists := r.URL.Query()[param]; exists { s := r.URL.Query().Get(param) - b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/") - v = &b + v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")) } return v } From 03ac02d9648adcccf81236241e18c93016343e51 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 19 May 2026 18:23:36 -0300 Subject: [PATCH 37/60] refactor: more warnings clean up Signed-off-by: Deluan --- adapters/deezer/client.go | 2 +- cmd/user.go | 4 ++-- conf/dir.go | 2 +- core/auth/auth_test.go | 3 +-- core/ffmpeg/ffmpeg_test.go | 2 +- core/playback/mpv/track.go | 2 +- model/criteria/export_test.go | 2 -- model/criteria/operators.go | 6 ------ persistence/radio_repository_test.go | 6 +----- plugins/manager_cache_test.go | 3 ++- scanner/metadata_old/metadata_internal_test.go | 2 +- utils/slice/slice_test.go | 2 +- 12 files changed, 12 insertions(+), 24 deletions(-) diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index 31150c673..d51f65dd9 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -1,7 +1,7 @@ package deezer import ( - bytes "bytes" + "bytes" "context" "encoding/json" "errors" diff --git a/cmd/user.go b/cmd/user.go index 1abf157b7..885edb53b 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -123,14 +123,14 @@ func promptPassword() string { for { fmt.Print("Enter new password (press enter with no password to cancel): ") // This cast is necessary for some platforms - password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert + password, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert if err != nil { log.Fatal("Error getting password", err) } fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert + confirmation, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert if err != nil { log.Fatal("Error getting password confirmation", err) diff --git a/conf/dir.go b/conf/dir.go index 8ed43039b..abbe72b77 100644 --- a/conf/dir.go +++ b/conf/dir.go @@ -57,7 +57,7 @@ func (d *Dir) MustPath() string { // GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) // prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { //nolint:govet +func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer return fmt.Sprintf("%q", d.path) } diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 761dd205c..3a3585e53 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -21,8 +21,7 @@ func TestAuth(t *testing.T) { } const ( - testJWTSecret = "not so secret" - oneDay = 24 * time.Hour + oneDay = 24 * time.Hour ) var _ = BeforeSuite(func() { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 562fd9100..2e2895738 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "runtime" "strings" - sync "sync" + "sync" "testing" "time" diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go index 14170efd4..1038b9190 100644 --- a/core/playback/mpv/track.go +++ b/core/playback/mpv/track.go @@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool { func waitForSocket(path string, timeout time.Duration, pause time.Duration) error { start := time.Now() end := start.Add(timeout) - var retries int = 0 + var retries = 0 for { fileInfo, err := os.Stat(path) diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go index 9f3f3922b..e2109aa1a 100644 --- a/model/criteria/export_test.go +++ b/model/criteria/export_test.go @@ -1,5 +1,3 @@ package criteria -var StartOfPeriod = startOfPeriod - type UnmarshalConjunctionType = unmarshalConjunctionType diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 3ddd77f8b..14a02ff4b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,7 +1,5 @@ package criteria -import "time" - // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { ChildPlaylistIds() []string @@ -142,10 +140,6 @@ func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { func (nitl NotInTheLast) fields() map[string]any { return nitl } -func startOfPeriod(numDays int64, from time.Time) string { - return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") -} - type InPlaylist map[string]any func (ipl InPlaylist) MarshalJSON() ([]byte, error) { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 828af8043..05628ca41 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -11,10 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var ( - NewId string = "123-456-789" -) - var _ = Describe("RadioRepository", func() { var repo model.RadioRepository @@ -139,7 +135,7 @@ var _ = Describe("RadioRepository", func() { It("returns an existing item", func() { res, err := repo.Get(radioWithHomePage.ID) - Expect(err).To((BeNil())) + Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) diff --git a/plugins/manager_cache_test.go b/plugins/manager_cache_test.go index f985fcd84..3dbfa45ee 100644 --- a/plugins/manager_cache_test.go +++ b/plugins/manager_cache_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strconv" "time" "github.com/dustin/go-humanize" @@ -143,7 +144,7 @@ var _ = Describe("purgeCacheBySize", func() { // Create 5 files, 1MiB each (total 5MiB) for i := range 5 { - path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin")) + path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin")) createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) } diff --git a/scanner/metadata_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go index 2d21e07eb..aff1ede9c 100644 --- a/scanner/metadata_old/metadata_internal_test.go +++ b/scanner/metadata_old/metadata_internal_test.go @@ -93,7 +93,7 @@ var _ = Describe("Tags", func() { var t *Tags BeforeEach(func() { t = &Tags{Tags: map[string][]string{ - "fbpm": []string{"141.7"}, + "fbpm": {"141.7"}, }} }) diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 65e5f0934..64cb89d53 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -134,7 +134,7 @@ var _ = Describe("Slice Utils", func() { count := 0 file, _ := os.Open(path) defer file.Close() - for _ = range slice.LinesFrom(file) { + for range slice.LinesFrom(file) { count++ } Expect(count).To(Equal(expected)) From e75ab3b0377d80691c86f547d60c778321a659aa Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 20 May 2026 19:33:42 -0300 Subject: [PATCH 38/60] fix(cli): restore int cast for syscall.Stdin on Windows On Windows, syscall.Stdin is syscall.Handle (uintptr), not int, so term.ReadPassword requires an explicit int() cast to compile. --- cmd/user.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index 885edb53b..1abf157b7 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -123,14 +123,14 @@ func promptPassword() string { for { fmt.Print("Enter new password (press enter with no password to cancel): ") // This cast is necessary for some platforms - password, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert + password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password", err) } fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert + confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password confirmation", err) From 74185dc6d1cf5676e90805929285302429aad009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 22 May 2026 18:00:13 -0300 Subject: [PATCH 39/60] fix(smartplaylists): optimize smart playlist performance for role and tag criteria (#5515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): optimize smart playlist role queries for large criteria (#5511) Role-based smart playlist criteria (artist, composer, etc.) now query the indexed media_file_artists join table instead of parsing JSON via json_tree() on every row. Multiple conditions for the same role within an OR group are merged into a single EXISTS subquery (batched at 200 to stay under SQLite's expression tree depth limit). A composite index (media_file_id, role) replaces the now-redundant single-column (media_file_id) index on media_file_artists. Benchmark (40k tracks, 500 patterns, 3 artists/track): - Merged join-table: 15ms (9.3x faster) - Merged json_tree: 30ms (4.6x faster) - Unmerged baseline: 137ms * refactor: simplify role condition SQL generation and benchmark Extract shared roleCondSQL/roleExistsSQL helpers to deduplicate the EXISTS template between roleCond and roleCondGroup. Use slices.Chunk for batching per project convention. Extract runBenchQuery helper to eliminate triplicated benchmark execution loop. * chore: raise roleCondBatchSize to 350 The empirical SQLite limit is 496 conditions per merged EXISTS subquery. Raising from 200 to 350 reduces the number of batches (e.g. 500 patterns now splits into 2 batches instead of 3). * fix(server): apply OR-merge optimization to tag conditions too Generalize mergeRoleConds into mergeJsonConds to also collapse multiple tag conditions for the same tag (e.g. genre) within OR groups. This gives the same ~5x speedup for tag-heavy smart playlists as the role optimization gives for artist-heavy ones. * refactor: benchmark uses real criteria pipeline instead of hand-built SQL The "Current" sub-benchmark now builds criteria.Criteria expressions and runs them through the actual newSmartPlaylistCriteria → Where() → ToSql() pipeline, validating the real production code path. The baseline still uses hand-built SQL representing the old json_tree approach. * fix: stabilize merged group ordering and close rows before error check Sort group keys in mergeJsonConds so the merged additions have deterministic order across runs, improving SQLite statement cache reuse. Move rows.Close() before rows.Err() in benchmark helper. --- ...add_media_file_artists_composite_index.sql | 9 + persistence/criteria_sql.go | 170 ++++++++++++- persistence/criteria_sql_benchmark_test.go | 236 ++++++++++++++++++ persistence/criteria_sql_test.go | 156 +++++++++++- 4 files changed, 558 insertions(+), 13 deletions(-) create mode 100644 db/migrations/20260520211813_add_media_file_artists_composite_index.sql create mode 100644 persistence/criteria_sql_benchmark_test.go diff --git a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql new file mode 100644 index 000000000..f65050d80 --- /dev/null +++ b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role + ON media_file_artists (media_file_id, role); +DROP INDEX IF EXISTS media_file_artists_media_file_id; + +-- +goose Down +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id + ON media_file_artists (media_file_id); +DROP INDEX IF EXISTS media_file_artists_media_file_id_role; diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index a1bae3170..37e4ae340 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -3,7 +3,9 @@ package persistence import ( "errors" "fmt" + "maps" "reflect" + "slices" "strconv" "strings" "time" @@ -147,7 +149,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } or = append(or, cond) } - return or, nil + return mergeJsonConds(or), nil case criteria.Is: return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { return squirrel.Eq(fields) @@ -381,17 +383,173 @@ type roleCond struct { func (e roleCond) ToSql() (string, []any, error) { var cond string var args []any - var err error if e.cond != nil { - cond, args, err = e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + innerSQL, innerArgs, err := roleCondSQL(e.cond) + if err != nil { + return "", nil, err + } + cond = roleExistsSQL(innerSQL) + args = append([]any{e.role}, innerArgs...) } else { - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)" + args = []any{e.role} } if e.not { cond = "not " + cond } - return cond, args, err + return cond, args, nil +} + +// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name. +func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) { + sql, args, err := cond.ToSql() + if err != nil { + return "", nil, err + } + return strings.ReplaceAll(sql, "value", "artist.name"), args, nil +} + +// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery. +func roleExistsSQL(innerCond string) string { + return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+ + "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond) +} + +// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery +// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper +// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum. +const jsonCondBatchSize = 350 + +// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same +// field within an OR group into batched EXISTS subqueries with the conditions ORed inside. +// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically +// improving performance for smart playlists with many patterns. +func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { + type condEntry struct { + index int + cond squirrel.Sqlizer + } + type group struct { + entries []condEntry + isRole bool + numeric bool + tag string + } + groups := make(map[string]*group) + for i, s := range or { + switch c := s.(type) { + case roleCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["role:"+c.role] + if !exists { + g = &group{isRole: true} + groups["role:"+c.role] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + case tagCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["tag:"+c.tag] + if !exists { + g = &group{tag: c.tag, numeric: c.numeric} + groups["tag:"+c.tag] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + } + } + + merged := false + remove := make(map[int]bool) + var additions []squirrel.Sqlizer + for _, key := range slices.Sorted(maps.Keys(groups)) { + g := groups[key] + if len(g.entries) < 2 { + continue + } + merged = true + for _, e := range g.entries { + remove[e.index] = true + } + conds := make([]squirrel.Sqlizer, len(g.entries)) + for i, e := range g.entries { + conds[i] = e.cond + } + if g.isRole { + role := key[len("role:"):] + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, roleCondGroup{role: role, conds: batch}) + } + } else { + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch}) + } + } + } + + if !merged { + return or + } + + result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions)) + for i, s := range or { + if !remove[i] { + result = append(result, s) + } + } + result = append(result, additions...) + return result +} + +// roleCondGroup represents multiple role conditions for the same role, merged into +// a single EXISTS subquery for performance. +type roleCondGroup struct { + role string + conds []squirrel.Sqlizer +} + +func (g roleCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + allArgs := []any{g.role} + for _, c := range g.conds { + part, args, err := roleCondSQL(c) + if err != nil { + return "", nil, err + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") + return cond, allArgs, nil +} + +// tagCondGroup represents multiple tag conditions for the same tag, merged into +// a single EXISTS subquery for performance. +type tagCondGroup struct { + tag string + numeric bool + conds []squirrel.Sqlizer +} + +func (g tagCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + var allArgs []any + for _, c := range g.conds { + part, args, err := c.ToSql() + if err != nil { + return "", nil, err + } + if g.numeric { + part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)") + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", + g.tag, strings.Join(innerParts, " OR ")) + return cond, allArgs, nil } func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go new file mode 100644 index 000000000..d901e9eda --- /dev/null +++ b/persistence/criteria_sql_benchmark_test.go @@ -0,0 +1,236 @@ +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/pocketbase/dbx" +) + +const ( + benchNumArtists = 1_000 + benchNumTracks = 40_000 + benchNumPatterns = 500 + benchArtistsPerTrack = 3 +) + +// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance +// between the current implementation (merged join-table via criteria pipeline) and +// the old baseline (unmerged json_tree subqueries). +func BenchmarkSmartPlaylistRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "contains artist" patterns in an OR group + anyExprs := make(criteria.Any, benchNumPatterns) + for i := range benchNumPatterns { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) { + benchmarkUnmergedJSONTree(b, ctx) + }) +} + +// benchmarkCriteriaPipeline runs the criteria through the actual production code path: +// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. +func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { + b.Helper() + + cSQL := newSmartPlaylistCriteria(expr) + + // Build the full query matching buildSmartPlaylistQuery + addCriteria + sq := squirrel.Select("media_file.id").From("media_file") + cond, err := cSQL.Where() + if err != nil { + b.Fatal(err) + } + sq = sq.Where(cond) + if expr.Limit > 0 { + sq = sq.Limit(uint64(expr.Limit)) + } + if order := cSQL.OrderBy(); order != "" { + sq = sq.OrderBy(order) + } + + query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + b.Fatal(err) + } + + runBenchQuery(b, ctx, query, args) +} + +// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS +// subqueries (the pre-optimization baseline). +func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" OR ") + } + sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)") + args = append(args, fmt.Sprintf("%%Artist %04d%%", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + +func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) { + b.Helper() + sqlDB := db.Db() + b.ResetTimer() + for range b.N { + rows, err := sqlDB.QueryContext(ctx, query, args...) + if err != nil { + b.Fatal(err) + } + for rows.Next() { + var id string + _ = rows.Scan(&id) + } + rows.Close() + if err := rows.Err(); err != nil { + b.Fatal(err) + } + } +} + +func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) { + b.Helper() + + sqlDB := db.Db() + + ur := NewUserRepository(ctx, conn) + if err := ur.Put(&user); err != nil { + b.Fatal(err) + } + if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil { + b.Fatal(err) + } + + tx, err := sqlDB.Begin() + if err != nil { + b.Fatal(err) + } + + // Create artists + artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)") + if err != nil { + b.Fatal(err) + } + for i := range benchNumArtists { + if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil { + b.Fatal(err) + } + } + artistStmt.Close() + + // Ensure folder exists + folderID := "bench-folder" + if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil { + b.Fatal(err) + } + + // Create media files with participants JSON, cycling through artists + mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id, + duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + b.Fatal(err) + } + + // Populate media_file_artists join table + mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)") + if err != nil { + b.Fatal(err) + } + + for i := range benchNumTracks { + trackID := fmt.Sprintf("track-%05d", i) + + // Assign benchArtistsPerTrack artists to each track, cycling through the pool + artistEntries := make([]map[string]string, benchArtistsPerTrack) + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistEntries[a] = map[string]string{ + "id": fmt.Sprintf("artist-%04d", artistIdx), + "name": fmt.Sprintf("Artist %04d", artistIdx), + } + } + primaryArtistIdx := i % benchNumArtists + primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx) + primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx) + + participants := map[string][]map[string]string{"artist": artistEntries} + participantsJSON, _ := json.Marshal(participants) + + if _, err := mfStmt.Exec( + trackID, + fmt.Sprintf("music/%s.mp3", trackID), + fmt.Sprintf("Track %05d", i), + "Bench Album", + primaryArtistName, + primaryArtistID, + "bench-album", + 180, 2024, 5000000, "mp3", + "{}", + string(participantsJSON), + "[]", + 1, folderID, trackID, "mp3", + ); err != nil { + b.Fatal(err) + } + + // Insert all artist associations into the join table + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistID := fmt.Sprintf("artist-%04d", artistIdx) + if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil { + b.Fatal(err) + } + } + } + mfStmt.Close() + mfaStmt.Close() + + if err := tx.Commit(); err != nil { + b.Fatal(err) + } + + b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns", + benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns) +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index ae2695a4d..5c8909e1c 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -1,6 +1,8 @@ package persistence import ( + "fmt" + "strings" "time" "github.com/navidrome/navidrome/model" @@ -56,9 +58,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), - Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), - Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"), // ReplayGain fields Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), @@ -70,9 +72,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isMissing — roles Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, - "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, - "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), // isPresent — tags Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), @@ -80,9 +82,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isPresent — roles Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, - "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, - "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), ) Describe("playlist permissions", func() { @@ -204,6 +206,146 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) + Describe("JSON condition merging", func() { + It("merges multiple role conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"artist": "Pink Floyd"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%")) + }) + + It("does not merge role conditions from different roles", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("mfa.role = ?")) + // Two separate EXISTS since roles differ + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated role conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two separate "not exists" since they are negated + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large groups to avoid SQLite expression tree depth limit", func() { + // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups + anyExprs := make(criteria.Any, jsonCondBatchSize+1) + for i := range anyExprs { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + // First batch has jsonCondBatchSize patterns, second has 1 => total args: + // 2 roles + (jsonCondBatchSize + 1) patterns + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) + + It("merges role conditions while preserving non-role conditions", func() { + expr := criteria.Any{ + criteria.Contains{"title": "Love"}, + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.title LIKE ?")) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges multiple tag conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + criteria.Contains{"genre": "Punk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%")) + }) + + It("does not merge tag conditions from different tags", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"mood": "Happy"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated tag conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("merges role and tag conditions independently", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two merged EXISTS: one for roles, one for tags + Expect(strings.Count(sql, "exists")).To(Equal(2)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name + }) + }) + Describe("joins", func() { It("excludes sort-only joins from expression joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} From 8897ec918e3a3b540c95b1693367b40eaf1be280 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 22 May 2026 18:42:17 -0300 Subject: [PATCH 40/60] fix(subsonic): mark AlbumID3 songCount and created as required The Subsonic API spec defines songCount and created as required attributes on AlbumID3, but they were tagged with omitempty in our response struct, allowing them to be silently dropped from responses (e.g. when songCount was 0). Created was also a *time.Time, which compounded the omitempty behavior. Remove omitempty from both fields and change Created from *time.Time to time.Time so they are always serialized, matching the spec contract that clients rely on. The buildAlbumID3 helper and its tests are updated for the non-pointer Created, and the AlbumWithSongsID3 snapshots are regenerated to include the now-always-present fields. --- server/subsonic/helpers.go | 2 +- server/subsonic/helpers_test.go | 13 +++++-------- ...s AlbumWithSongsID3 with data should match .JSON | 2 ++ ...es AlbumWithSongsID3 with data should match .XML | 2 +- ...lbumWithSongsID3 without data should match .JSON | 4 +++- ...AlbumWithSongsID3 without data should match .XML | 2 +- ...ID3 without data should match OpenSubsonic .JSON | 2 ++ ...sID3 without data should match OpenSubsonic .XML | 2 +- server/subsonic/responses/responses.go | 4 ++-- 9 files changed, 18 insertions(+), 15 deletions(-) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 0b18b1bc7..e4c39e373 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -441,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = new(albumCreatedAt(album)) + dir.Created = albumCreatedAt(album) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index abf6116f3..2ae6eb28e 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -576,30 +576,27 @@ var _ = Describe("helpers", func() { t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) al := model.Album{ID: "a1", Name: "A", CreatedAt: t} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(t)) + Expect(dir.Created).To(Equal(t)) }) It("falls back to UpdatedAt when CreatedAt is zero", func() { updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(updated)) + Expect(dir.Created).To(Equal(updated)) }) It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(imported)) + Expect(dir.Created).To(Equal(imported)) }) - It("never leaves Created nil even when all timestamps are zero", func() { + It("leaves Created as zero time when all timestamps are zero", func() { al := model.Album{ID: "a4", Name: "A"} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) + Expect(dir.Created.IsZero()).To(BeTrue()) }) }) diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index a07914344..bff0bd20c 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -8,7 +8,9 @@ "id": "1", "name": "album", "artist": "artist", + "songCount": 0, "duration": 292, + "created": "0001-01-01T00:00:00Z", "genre": "rock", "userRating": 4, "genres": [ diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index 6c96391bc..16a7748aa 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -1,5 +1,5 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON index 14e96939e..030502618 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -7,6 +7,8 @@ "album": { "id": "", "name": "", - "duration": 0 + "songCount": 0, + "duration": 0, + "created": "0001-01-01T00:00:00Z" } } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML index 868265347..0d3882080 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON index 446368fa5..d3964663b 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON @@ -7,7 +7,9 @@ "album": { "id": "", "name": "", + "songCount": 0, "duration": 0, + "created": "0001-01-01T00:00:00Z", "userRating": 0, "genres": [], "musicBrainzId": "", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML index 868265347..0d3882080 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index 4165669df..dcb458932 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -251,10 +251,10 @@ type AlbumID3 struct { Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` - SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` Duration int32 `xml:"duration,attr" json:"duration"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` - Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` + Created time.Time `xml:"created,attr" json:"created"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` From 0265ff3ad18b7a83c2ea20c62bad69ccb737aee8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 22 May 2026 22:22:23 -0300 Subject: [PATCH 41/60] fix(ui): report playback when restarting current track via prev The navidrome-music-player library rewinds the current track by directly mutating audio.currentTime when the Previous button is pressed with restartCurrentOnPrev (and other programmatic seek paths like singleLoop reset and mediaSession seek). It does not invoke its onAudioSeeked callback for these, so the play tracker never learned about the new position until the next ~30s heartbeat. Replace the React onAudioSeeked prop with a native HTML5 'seeked' event listener on the audio element, which fires for every seek (programmatic or via slider release). The handler is debounced by 250ms so the burst of seeks emitted while dragging the progress bar coalesces into a single reportPlayback call at the final position. --- ui/src/audioplayer/Player.jsx | 42 ++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index e2070deea..c3d795b26 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -272,18 +272,6 @@ const Player = () => { } }, []) - const onAudioSeeked = useCallback( - (info) => { - if (!info.isRadio && currentTrackId) { - const posMs = Math.floor(info.currentTime * 1000) - lastPositionMsRef.current = posMs - const state = audioInstance?.paused ? 'paused' : 'playing' - subsonic.reportPlayback(currentTrackId, posMs, state) - } - }, - [currentTrackId, audioInstance], - ) - const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume (volume) => dispatch(setVolume(Math.sqrt(volume))), @@ -436,6 +424,35 @@ const Player = () => { } }, [isMobilePlayer, audioInstance]) + // Report every seek (including programmatic ones the library does not surface + // via onAudioSeeked, e.g. restartCurrentOnPrev). Debounce coalesces drag + // bursts into one report at the final position. + useEffect(() => { + if (!audioInstance) return + let timer = null + const flush = () => { + timer = null + if ( + !currentTrackIdRef.current || + playerStateRef.current?.current?.isRadio + ) { + return + } + const posMs = Math.floor((audioInstance.currentTime || 0) * 1000) + const state = audioInstance.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackIdRef.current, posMs, state) + } + const handleSeeked = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(flush, 250) + } + audioInstance.addEventListener('seeked', handleSeeked) + return () => { + if (timer) clearTimeout(timer) + audioInstance.removeEventListener('seeked', handleSeeked) + } + }, [audioInstance]) + return ( { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} - onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} From edffca24b11b5378876cbcfed62acbbed8d95d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 23 May 2026 12:16:15 -0300 Subject: [PATCH 42/60] fix(lastfm): require signed state token on link callback (#5521) * fix(lastfm): require signed state token on link callback The Last.fm OAuth callback at /api/lastfm/link/callback trusted a raw \`uid\` query parameter and wrote the resulting Last.fm session key under that user with no ownership check. Any authenticated user who learned a victim's internal user ID (e.g. from playlist ownerId) could redirect the victim's scrobbles to an attacker-controlled Last.fm account by calling the callback directly with the victim's uid and a Last.fm token obtained for their own account. The callback cannot use the regular auth middleware because it is reached via a browser redirect from Last.fm, which cannot carry a JWT header. Instead, GET /api/lastfm/link (authenticated) now also returns a short- lived (5 min) HMAC-signed link token bound to the requesting user, with a dedicated "lastfm-link" scope claim. The callback verifies the signature, scope and expiry before deriving the user ID from the token; the \`uid\` query value is no longer trusted as a user identifier. The UI fetches this token at link-flow start and passes it in place of the raw user ID. Reuses the existing HS256 secret via auth.EncodeToken/DecodeAndVerifyToken so no new key management is introduced. * fix(ui): keep Last.fm popup tied to user gesture for Safari Opening the Last.fm OAuth tab after an awaited fetch causes the popup to be blocked on Safari and on Firefox with strict popup blocking enabled, because the browser's transient-activation window has already elapsed by the time window.open is reached. Linking became impossible on those browsers in the previous commit. Move the click handler up to the parent component and open a placeholder about:blank tab synchronously from the click; the linkToken fetch then runs in parallel and we redirect the existing tab to Last.fm's auth URL once it resolves. The user gesture stays attached to the window.open call, so popup blockers no longer fire. The polling/progress UI is unchanged; it now receives the openedTab ref from the parent instead of owning it. * fix(lastfm): require exp claim on link tokens jwtauth.VerifyToken treats a JWT without an exp claim as non-expiring, so verifyLinkToken used to delegate expiry handling entirely. A future regression in createLinkToken that dropped the exp field would silently turn link tokens into permanent bearer credentials. Assert presence of an exp claim explicitly and add a regression test covering the missing-exp case. Also tightens the wrong-scope test to use a freshly-minted token with all claims present except the scope, instead of relying on auth.CreatePublicToken which happens to also be missing exp. * style(lastfm): simplify comments in link token code Trim doc comments on createLinkToken/verifyLinkToken/callback/startLink to the load-bearing lines: keep the non-obvious 'jwtauth treats missing exp as non-expiring' note and the popup-blocker hint, drop the rest since the function names already describe behavior. Signed-off-by: Deluan * fix(lastfm): address review feedback on link token PR - Wrap openInNewTab in a try/catch in startLink: openInNewTab calls win.focus() unconditionally, so if the browser blocks the popup (window.open returns null) it throws a TypeError synchronously, before the catch() on the link-token fetch is attached. The throw used to escape the click handler, leaving the UI without a notification. Now the failure is surfaced as lastfmLinkFailure and the toggle stays usable. - Rename the link-token "subject" rejection message to "user ID" since the claim is uid, not the JWT sub field. --------- Signed-off-by: Deluan --- adapters/lastfm/auth_router.go | 15 +- adapters/lastfm/auth_router_test.go | 218 +++++++++++++++++++++++ adapters/lastfm/link_token.go | 50 ++++++ ui/src/personal/LastfmScrobbleToggle.jsx | 51 ++++-- 4 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 adapters/lastfm/auth_router_test.go create mode 100644 adapters/lastfm/link_token.go diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 162ae9037..499863e28 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { return } resp["status"] = key != "" + linkToken, err := createLinkToken(u.ID) + if err != nil { + log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err) + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + resp["linkToken"] = linkToken _ = rest.RespondWithJSON(w, http.StatusOK, resp) } @@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") return } - uid, err := p.String("uid") + linkToken, err := p.String("uid") if err != nil { _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") return } + uid, err := verifyLinkToken(linkToken) + if err != nil { + log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err) + _ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token") + return + } // Need to add user to context, as this is a non-authenticated endpoint, so it does not // automatically contain any user info diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go new file mode 100644 index 000000000..4cbbd4298 --- /dev/null +++ b/adapters/lastfm/auth_router_test.go @@ -0,0 +1,218 @@ +package lastfm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("auth_router", func() { + var ( + ds *tests.MockDataStore + userProps *tests.MockedUserPropsRepo + httpClient *tests.FakeHttpClient + router *Router + ) + + const ( + victimID = "victim-user-id" + attackerID = "attacker-user-id" + ) + + BeforeEach(func() { + userProps = &tests.MockedUserPropsRepo{} + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedUserProps: userProps, + } + auth.Init(ds) + + httpClient = &tests.FakeHttpClient{} + router = &Router{ + ds: ds, + apiKey: "API_KEY", + secret: "SECRET", + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + router.client = newClient(router.apiKey, router.secret, httpClient) + router.Handler = router.routes() + }) + + storedSessionKey := func(userID string) string { + key, _ := userProps.Get(userID, sessionKeyProperty) + return key + } + + stubGetSessionOK := func(sessionKey string) { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)), + StatusCode: 200, + } + } + + Describe("getLinkStatus", func() { + It("includes a signed linkToken for the authenticated user", func() { + req := httptest.NewRequest(http.MethodGet, "/link", nil) + ctx := request.WithUser(req.Context(), model.User{ID: victimID}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + router.getLinkStatus(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var body map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) + Expect(body["apiKey"]).To(Equal("API_KEY")) + Expect(body["status"]).To(Equal(false)) + token, ok := body["linkToken"].(string) + Expect(ok).To(BeTrue()) + Expect(token).ToNot(BeEmpty()) + + verified, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(verified).To(Equal(victimID)) + }) + }) + + Describe("callback", func() { + It("stores the session key under the user encoded in the signed token", func() { + stubGetSessionOK("LEGIT_SESSION") + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION")) + }) + + It("rejects a raw (unsigned) uid value", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects an expired link token", func() { + expiredToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + "exp": time.Now().Add(-1 * time.Minute).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() { + sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"}) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(attackerID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("writes only under the user encoded in the token, regardless of query manipulation", func() { + // An attacker holds a legitimate link token for their own account. + // They attempt to call the callback hoping to overwrite the victim's + // session key — but the handler must derive the user ID from the + // signed token, not from any other input. + stubGetSessionOK("ATTACKER_SESSION") + attackerToken, err := createLinkToken(attackerID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION")) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + }) + + It("returns 400 when uid is missing", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when token is missing", func() { + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("link token helpers", func() { + It("round-trips a freshly issued token", func() { + token, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + uid, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(uid).To(Equal(victimID)) + }) + + It("rejects garbage", func() { + _, err := verifyLinkToken("not-a-jwt") + Expect(err).To(HaveOccurred()) + }) + + It("rejects a token whose scope claim is wrong", func() { + wrongScopeToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": "some-other-scope", + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(wrongScopeToken) + Expect(err).To(MatchError("invalid link token scope")) + }) + + It("rejects a scoped token that has no expiration", func() { + nonExpiringToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(nonExpiringToken) + Expect(err).To(MatchError("link token missing expiration")) + }) + }) +}) diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go new file mode 100644 index 000000000..fd8ceb3c9 --- /dev/null +++ b/adapters/lastfm/link_token.go @@ -0,0 +1,50 @@ +package lastfm + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/core/auth" +) + +const ( + linkTokenScope = "lastfm-link" + linkTokenTTL = 5 * time.Minute +) + +// createLinkToken issues a signed token binding the Last.fm callback to the +// user who initiated the OAuth flow. It travels back through Last.fm via the +// `cb` URL in place of the previously-trusted raw `uid` query parameter. +func createLinkToken(userID string) (string, error) { + claims := map[string]any{ + "uid": userID, + "scope": linkTokenScope, + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + } + return auth.EncodeToken(claims) +} + +// verifyLinkToken validates a signed link token and returns the encoded user ID. +// It enforces both the signature/expiry (via the underlying JWT verifier) and a +// dedicated scope claim, preventing tokens minted for other purposes (e.g. a +// regular session JWT) from being accepted here. +func verifyLinkToken(tokenStr string) (string, error) { + token, err := auth.DecodeAndVerifyToken(tokenStr) + if err != nil { + return "", err + } + // jwtauth treats a token without `exp` as non-expiring; require it + // explicitly so an accidental regression cannot mint permanent tokens. + if exp, ok := token.Expiration(); !ok || exp.IsZero() { + return "", errors.New("link token missing expiration") + } + var scope string + if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope { + return "", errors.New("invalid link token scope") + } + var uid string + if err := token.Get("uid", &uid); err != nil || uid == "" { + return "", errors.New("invalid link token user ID") + } + return uid, nil +} diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index 67018d2bb..c8e07328f 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -13,21 +13,10 @@ import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' const Progress = (props) => { - const { setLinked, setCheckingLink, apiKey } = props + const { setLinked, setCheckingLink, openedTab } = props const notify = useNotify() let linkCheckDelay = 2000 let linkChecks = 30 - const openedTab = useRef() - - useEffect(() => { - const callbackEndpoint = baseUrl( - `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, - ) - const callbackUrl = `${window.location.origin}${callbackEndpoint}` - openedTab.current = openInNewTab( - `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`, - ) - }, [apiKey]) const endChecking = (success) => { linkCheckDelay = null @@ -76,6 +65,7 @@ export const LastfmScrobbleToggle = (props) => { const [linked, setLinked] = useState(null) const [checkingLink, setCheckingLink] = useState(false) const [apiKey, setApiKey] = useState(false) + const openedTab = useRef() useEffect(() => { httpClient('/api/lastfm/link') @@ -88,9 +78,42 @@ export const LastfmScrobbleToggle = (props) => { }) }, [setLinked, setApiKey]) + const startLink = () => { + // Open the tab synchronously so popup blockers attribute it to the click. + let tab + try { + tab = openInNewTab('about:blank') + } catch { + notify('message.lastfmLinkFailure', 'warning') + return + } + openedTab.current = tab + setCheckingLink(true) + httpClient('/api/lastfm/link') + .then((response) => { + const linkToken = response.json.linkToken + if (!linkToken) { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + return + } + const callbackEndpoint = baseUrl( + `/api/lastfm/link/callback?uid=${encodeURIComponent(linkToken)}`, + ) + const callbackUrl = `${window.location.origin}${callbackEndpoint}` + tab.location.href = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}` + }) + .catch(() => { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + }) + } + const toggleScrobble = () => { if (!linked) { - setCheckingLink(true) + startLink() } else { httpClient('/api/lastfm/link', { method: 'DELETE' }) .then(() => { @@ -121,7 +144,7 @@ export const LastfmScrobbleToggle = (props) => { )} {!apiKey && ( From 55a31f30b3725a17b8a25b13c426dbc41d85b258 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 23 May 2026 18:20:18 -0400 Subject: [PATCH 43/60] fix(scanner): respect tag split config when multiple frames map to the same tag (#5193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: split tag values from multiple sources individually When a file has multiple tag frames mapping to the same logical tag (e.g. both TXXX:MOOD and TMOO), TagLib merges them into one key with multiple values. SplitTagValue had a len(values) != 1 guard that skipped splitting entirely in this case, leaving comma-separated values unsplit. Change SplitTagValue to split each value individually regardless of input count. Empty values are filtered during splitting. Fixes #5065 * test: cover SplitTagValue with multi-frame regression cases Add tests pinning the behavior fixed by SplitTagValue iterating over each input value. The previous len(values) != 1 short-circuit silently skipped splitting whenever TagLib merged multiple ID3v2 frames into the same property (e.g. TMOO + TXXX:MOOD for mood, or duplicate TIPL entries for composer), as reported in #5065. Three layers of coverage: - model/tag_mappings_test.go: direct unit tests on TagConf.SplitTagValue covering single/multi-value input, case-insensitive separators, missing SplitRx, empty input, and the empty-strings-passed-through contract that the downstream metadata pipeline relies on. - model/metadata/metadata_test.go: end-to-end check that a "mood" tag surfaced as two raw values (the exact shape from the bug report) is split, trimmed, and deduplicated to the expected three moods. - model/metadata/map_participants_test.go: parallel multi-value case for the COMPOSER tag, ensuring the same fix also corrects multi-frame role parsing. All three new specs fail on the pre-fix code and pass on the patched SplitTagValue. --------- Co-authored-by: Deluan Quintão --- model/metadata/map_participants_test.go | 20 ++++++++ model/metadata/metadata_test.go | 15 ++++++ model/tag_mappings.go | 24 +++++----- model/tag_mappings_test.go | 64 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 model/tag_mappings_test.go diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index 71cb9c1f2..5ee802ced 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -684,6 +684,26 @@ var _ = Describe("Participants", func() { Expect(composers[2].Name).To(Equal("The Album Artist")) }) }) + + // Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when + // multiple frames map to the same role tag (e.g. TIPL producer entries), + // the configured split separator must still apply to each value. + When("the tag has multiple values", func() { + It("should split each value individually", func() { + mf = toMediaFile(model.RawTags{ + "COMPOSER": {"John Doe/Jane Doe", "Someone Else"}, + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) + composers := participants[model.RoleComposer] + Expect(composers).To(ConsistOf( + HaveField("Name", "John Doe"), + HaveField("Name", "Jane Doe"), + HaveField("Name", "Someone Else"), + )) + }) + }) }) Describe("MBID tags", func() { diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index c9acdbce9..350731b89 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -129,6 +129,21 @@ var _ = Describe("Metadata", func() { Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"})) }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by + // TagLib's PropertyMap as a single "mood" key with multiple values. The split + // configuration must still apply to each value individually. + It("should split values from multiple frames mapping to the same tag", func() { + props.Tags = model.RawTags{ + // Same shape as the bug report: two frames, comma-separated content. + "mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"}, + } + md = metadata.New(filePath, props) + + Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad")) + }) }) DescribeTable("Date", diff --git a/model/tag_mappings.go b/model/tag_mappings.go index bfe098f77..af76de741 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -34,23 +34,25 @@ type TagConf struct { SplitRx *regexp.Regexp `yaml:"-"` } -// SplitTagValue splits a tag value by the split separators, but only if it has a single value. +// SplitTagValue splits tag values by the configured split separators. +// Each value in the input slice is individually split and trimmed. func (c TagConf) SplitTagValue(values []string) []string { - // If there's not exactly one value or no separators, return early. - if len(values) != 1 || c.SplitRx == nil { + if c.SplitRx == nil || len(values) == 0 { return values } - tag := values[0] - // Replace all occurrences of any separator with the zero-width space. - tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) + var result []string + for _, tag := range values { + // Replace all occurrences of any separator with the zero-width space. + tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) - // Split by the zero-width space and trim each substring. - parts := strings.Split(tag, consts.Zwsp) - for i, part := range parts { - parts[i] = strings.TrimSpace(part) + // Split by the zero-width space and trim each substring. + parts := strings.Split(tag, consts.Zwsp) + for _, part := range parts { + result = append(result, strings.TrimSpace(part)) + } } - return parts + return result } type TagType string diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go new file mode 100644 index 000000000..1665d557b --- /dev/null +++ b/model/tag_mappings_test.go @@ -0,0 +1,64 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TagConf", func() { + Describe("SplitTagValue", func() { + var conf TagConf + + BeforeEach(func() { + conf = TagConf{Split: []string{";", "/", ","}} + conf.SplitRx = compileSplitRegex("test", conf.Split) + }) + + It("splits a single value on configured separators", func() { + Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"})) + }) + + It("trims whitespace around split values", func() { + Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"})) + }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD), + // TagLib's PropertyMap merges them into a slice with several entries. Previously + // SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case. + It("splits each value individually when given multiple inputs", func() { + input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"} + Expect(conf.SplitTagValue(input)).To(Equal([]string{ + "Love", "Emotional", "Ballad", + "Love", "Emotional", "Ballad", + })) + }) + + It("matches separators case-insensitively when the split pattern allows", func() { + c := TagConf{Split: []string{" AND "}} + c.SplitRx = compileSplitRegex("test", c.Split) + Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"})) + }) + + It("returns values unchanged when no separators are configured", func() { + c := TagConf{} + Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"})) + Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"})) + }) + + It("returns an empty slice for empty input", func() { + Expect(conf.SplitTagValue([]string{})).To(BeEmpty()) + }) + + It("handles a value with no separator as a single-element result", func() { + Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"})) + }) + + It("produces empty strings when separators are adjacent (dedup happens downstream)", func() { + // SplitTagValue itself does not filter empties; that is the job of + // filterDuplicatedOrEmptyValues in the metadata pipeline. + Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"})) + }) + }) +}) From 945d0ba1e2ddfc0e8b6f63b47e2913e43310dd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 24 May 2026 00:24:30 -0300 Subject: [PATCH 44/60] fix(transcoding): cap concurrent transcodes to prevent ffmpeg DoS (#5522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transcoding): add MaxConcurrent and MaxConcurrentPerUser config Introduce Transcoding.MaxConcurrent (default NumCPU()*2) and Transcoding.MaxConcurrentPerUser (default 3) to support upcoming concurrency limits on the streaming pipeline. No behavior change yet. Refs #5246 * feat(transcoding): add TranscodeLimiter with global and per-user caps Introduce a non-blocking limiter that gates concurrent transcodes. Returns ErrTooManyTranscodes immediately when the cap is reached so callers can translate it into a 429 response, rather than queuing requests. The per-user reservation is taken first to avoid burning a global slot that would only be rolled back when the per-user cap rejects the caller. Release is idempotent so wrapping the transcoder reader's Close is safe. Refs #5246 * feat(transcoding): cap concurrent transcodes in media streamer Acquire a TranscodeLimiter slot before spawning ffmpeg in the transcoding cache's read function, and release it when the resulting reader is closed. Raw streams and cache hits bypass the limiter so a single saturating client cannot block ordinary playback. When the cap is reached, ErrTooManyTranscodes bubbles up through cache.Get, ready for the HTTP layer to translate into a 429 response. Refs #5246 * feat(transcoding): return HTTP 429 with Retry-After when transcode cap is hit Map stream.ErrTooManyTranscodes to HTTP 429 in both the Subsonic API (/stream, /download) and the public share endpoint, including a 5s Retry-After hint. The Subsonic response still carries a failed-status envelope so clients that ignore HTTP codes also see the failure. Refs #5246 * feat(transcoding): default MaxConcurrent to 0 (disabled) Ship the limiter opt-in so existing installations are not affected by a behavior change on upgrade. Users hitting the DoS reported in #5246 can enable it by setting Transcoding.MaxConcurrent to a positive value (NumCPU()*2 is a reasonable starting point). Refs #5246 * fix(transcoding): make global and per-user caps independent Previously the limiter short-circuited to a no-op whenever MaxConcurrent was zero, silently ignoring a configured MaxConcurrentPerUser. Treat each cap independently so an operator can throttle per-user without enforcing a global ceiling (or vice versa), and only fall back to the no-op limiter when both caps are disabled. * fix(archiver): abort archive download when the transcode limiter rejects The album/artist/playlist zip writers were silently producing zip entries with headers but no data when ms.NewStream returned ErrTooManyTranscodes, because the per-file error was discarded by `_ = a.addFileToZip(...)`. The client received HTTP 200 with a corrupt zip and no indication that the server was rate-limited. Now the zip loop bails out as soon as it sees ErrTooManyTranscodes, and the Download handler swallows the error (the response status and Content-Disposition are already flushed by the time the limit is hit, so no 429 can be sent). The truncated zip surfaces the problem to the client; operators see a clear "transcode cap reached" warning in the server logs. Refs #5246 * fix(transcoding): release limiter slot on client close, not ffmpeg EOF Previously the slot was wrapped around the ffmpeg source reader, so it was only released by the cache's background copyAndClose goroutine when ffmpeg finished producing the file — meaning a client that disconnected after a single byte still held the slot for the full transcode duration. Under MaxConcurrent=N this serialized fresh requests behind abandoned encodes for minutes. Hand the release function back from the cache producer via the streamJob struct and wire it into the consumer-side Stream.Close. The HTTP handler already runs `defer stream.Close()`, so disconnect now frees the slot immediately. Cache hits never enter the producer and still pay no slot, and singleflight waiters on the same key correctly inherit no release (only the original producer's job holds the slot). Refs #5246 * fix(transcoding): skip per-user cap for anonymous requests Public share viewers have no user in context, so userName(ctx) returned the literal string "UNKNOWN" and the limiter mapped every anonymous viewer to the same bucket. With MaxConcurrentPerUser=N, only N unrelated anonymous clients could stream a viral share at any time — the opposite of the fairness the per-user cap is meant to provide. Introduce a limiterKey(ctx) helper that returns "" for anonymous callers (userName(ctx) is unchanged for logs), and teach Acquire to skip the per-user reservation when the key is empty. The global cap is still enforced for anonymous traffic and remains the protection against runaway anonymous load. Refs #5246 * refactor(transcoding): tidy limiter struct and centralize Retry-After Per review feedback: - Drop the redundant maxConcurrent field on transcodeLimiter; the channel capacity already enforces the global cap and the field was only used inside the constructor. - Only allocate the perUser map when MaxConcurrentPerUser > 0. - Move the Retry-After value into core/stream as RetryAfterSeconds so the Subsonic API and public-share handlers cannot drift if the window is later tuned. * fix(transcoding): do not log limiter rejections as cache failures NewStream was emitting an error-level "Error accessing transcoding cache" log whenever cache.Get returned anything non-nil, including the limiter's ErrTooManyTranscodes — even though the producer had already logged the rejection at warn level. The result was double logging and a misleading "cache failure" classification that buries real cache problems. Skip the error log when the cause is ErrTooManyTranscodes; the warn line from the producer is the canonical signal. * fix(archiver): open stream before writing zip entry header Per review: addFileToZip previously called z.CreateHeader before NewStream, so when the limiter rejected a transcode the zip already contained a 0-byte entry for that track. Open the source first and only write the header once the read side is ready; rejections now skip the entry entirely. The truncation comment in handleArchiveErr was also misleading — z.Close finalises the central directory, so the client receives a well-formed zip containing only the tracks written before the rejection, not a "truncated" archive. Reword to match reality. * fix(transcoding): hold slot for ffmpeg lifetime, force cancellable ctx The previous release-on-consumer-close design let a client open many unique transcodes, disconnect immediately, and still spawn the configured cap's worth of ffmpeg processes — the cache writer goroutine continued draining ffmpeg to disk after the client disappeared, defeating the DoS protection the limiter is meant to provide. Move the release back onto the source reader so the slot is freed only when ffmpeg actually exits (either EOF or context cancellation). To keep disconnects from leaking slots for the full transcode duration, force the request context into ffmpeg whenever the limiter is enabled — so client disconnect cancels the process and frees the slot promptly. When the limiter is disabled, the legacy EnableTranscodingCancellation behavior is preserved unchanged. Reported by codex and Copilot reviewers on #5522. --- conf/configuration.go | 8 ++ core/archiver.go | 56 ++++++--- core/archiver_test.go | 26 ++++ core/stream/limiter.go | 135 +++++++++++++++++++++ core/stream/limiter_test.go | 186 +++++++++++++++++++++++++++++ core/stream/media_streamer.go | 63 ++++++++-- core/stream/media_streamer_test.go | 54 +++++++++ server/public/handle_streams.go | 9 +- server/subsonic/api.go | 26 +++- server/subsonic/api_test.go | 20 ++++ server/subsonic/stream.go | 23 +++- 11 files changed, 571 insertions(+), 35 deletions(-) create mode 100644 core/stream/limiter.go create mode 100644 core/stream/limiter_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 6fff1641a..b4c43f135 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -113,6 +113,7 @@ type configOptions struct { PID pidOptions `json:",omitzero"` Inspect inspectOptions `json:",omitzero"` Subsonic subsonicOptions `json:",omitzero"` + Transcoding transcodingOptions `json:",omitzero"` LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` @@ -165,6 +166,11 @@ type scannerOptions struct { PurgeMissing string // Values: "never", "always", "full" } +type transcodingOptions struct { + MaxConcurrent int + MaxConcurrentPerUser int +} + type subsonicOptions struct { AppendSubtitle bool AppendAlbumVersion bool @@ -822,6 +828,8 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") + viper.SetDefault("transcoding.maxconcurrent", 0) + viper.SetDefault("transcoding.maxconcurrentperuser", 0) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) diff --git a/core/archiver.go b/core/archiver.go index 96cc2c31e..5d1c090cd 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -3,6 +3,7 @@ package core import ( "archive/zip" "context" + "errors" "fmt" "io" "os" @@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { file := a.albumFilename(mf, format, isMultiDisc) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Stop iterating: continuing would just rack up more + // rejections from the limiter. Close finalises whatever + // tracks were already written; the rejected one is not + // present in the archive (addFileToZip aborts before + // writing its entry header). + _ = z.Close() + return addErr + } } } err = z.Close() @@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { file := a.playlistFilename(mf, format, idx) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Abort the whole archive: continuing would silently emit + // empty zip entries since the headers are already written. + _ = z.Close() + return addErr + } mf.Path = file zippedMfs[idx] = mf } @@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { path := mf.AbsolutePath() + + // Open the source before writing the zip entry header so a rejection + // (limiter, missing file, etc.) does not leave an empty entry in the + // archive. + var r io.ReadCloser + var err error + if format != "raw" && format != "" { + r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + } else { + r, err = os.Open(path) + } + if err != nil { + log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + return err + } + defer func() { + if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) + } + }() + w, err := z.CreateHeader(&zip.FileHeader{ Name: filename, Modified: mf.UpdatedAt, @@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return err } - var r io.ReadCloser - if format != "raw" && format != "" { - r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) - } else { - r, err = os.Open(path) - } - if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) - return err - } - - defer func() { - if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { - log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) - } - }() - _, err = io.Copy(w, r) if err != nil { log.Error(ctx, "Error zipping file", "file", path, err) diff --git a/core/archiver_test.go b/core/archiver_test.go index 4f7aed278..f432139d8 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() { }) }) + Context("when the transcode limiter rejects a file", func() { + It("aborts the archive instead of continuing with empty entries", func() { + mfs := model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + } + + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{ + Filters: squirrel.Eq{"album_id": "1"}, + Sort: "album", + }}).Return(mfs, nil) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}). + Return(nil, stream.ErrTooManyTranscodes).Once() + + out := new(bytes.Buffer) + err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) + Expect(err).To(MatchError(stream.ErrTooManyTranscodes)) + // NewStream should only have been called once: the loop must bail + // out on the rejection instead of trying every remaining track. + ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1) + }) + }) + Context("ZipShare", func() { It("zips a share correctly", func() { mfs := model.MediaFiles{ diff --git a/core/stream/limiter.go b/core/stream/limiter.go new file mode 100644 index 000000000..622fe21cc --- /dev/null +++ b/core/stream/limiter.go @@ -0,0 +1,135 @@ +package stream + +import ( + "context" + "errors" + "io" + "sync" + "sync/atomic" +) + +// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the +// configured concurrency cap has been reached. Callers should translate this +// into an HTTP 429 response so well-behaved clients back off and retry. +var ErrTooManyTranscodes = errors.New("too many concurrent transcodes") + +// RetryAfterSeconds is the value returned in the HTTP Retry-After header when +// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well +// within this window, so retrying after this delay typically succeeds. +const RetryAfterSeconds = 5 + +// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces +// both a global cap (to protect the host from process exhaustion) and an optional +// per-user cap (to keep one client from starving the others). Acquire never +// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately. +type TranscodeLimiter interface { + // Acquire reserves a slot for the given user. On success it returns a release + // function that must be called exactly once when the transcode is done. + // Calling release more than once is safe and idempotent. + Acquire(ctx context.Context, user string) (release func(), err error) + + // Enabled reports whether the limiter actually enforces any cap. Callers + // can use it to decide whether to bind ffmpeg's lifetime to the request + // context so disconnects free slots quickly, rather than letting the + // process drain to completion in the background. + Enabled() bool +} + +// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is +// independent: a value of zero or less disables that cap. When both caps are +// disabled the limiter is a no-op. +func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter { + if maxConcurrent <= 0 && maxPerUser <= 0 { + return noopLimiter{} + } + l := &transcodeLimiter{maxPerUser: maxPerUser} + if maxConcurrent > 0 { + l.global = make(chan struct{}, maxConcurrent) + } + if maxPerUser > 0 { + l.perUser = make(map[string]int) + } + return l +} + +// releasingReadCloser wraps an io.ReadCloser so that closing it also releases +// the limiter slot exactly once. release must be the function returned by +// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too. +type releasingReadCloser struct { + io.ReadCloser + release func() +} + +func (r *releasingReadCloser) Close() error { + err := r.ReadCloser.Close() + r.release() + return err +} + +type noopLimiter struct{} + +func (noopLimiter) Acquire(context.Context, string) (func(), error) { + return func() {}, nil +} + +func (noopLimiter) Enabled() bool { return false } + +type transcodeLimiter struct { + maxPerUser int + global chan struct{} + + mu sync.Mutex + perUser map[string]int +} + +func (*transcodeLimiter) Enabled() bool { return true } + +func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) { + // Reserve a per-user slot first so a noisy user can't burn through + // global slots only to be rejected later. An empty user key means + // "anonymous" (e.g. public share viewers); we skip the per-user cap + // entirely so unrelated anonymous clients do not share a bucket. + perUserActive := l.maxPerUser > 0 && user != "" + if perUserActive { + l.mu.Lock() + if l.perUser[user] >= l.maxPerUser { + l.mu.Unlock() + return nil, ErrTooManyTranscodes + } + l.perUser[user]++ + l.mu.Unlock() + } + + if l.global != nil { + select { + case l.global <- struct{}{}: + default: + if perUserActive { + l.releasePerUser(user) + } + return nil, ErrTooManyTranscodes + } + } + + var released atomic.Bool + return func() { + if !released.CompareAndSwap(false, true) { + return + } + if l.global != nil { + <-l.global + } + if perUserActive { + l.releasePerUser(user) + } + }, nil +} + +func (l *transcodeLimiter) releasePerUser(user string) { + l.mu.Lock() + defer l.mu.Unlock() + l.perUser[user]-- + if l.perUser[user] <= 0 { + delete(l.perUser, user) + } +} diff --git a/core/stream/limiter_test.go b/core/stream/limiter_test.go new file mode 100644 index 000000000..d278d47c8 --- /dev/null +++ b/core/stream/limiter_test.go @@ -0,0 +1,186 @@ +package stream_test + +import ( + "context" + "errors" + "sync" + + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TranscodeLimiter", func() { + ctx := log.NewContext(context.TODO()) + + Describe("Disabled (both caps <= 0)", func() { + It("never blocks and never returns ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(0, 0) + for range 100 { + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + Expect(rel).ToNot(BeNil()) + } + }) + }) + + Describe("Per-user cap only (no global cap)", func() { + It("still enforces the per-user limit when MaxConcurrent is disabled", func() { + lim := stream.NewTranscodeLimiter(0, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // Other users have their own buckets. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + }) + + Describe("Global cap", func() { + It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(2, 0) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "carol") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + _, err = lim.Acquire(ctx, "carol") + Expect(err).ToNot(HaveOccurred()) + + rel2() + }) + + It("releases a slot only once even if release is called multiple times", func() { + lim := stream.NewTranscodeLimiter(1, 0) + + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel() + rel() + rel() + + // After releases, exactly one slot should be available. + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) + + Describe("Per-user cap", func() { + It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() { + lim := stream.NewTranscodeLimiter(10, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // A different user is unaffected. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + + It("skips the per-user cap for anonymous users (empty key)", func() { + // Anonymous requests (e.g. public share viewers) deliberately + // bypass the per-user cap so unrelated anonymous clients are not + // collapsed into a single shared bucket. The global cap remains + // the only ceiling on anonymous traffic. + lim := stream.NewTranscodeLimiter(10, 1) + + rels := make([]func(), 0, 5) + for range 5 { + rel, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rels = append(rels, rel) + } + for _, rel := range rels { + rel() + } + }) + + It("still applies the global cap to anonymous users", func() { + lim := stream.NewTranscodeLimiter(2, 1) + + rel1, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + rel2() + }) + }) + + Describe("Concurrent safety", func() { + It("survives parallel Acquire/release with consistent counts", func() { + lim := stream.NewTranscodeLimiter(5, 0) + + var wg sync.WaitGroup + var acquired int64 + var rejected int64 + var mu sync.Mutex + + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + rel, err := lim.Acquire(ctx, "alice") + mu.Lock() + if err == nil { + acquired++ + mu.Unlock() + rel() + } else { + rejected++ + mu.Unlock() + } + _ = i + }(i) + } + wg.Wait() + + Expect(acquired + rejected).To(Equal(int64(50))) + // After all releases, all 5 slots should be free again. + for range 5 { + _, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + } + _, err := lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) +}) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index de03b4d2f..f898e066e 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -2,6 +2,7 @@ package stream import ( "context" + "errors" "fmt" "io" "mime" @@ -28,13 +29,19 @@ type MediaStreamer interface { type TranscodingCache cache.FileCache func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer { - return &mediaStreamer{ds: ds, transcoder: t, cache: cache} + return &mediaStreamer{ + ds: ds, + transcoder: t, + cache: cache, + limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser), + } } type mediaStreamer struct { ds model.DataStore transcoder ffmpeg.FFmpeg cache cache.FileCache + limiter TranscodeLimiter } type streamJob struct { @@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req } r, err := ms.cache.Get(ctx, job) if err != nil { - log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + // Rate-limit rejections are already logged at warn level by the + // producer; treating them as cache failures here would both + // double-log and mask actual cache problems. + if !errors.Is(err, ErrTooManyTranscodes) { + log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + } return nil, err } cached = r.Cached @@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache { return nil, os.ErrInvalid } - // Choose the appropriate context based on EnableTranscodingCancellation configuration. - // This is where we decide whether transcoding processes should be cancellable or not. + release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx)) + if err != nil { + log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached", + "id", job.mf.ID, "user", userName(ctx), + "maxConcurrent", conf.Server.Transcoding.MaxConcurrent, + "maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser) + return nil, err + } + + // Choose the context that drives the ffmpeg process. + // + // When the limiter is enabled, force the request context so a + // client disconnect cancels ffmpeg and frees the slot promptly. + // Otherwise a client could open many transcodes, disconnect + // immediately, and still leave the configured cap's worth of + // ffmpeg processes draining in the background — which is exactly + // the DoS the limiter is meant to prevent. + // + // When the limiter is disabled, preserve the legacy behavior + // governed by EnableTranscodingCancellation so this PR does not + // change observable behavior for operators who have not opted in. var transcodingCtx context.Context - if conf.Server.EnableTranscodingCancellation { - // Use the request context directly, allowing cancellation when client disconnects + if job.ms.limiter.Enabled() || conf.Server.EnableTranscodingCancellation { transcodingCtx = ctx } else { - // Use background context with request values preserved. - // This prevents cancellation but maintains request metadata (user, client, etc.) transcodingCtx = request.AddValues(context.Background(), ctx) } @@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache { Offset: job.offset, }) if err != nil { + release() log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid } - return out, nil + // Tie the slot to the ffmpeg process: copyAndClose calls Close + // on this reader after io.Copy returns, which is exactly when + // ffmpeg has exited (either EOF or context cancellation). + return &releasingReadCloser{ReadCloser: out, release: release}, nil }) } @@ -255,3 +287,16 @@ func userName(ctx context.Context) string { return user.UserName } } + +// limiterKey returns the per-user bucket key used by the transcode limiter. +// For anonymous requests (e.g. public shares) it returns the empty string, +// which signals the limiter to skip the per-user cap entirely — otherwise +// every anonymous viewer of a public share would collide on the same key +// and starve each other within MaxConcurrentPerUser slots. The global cap +// still applies and remains the protection against runaway anonymous load. +func limiterKey(ctx context.Context) string { + if user, ok := request.UserFrom(ctx); ok { + return user.UserName + } + return "" +} diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 1bbf868fa..676e8d6f8 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -2,6 +2,7 @@ package stream_test import ( "context" + "errors" "io" "os" @@ -10,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,6 +63,58 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) + It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + // Rebuild the streamer with a tight cap. The first request will hold the + // ffmpeg reader open (we don't read/close it), saturating the single slot. + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Different cache key so it doesn't dedupe with the first request. + _, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + + It("releases the slot once the stream is closed", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + _, _ = io.ReadAll(s1) + _ = s1.Close() + Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) + + // Slot should now be free for a different transcode. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + + It("does not consume a slot for raw streams", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + // First, saturate the single transcode slot. + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Raw stream must still succeed. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + It("returns a seekable stream if the file is complete in the cache", func() { s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 7d5a836b3..c7b8a4d4f 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -7,7 +7,7 @@ import ( "time" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/stream" + streampkg "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" @@ -48,10 +48,15 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, mf, stream.Request{ + stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ Format: info.format, BitRate: info.bitrate, }) if err != nil { + if errors.Is(err, streampkg.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) + http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests) + return + } log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) return diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 7d15125b6..f39dec009 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" @@ -304,6 +305,8 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorDataNotFound, "data not found") case errors.Is(err, model.ErrNotAuthorized): err = newError(responses.ErrorAuthorizationFail) + case errors.Is(err, stream.ErrTooManyTranscodes): + err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") default: err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) } @@ -313,15 +316,31 @@ func mapToSubsonicError(err error) subError { } func sendError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, stream.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(stream.RetryAfterSeconds)) + sendResponseWithStatus(w, r, errorResponse(err), http.StatusTooManyRequests) + return + } + sendResponse(w, r, errorResponse(err)) +} + +func errorResponse(err error) *responses.Subsonic { subErr := mapToSubsonicError(err) response := newResponse() response.Status = responses.StatusFailed response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()} - - sendResponse(w, r, response) + return response } func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) { + sendResponseWithStatus(w, r, payload, 0) +} + +// sendResponseWithStatus writes the response body in the format requested by +// the client. When status is non-zero, WriteHeader is called with that code +// before the body is written; callers that need to set additional headers +// (e.g. Retry-After) must set them before calling. +func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic, status int) { p := req.Params(r) f, _ := p.String("f") var response []byte @@ -356,6 +375,9 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub sendError(w, r, err) return } + if status != 0 { + w.WriteHeader(status) + } if payload.Status == responses.StatusOK { if log.IsGreaterOrEqualTo(log.LevelTrace) { diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index a2db4a0af..a1b66925a 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -3,11 +3,13 @@ package subsonic import ( "encoding/json" "encoding/xml" + "fmt" "math" "net/http" "net/http/httptest" "strings" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -152,6 +154,24 @@ var _ = Describe("sendResponse", func() { }) }) + It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() { + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/rest/stream", nil) + + sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes)) + + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) + + var subsonicResponse responses.Subsonic + err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed)) + Expect(subsonicResponse.Error).ToNot(BeNil()) + Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric)) + Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode")) + }) + It("updates status pointer when an error occurs", func() { pointer := int32(0) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index b49af2b24..28b4585f0 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,12 +1,15 @@ package subsonic import ( + "context" + "errors" "fmt" "net/http" "strconv" "strings" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -119,14 +122,28 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. return nil, err case *model.Album: setHeaders(v.Name) - return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)) case *model.Artist: setHeaders(v.Name) - return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)) case *model.Playlist: setHeaders(v.Name) - return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)) default: return nil, model.ErrNotFound } } + +// handleArchiveErr swallows ErrTooManyTranscodes from archive downloads so the +// outer error handler does not try to write a 429 onto a response whose status +// and Content-Disposition have already been flushed. The archive ends up with +// the tracks that were written before the rejection (the rejected track and +// any following ones are omitted); the server-side log is the unambiguous +// signal operators can act on. +func handleArchiveErr(ctx context.Context, id string, err error) error { + if errors.Is(err, stream.ErrTooManyTranscodes) { + log.Warn(ctx, "Archive download finalized early: transcode cap reached", "id", id, err) + return nil + } + return err +} From 823d851b75f72cea1ca1092aec0e30ced5aeef79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 24 May 2026 00:51:58 -0300 Subject: [PATCH 45/60] refactor(transcoding): rename EnableTranscodingCancellation to Transcoding.EnableCancellation (#5523) Move the option into the nested Transcoding config group alongside the limit knobs it interacts with, so all transcoding-related settings live together. The old top-level name is still honored via the existing mapDeprecatedOption / logDeprecatedOptions plumbing, which forwards the value to the new key and logs a deprecation warning at startup. The old struct field is removed (the new field is the single source of truth); the deprecated default is removed so viper.IsSet correctly distinguishes "user set the legacy option" from "no one set it." --- conf/configuration.go | 6 ++++-- core/stream/media_streamer.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index b4c43f135..08f12fc94 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -47,7 +47,6 @@ type configOptions struct { UIWelcomeMessage string MaxSidebarPlaylists int EnableTranscodingConfig bool - EnableTranscodingCancellation bool EnableDownloads bool EnableExternalServices bool EnableM3UExternalAlbumArt bool @@ -169,6 +168,7 @@ type scannerOptions struct { type transcodingOptions struct { MaxConcurrent int MaxConcurrentPerUser int + EnableCancellation bool } type subsonicOptions struct { @@ -330,6 +330,7 @@ func Load(noConfigDump bool) { mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") err := viper.Unmarshal(&Server, viper.DecodeHook( mapstructure.ComposeDecodeHookFunc( @@ -455,6 +456,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation") // Removed options logRemovedOptions("Spotify.ID", "Spotify.Secret") @@ -743,7 +745,6 @@ func setViperDefaults() { viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) viper.SetDefault("enabletranscodingconfig", false) - viper.SetDefault("enabletranscodingcancellation", false) viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("imagecachesize", "100MB") viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) @@ -830,6 +831,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.minimalclients", "SubMusic") viper.SetDefault("transcoding.maxconcurrent", 0) viper.SetDefault("transcoding.maxconcurrentperuser", 0) + viper.SetDefault("transcoding.enablecancellation", false) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index f898e066e..b09d9bab8 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -248,10 +248,10 @@ func NewTranscodingCache() TranscodingCache { // the DoS the limiter is meant to prevent. // // When the limiter is disabled, preserve the legacy behavior - // governed by EnableTranscodingCancellation so this PR does not - // change observable behavior for operators who have not opted in. + // governed by Transcoding.EnableCancellation so unchanged configs + // keep their previous observable behavior. var transcodingCtx context.Context - if job.ms.limiter.Enabled() || conf.Server.EnableTranscodingCancellation { + if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation { transcodingCtx = ctx } else { transcodingCtx = request.AddValues(context.Background(), ctx) From fc9cdf39c8098e2c0e3d315aee4aff2d7772caa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 27 May 2026 23:18:35 -0300 Subject: [PATCH 46/60] fix(conf): make Dir a plain value type to prevent sync.Once corruption (#5543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dir embedded sync.Once directly and exposed a value-receiver GoString so that pretty.Sprintf("%# v", Server) could render the path. That meant every pretty-print copied the entire Dir along with its Once, and a goroutine concurrently using the original (or any copy) for Path() could hit a "sync: unlock of unlocked mutex" runtime fatal error. The failure was reproduced deterministically on Windows CI when test-suite shuffle ordering raced cache initialization (utils/cache/file_caches.go's NewFileCache.func1 -> conf.CacheFolder.MustPath) against the configuration-dump pretty.Sprintf in Load(). Drop the sync.Once entirely. Dir is now a plain {path, perm} value type, and Path() calls os.MkdirAll on every invocation. MkdirAll is idempotent, so repeated calls on an existing directory cost one stat syscall — negligible for the few config paths read at startup and during cache init. This removes the entire class of bug: - No Mutex, so copies (via reflection, pretty-print, etc.) are safe. - No state pointer, so no nil-state defensive checks scattered across methods, and no risk of two copies seeing different lifecycle state. - go vet is happy with the value receivers — the //nolint:govet suppression on GoString is gone. Adds two regression tests in conf/dir_test.go: - GoString renders Dir as a quoted path under pretty.Sprintf (and does not leak the internal struct fields). - Concurrent copy + Path() stress test, locking in the copy-safety property in case the type ever grows non-trivial state again. --- conf/dir.go | 47 ++++++++++++++++++++++++----------------------- conf/dir_test.go | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/conf/dir.go b/conf/dir.go index abbe72b77..f7a14b933 100644 --- a/conf/dir.go +++ b/conf/dir.go @@ -1,20 +1,20 @@ package conf import ( + "cmp" "fmt" "os" - "sync" ) -// Dir wraps a directory path and lazily creates the directory on first use. -// The directory is created at most once; if creation fails, the error is -// permanently cached (sync.Once semantics). Dir is not safe for mutation -// after Path() has been called. +// Dir wraps a directory path and creates the directory on demand. Dir is a +// plain value type — safe to copy, compare, and print via reflection-based +// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards. +// Directory creation is delegated to os.MkdirAll on every Path() call; +// MkdirAll is idempotent, so repeated calls cost one stat syscall when the +// directory already exists. type Dir struct { path string perm os.FileMode - once sync.Once - err error } // NewDir creates a new Dir with the given path and default permissions (os.ModePerm). @@ -23,31 +23,32 @@ func NewDir(path string) Dir { } // NewDirWithPerm creates a new Dir with the given path and permissions. +// A perm of 0 is treated as "default" and resolves to os.ModePerm at +// directory-creation time; pass an explicit non-zero mode to constrain the +// permissions. func NewDirWithPerm(path string, perm os.FileMode) Dir { return Dir{path: path, perm: perm} } // String returns the raw path without creating the directory. Satisfies fmt.Stringer. -func (d *Dir) String() string { +func (d Dir) String() string { return d.path } -// Path creates the directory on first call (via sync.Once) and returns the path. -func (d *Dir) Path() (string, error) { - d.once.Do(func() { - if d.path == "" { - return - } - d.err = os.MkdirAll(d.path, d.perm) - if d.err != nil { - d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err) - } - }) - return d.path, d.err +// Path ensures the directory exists and returns its path. Safe to call +// repeatedly; an empty path is returned as-is with no error. +func (d Dir) Path() (string, error) { + if d.path == "" { + return "", nil + } + if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil { + return d.path, fmt.Errorf("creating directory %q: %w", d.path, err) + } + return d.path, nil } // MustPath calls Path() and calls logFatal on error. -func (d *Dir) MustPath() string { +func (d Dir) MustPath() string { path, err := d.Path() if err != nil { logFatal("creating directory:", err) @@ -57,12 +58,12 @@ func (d *Dir) MustPath() string { // GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) // prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer +func (d Dir) GoString() string { return fmt.Sprintf("%q", d.path) } // MarshalText returns the raw path bytes. No side effects. -func (d *Dir) MarshalText() ([]byte, error) { +func (d Dir) MarshalText() ([]byte, error) { return []byte(d.path), nil } diff --git a/conf/dir_test.go b/conf/dir_test.go index 2dd4250bc..79db379d2 100644 --- a/conf/dir_test.go +++ b/conf/dir_test.go @@ -2,7 +2,9 @@ package conf_test import ( "os" + "sync" + "github.com/kr/pretty" "github.com/navidrome/navidrome/conf" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -35,9 +37,9 @@ var _ = Describe("Dir", func() { Expect(target).To(BeADirectory()) }) - It("returns the same result on subsequent calls (sync.Once)", func() { + It("is idempotent on subsequent calls", func() { dir := GinkgoT().TempDir() - target := dir + "/once" + target := dir + "/idempotent" d := conf.NewDir(target) path1, err1 := d.Path() @@ -45,6 +47,7 @@ var _ = Describe("Dir", func() { Expect(err1).ToNot(HaveOccurred()) Expect(err2).ToNot(HaveOccurred()) Expect(path1).To(Equal(path2)) + Expect(target).To(BeADirectory()) }) It("returns an error when directory cannot be created", func() { @@ -124,4 +127,38 @@ var _ = Describe("Dir", func() { Expect(d2.String()).To(Equal(d1.String())) }) }) + + Describe("GoString", func() { + // Regression: pretty.Sprintf("%# v", ...) is used by the + // configuration dump. It must render Dir as a quoted path via + // GoString, not dump the internal struct fields. + It("renders Dir as a quoted path under pretty.Sprintf", func() { + type host struct { + DataFolder conf.Dir + } + h := host{DataFolder: conf.NewDir("./data")} + out := pretty.Sprintf("%# v", h) + Expect(out).To(ContainSubstring(`DataFolder: "./data"`)) + Expect(out).ToNot(ContainSubstring("perm:")) + Expect(out).ToNot(ContainSubstring("path:")) + }) + + It("is safe to copy and use concurrently", func() { + // Regression for the Windows "sync: unlock of unlocked mutex" + // crash that was caused by copying a Dir embedding sync.Once. + // Dir is a plain value type now, but keep the concurrent stress + // test to lock in the property. + dir := GinkgoT().TempDir() + d := conf.NewDir(dir + "/race") + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + copy1 := d + _ = pretty.Sprintf("%# v", copy1) + _, _ = copy1.Path() + }) + } + wg.Wait() + }) + }) }) From 74a5c0c6d116c206fed3f2287af5ab46a3f62a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 27 May 2026 23:29:17 -0300 Subject: [PATCH 47/60] fix(playlists): preserve unchanged fields on partial REST updates (#5542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(playlists): preserve unchanged fields on partial REST updates (#5541) The REST adapter for playlists was discarding the `cols` argument that rest.Put provides (the list of fields actually present in the JSON body). updatePlaylistEntity then compared the deserialized entity's zero-valued Name/Comment against the DB row, decided "content changed", and called updateMetadata with &entity.Name — overwriting the name with the empty string. This surfaced via the Playlists list view's bulk "Make Public" action, which sends N parallel `PUT /api/playlist/{id}` requests with body `{"public": true}`. Affected playlists ended up with their names wiped (UI showed "Loading..." indefinitely). The per-row Public toggle was unaffected because it spreads the full record into the payload. Honor the cols list: gate every field-change check and every pointer passed to updateMetadata by whether the field was actually in the request body. Empty cols falls back to the existing "treat as a full record" behavior so non-REST callers are unaffected. * test(playlists): cover rules-only PUT + case-variant owner-change guard Follow-ups from manual testing and code review of the prior commit: - Manual testing confirmed Feishin-style rules-only PUT works correctly on the fix; add ginkgo regression tests for rules-only update, name+ rules combined, idempotent rules PUT (no-op), and bulk Make-Public preserving rules on smart playlists. - Keep the non-admin owner-change permission check gated on the deserialized entity content (not on `sent("ownerId")`) so a case-variant JSON key like {"OwnerId":"x"} can't downgrade the 403 to a silent 200. Go's json decoder is case-insensitive on struct field matching but rest.Put's field-name extraction is case- sensitive; the entity-based guard catches both spellings. The apply-side gating on ownerChanged still prevents the actual mutation, so this was a behavioral (not security) regression, but worth fixing. Adds a regression test asserting the case-variant key still returns rest.ErrPermissionDenied. - Correct misleading doc on applyContentUpdate: the path does not rewrite the backing M3U file; it goes through updateMetadata which bumps updatedAt and invalidates cached cover-art URLs. * fix(playlists): match REST cols case-insensitively (PR #5542 review) Go's encoding/json populates struct fields from case-variant keys like {"Name":"x"} or {"OwnerId":"y"}, but rest.Put's getFieldNames extracts raw JSON keys verbatim. With case-sensitive matching, sentFields would ignore the field on the update side — a request with {"Name":"Renamed"} would parse into entity.Name but then sent("name") returns false and the rename silently no-ops. Normalize both sides to lowercase. The entity-based owner-permission guard added in the previous commit remains as belt-and-suspenders but is now redundant with this change. Also clarify the applyContentUpdate doc comment: namePtr/commentPtr are nil when the field is absent OR present-but-unchanged, while publicPtr only tracks presence (an idempotent public is still forwarded). * refactor(playlists): drop redundant entity-based owner-permission guard The case-insensitive sentFields predicate already prevents case-variant JSON keys like {"OwnerId":"x"} from bypassing the ownerChanged check, so the duplicated entity-content guard is no longer load-bearing. Strengthen the regression test into a DescribeTable covering canonical, PascalCase, all-upper, and all-lower spellings to lock in the case-insensitive contract. --- core/playlists/rest_adapter.go | 111 ++++++++++++++---- core/playlists/rest_adapter_test.go | 169 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 25 deletions(-) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index c9b7c4ea6..3f886aadd 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -4,11 +4,13 @@ import ( "context" "errors" "reflect" + "strings" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" ) // --- REST adapter (follows Share/Library pattern) --- @@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) +func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { +// +// cols names the fields the client actually sent in the JSON body (extracted by +// rest.Put). When non-empty, fields outside cols are not considered changed and +// are left untouched — this prevents partial requests like bulk "Make Public" +// (body: {"public": true}) from wiping fields that just happen to be zero in +// the deserialized entity (see issue #5541). An empty cols means "treat the +// entity as a complete record" — preserved for callers that don't use the REST +// wrapper. +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity return err } } + + sent := sentFields(cols) + usr, _ := request.UserFrom(ctx) - if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { + ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID + if !usr.IsAdmin && ownerChanged { return rest.ErrPermissionDenied } - contentChanged := entity.Name != current.Name || - entity.Comment != current.Comment || - (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || - !rulesEqual(current.Rules, entity.Rules) + nameChanged := sent("name") && entity.Name != current.Name + commentChanged := sent("comment") && entity.Comment != current.Comment + rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules) - if contentChanged { - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID - } + if nameChanged || commentChanged || ownerChanged || rulesChanged { + return s.applyContentUpdate(ctx, current, entity, sent, + nameChanged, commentChanged, ownerChanged, rulesChanged) + } + return s.applyFlagsOnly(ctx, current, entity, sent) +} + +// applyContentUpdate handles updates that change at least one of name/comment/ +// owner/rules. It goes through updateMetadata, which always bumps updatedAt +// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the +// field is absent from the request OR present-but-unchanged (so updateMetadata +// skips them); publicPtr is nil only when public is absent from the request +// (an idempotent public value is still forwarded). +func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool, +) error { + if ownerChanged { + current.OwnerID = entity.OwnerID + } + if rulesChanged { current.Rules = entity.Rules - if current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - } - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - - // Only sync/public changed — skip updatedAt so cover art URLs stay stable - var cols []string - if current.Path != "" && current.Sync != entity.Sync { + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync - cols = append(cols, "sync") } - if current.Public != entity.Public { + var namePtr, commentPtr *string + var publicPtr *bool + if nameChanged { + namePtr = &entity.Name + } + if commentChanged { + commentPtr = &entity.Comment + } + if sent("public") { + publicPtr = &entity.Public + } + return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr) +} + +// applyFlagsOnly handles updates that only toggle sync/public — skips +// updatedAt so cover art URLs stay stable. +func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, +) error { + var updateCols []string + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + updateCols = append(updateCols, "sync") + } + if sent("public") && current.Public != entity.Public { current.Public = entity.Public - cols = append(cols, "public") + updateCols = append(updateCols, "public") } - if len(cols) == 0 { + if len(updateCols) == 0 { return nil } - return s.ds.Playlist(ctx).Put(current, cols...) + return s.ds.Playlist(ctx).Put(current, updateCols...) +} + +// sentFields returns a predicate that reports whether a JSON field was present +// in the request body. Matching is case-insensitive to mirror Go's json +// decoder, which populates struct fields from case-variant keys like +// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity +// as a full record" — every field is considered sent. +func sentFields(cols []string) func(string) bool { + if len(cols) == 0 { + return func(string) bool { return true } + } + set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} }) + return func(field string) bool { + _, ok := set[strings.ToLower(field)] + return ok + } } func rulesEqual(a, b *criteria.Criteria) bool { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 68461b259..79d72d147 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -125,6 +125,25 @@ var _ = Describe("REST Adapter", func() { Expect(err).To(Equal(rest.ErrPermissionDenied)) }) + DescribeTable("denies regular user from changing ownership under any case-variant JSON key", + func(colName string) { + // rest.Put's field-name extraction is case-sensitive, but Go's + // json decoder is case-insensitive on struct fields, so any + // {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates + // entity.OwnerID. sentFields normalizes both sides so the + // permission gate fires regardless of casing. + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{OwnerID: "other-user"} + err := repo.Update("pls-1", pls, colName) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }, + Entry("canonical camelCase", "ownerId"), + Entry("PascalCase", "OwnerId"), + Entry("all upper", "OWNERID"), + Entry("all lower", "ownerid"), + ) + It("updates smart playlist rules", func() { mockPlsRepo.Data["smart-1"] = &model.Playlist{ ID: "smart-1", @@ -218,6 +237,156 @@ var _ = Describe("REST Adapter", func() { err := repo.Update("nonexistent", pls) Expect(err).To(Equal(rest.ErrNotFound)) }) + + // Regression tests for #5541: partial REST updates (e.g. bulk "Make Public") + // must only touch the fields the client actually sent. The cols list from + // rest.Put names those fields; fields outside it must be left alone, even + // when the deserialized entity has zero values for them. + Context("with partial updates (cols)", func() { + BeforeEach(func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + mockPlsRepo.Data["partial"] = &model.Playlist{ + ID: "partial", + Name: "Original Name", + Comment: "Original comment", + OwnerID: "user-1", + Public: false, + } + }) + + It("preserves name and comment when only public is sent (bulk Make Public)", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("preserves name when only sync is sent for a file-backed playlist", func() { + mockPlsRepo.Data["file-partial"] = &model.Playlist{ + ID: "file-partial", + Name: "Keep Me", + OwnerID: "user-1", + Path: "/music/p.m3u", + Sync: true, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + + It("renames the playlist when only name is sent", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeFalse()) + }) + + It("clears the comment when an empty comment is sent explicitly", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Comment).To(BeEmpty()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + }) + + It("updates rules-only on a smart playlist (Feishin-style edit)", func() { + mockPlsRepo.Data["smart-partial"] = &model.Playlist{ + ID: "smart-partial", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Public: true, + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} + err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original")) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("updates name and rules together (smart-playlist Edit form)", func() { + mockPlsRepo.Data["smart-edit"] = &model.Playlist{ + ID: "smart-edit", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} + err := repo.Update("smart-edit", + &model.Playlist{Name: "Smart Renamed", Rules: newRules}, + "name", "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed")) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + }) + + It("does not bump the saved rules on an idempotent rules-only PUT", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{ + ID: "smart-idempotent", + Name: "Smart Idempotent", + OwnerID: "user-1", + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + // Same rules sent back — rulesEqual should report no change and + // the request should no-op (no Put call). + sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened + }) + + It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-public"] = &model.Playlist{ + ID: "smart-public", + Name: "Smart Public", + OwnerID: "user-1", + Public: false, + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-public", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.Rules).To(Equal(rules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public")) + }) + + It("does not treat a missing ownerId as an ownership transfer attempt", func() { + // A non-admin user sending only {public:true} should not be blocked + // just because OwnerID is the zero value in the deserialized entity. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + }) + + It("matches cols case-insensitively (mirrors json decoder behavior)", func() { + // Go's json decoder populates struct fields from case-variant keys + // like {"Name":"x"}, but rest.Put's field-name extraction is + // case-sensitive. sentFields normalizes both sides so a request + // with {"Name":"Renamed"} is honored, not silently ignored. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + }) + }) }) Describe("Delete", func() { From 833c50adc7d45dcc9f0f6dfb700e02be9a3706a1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 00:07:49 -0300 Subject: [PATCH 48/60] test(stream): fix data race in MediaStreamer transcoding cap tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three It blocks that build a tight-cap streamer each spawned a fresh transcoding cache without waiting for its background initialization. The init goroutine reads conf.Server.CacheFolder, which races against SnapshotConfig's pointer-swap restore (Server = &restored) fired by DeferCleanup at the end of the spec. CI tripped the race under -shuffle=on -race; locally it reproduced about 10% of the time. Wait for tightCache.Available() before constructing the streamer, mirroring the outer BeforeEach. For the slot-saturation spec, swap in a blocking io.Pipe-backed mock ffmpeg so the cache's background copyAndClose can't drain the source and release the slot — the previous behavior happened to work only because the cache wasn't yet available and the no-cache path was exercised. --- core/stream/media_streamer_test.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 676e8d6f8..f5ca16d3f 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -64,11 +64,19 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Duration()).To(Equal(float32(257.0))) }) It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { - // Rebuild the streamer with a tight cap. The first request will hold the - // ffmpeg reader open (we don't read/close it), saturating the single slot. + // Use an ffmpeg whose Read blocks indefinitely so the cache's + // background copy can't drain the source and release the slot — + // keeping the single transcode slot pinned for this test. + pr, pw := io.Pipe() + DeferCleanup(func() { _ = pw.Close() }) + blockingFFmpeg := tests.NewMockFFmpeg("") + blockingFFmpeg.Reader = pr + conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) @@ -83,7 +91,9 @@ var _ = Describe("MediaStreamer", func() { It("releases the slot once the stream is closed", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) @@ -101,7 +111,9 @@ var _ = Describe("MediaStreamer", func() { It("does not consume a slot for raw streams", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") // First, saturate the single transcode slot. From 59b6755014be0ec7e722ec94819723b28875e403 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 19:25:26 -0300 Subject: [PATCH 49/60] chore(deps): update dependencies to latest versions in go.mod and go.sum Signed-off-by: Deluan --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 937cffbd5..29a415126 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/gen2brain/webp v0.5.5 - github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.4.0 @@ -39,8 +39,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.3 - github.com/onsi/gomega v1.40.0 + github.com/onsi/ginkgo/v2 v2.29.0 + github.com/onsi/gomega v1.41.0 github.com/pelletier/go-toml/v2 v2.3.1 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 @@ -59,10 +59,10 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.40.0 - golang.org/x/net v0.54.0 + golang.org/x/image v0.41.0 + golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 @@ -81,7 +81,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.10.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect @@ -115,7 +115,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -133,7 +133,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect golang.org/x/tools v0.45.0 // indirect diff --git a/go.sum b/go.sum index 0c550e47d..57289abfd 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -73,8 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= @@ -193,10 +193,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= -github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= -github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= +github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -224,8 +224,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= @@ -316,10 +316,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= -golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -338,8 +338,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -364,8 +364,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= From 2a43c4683ea41492a6ad8c6b22af9a1f7eacb1e9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 22:09:54 -0300 Subject: [PATCH 50/60] chore: go fix Signed-off-by: Deluan --- cmd/inspect.go | 6 +++--- core/artwork/benchmark_e2e_test.go | 2 +- core/artwork/benchmark_helpers_test.go | 4 ++-- core/ffmpeg/ffmpeg.go | 4 ++-- core/share.go | 2 +- log/journal.go | 2 +- model/tag_mappings.go | 4 ++-- persistence/sql_search_fts.go | 12 ++++++------ persistence/sql_search_like.go | 2 +- plugins/host_taskqueue.go | 5 ++--- plugins/host_taskqueue_test.go | 14 +++++++------- scheduler/crontab_schedule_test.go | 2 +- server/subsonic/api_test.go | 2 +- server/throttle_backlog.go | 5 ++--- utils/cache/benchmark_test.go | 4 ++-- 15 files changed, 34 insertions(+), 36 deletions(-) diff --git a/cmd/inspect.go b/cmd/inspect.go index 9f9270b1e..5e88793cc 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{ }, } -var marshalers = map[string]func(interface{}) ([]byte, error){ +var marshalers = map[string]func(any) ([]byte, error){ "pretty": prettyMarshal, "toml": toml.Marshal, "yaml": yaml.Marshal, "json": json.Marshal, - "jsonindent": func(v interface{}) ([]byte, error) { + "jsonindent": func(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }, } -func prettyMarshal(v interface{}) ([]byte, error) { +func prettyMarshal(v any) ([]byte, error) { out := v.([]core.InspectOutput) var res strings.Builder for i := range out { diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index 393cbb473..bf3d435a8 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() r, _, err := aw.Get(context.Background(), artID, 300, true) diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index 60990bb8b..0076506f3 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte { // generateGradientImage creates an RGBA image with a diagonal gradient pattern. func generateGradientImage(width, height int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { + for y := range height { + for x := range width { r := uint8((x * 255) / width) g := uint8((y * 255) / height) b := uint8(((x + y) * 255) / (width + height)) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index a3f6cd7d2..58e9fd152 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -496,8 +496,8 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) // instead of decoding all frames up to the offset (slow). insertAt := len(args) - for i := len(args) - 1; i >= 0; i-- { - if args[i] == "-i" { + for i, arg := range slices.Backward(args) { + if arg == "-i" { insertAt = i break } diff --git a/core/share.go b/core/share.go index eb9b63ae9..5a611c7f0 100644 --- a/core/share.go +++ b/core/share.go @@ -98,7 +98,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } - firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] + firstId, _, _ := strings.Cut(s.ResourceIDs, ",") v, err := model.GetEntityByID(r.ctx, r.ds, firstId) if err != nil { return "", err diff --git a/log/journal.go b/log/journal.go index f1c17d2e7..dd7cf5400 100644 --- a/log/journal.go +++ b/log/journal.go @@ -36,6 +36,6 @@ func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) { if !ok { priority = 6 // default to info for unknown levels } - prefix := []byte(fmt.Sprintf("<%d>", priority)) + prefix := fmt.Appendf(nil, "<%d>", priority) return append(prefix, formatted...), nil } diff --git a/model/tag_mappings.go b/model/tag_mappings.go index af76de741..dd19a157b 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -47,8 +47,8 @@ func (c TagConf) SplitTagValue(values []string) []string { tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) // Split by the zero-width space and trim each substring. - parts := strings.Split(tag, consts.Zwsp) - for _, part := range parts { + parts := strings.SplitSeq(tag, consts.Zwsp) + for part := range parts { result = append(result, strings.TrimSpace(part)) } } diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index e9b961d91..b90dc937b 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -66,7 +66,7 @@ func normalizeForFTS(values ...string) string { result = append(result, variant) } for _, v := range values { - for _, word := range strings.Fields(v) { + for word := range strings.FieldsSeq(v) { transliterated := sanitize.Accents(word) // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) @@ -279,9 +279,9 @@ type ftsSearch struct { } // ToSql returns a single-query fallback for the REST filter path (no two-phase split). -func (s *ftsSearch) ToSql() (string, []interface{}, error) { +func (s *ftsSearch) ToSql() (string, []any, error) { sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)" - return sql, []interface{}{s.matchExpr}, nil + return sql, []any{s.matchExpr}, nil } // execute runs a two-phase FTS5 search: @@ -373,8 +373,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Check if all effective FTS tokens are very short (≤2 chars). // Short tokens with prefix matching are too broad when special chars were stripped. // For quoted phrases, extract the content and check the tokens inside. - tokens := strings.Fields(ftsQuery) - for _, t := range tokens { + tokens := strings.FieldsSeq(ftsQuery) + for t := range tokens { t = strings.TrimSuffix(t, "*") // Skip internal phrase placeholders if strings.HasPrefix(t, "\x00") { @@ -390,7 +390,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Extract content between quotes inner := strings.Trim(t, `"`) innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ") - for _, it := range strings.Fields(innerAlpha) { + for it := range strings.FieldsSeq(innerAlpha) { if len(it) > 2 { return false } diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go index 769a911d5..972545ac5 100644 --- a/persistence/sql_search_like.go +++ b/persistence/sql_search_like.go @@ -16,7 +16,7 @@ type likeSearch struct { filter Sqlizer } -func (s *likeSearch) ToSql() (string, []interface{}, error) { +func (s *likeSearch) ToSql() (string, []any, error) { return s.filter.ToSql() } diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index eff73c822..a5db3344f 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path/filepath" "sync" @@ -540,9 +541,7 @@ func (s *taskQueueServiceImpl) cleanupLoop() { func (s *taskQueueServiceImpl) runCleanup() { s.mu.Lock() queues := make(map[string]*queueState, len(s.queues)) - for k, v := range s.queues { - queues[k] = v - } + maps.Copy(queues, s.queues) s.mu.Unlock() now := time.Now().UnixMilli() diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index 8a58f1eb4..faff79c8e 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -367,8 +367,8 @@ var _ = Describe("TaskQueueService", func() { // Enqueue several more tasks — they stay pending since the worker is busy var pendingIDs []string - for i := 0; i < 3; i++ { - taskID, err := service.Enqueue(ctx, "clear-test", []byte(fmt.Sprintf("task-%d", i))) + for i := range 3 { + taskID, err := service.Enqueue(ctx, "clear-test", fmt.Appendf(nil, "task-%d", i)) Expect(err).ToNot(HaveOccurred()) pendingIDs = append(pendingIDs, taskID) } @@ -674,8 +674,8 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) // Enqueue 5 tasks - for i := 0; i < 5; i++ { - _, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i))) + for i := range 5 { + _, err := service.Enqueue(ctx, "delay-concurrent", fmt.Appendf(nil, "task-%d", i)) Expect(err).ToNot(HaveOccurred()) } @@ -1112,7 +1112,7 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // the second will be dequeued but block on the rate limiter (status=running), // the rest will stay pending. var taskIDs []string - for i := 0; i < 5; i++ { + for range 5 { output, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-cancel", @@ -1186,11 +1186,11 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Enqueue several tasks - for i := 0; i < 4; i++ { + for i := range 4 { _, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-clear", - Payload: []byte(fmt.Sprintf("task-%d", i)), + Payload: fmt.Appendf(nil, "task-%d", i), }) Expect(err).ToNot(HaveOccurred()) } diff --git a/scheduler/crontab_schedule_test.go b/scheduler/crontab_schedule_test.go index b1e26f1de..b616f0884 100644 --- a/scheduler/crontab_schedule_test.go +++ b/scheduler/crontab_schedule_test.go @@ -185,7 +185,7 @@ var _ = Describe("ParseCrontab", func() { // findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63). func findSetBit(v uint64) int { v &^= 1 << 63 // clear starBit - for i := 0; i < 63; i++ { + for i := range 63 { if v&(1< 0 { w.WriteHeader(buf.code) } diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index e3fc08eda..9ab07cf18 100644 --- a/utils/cache/benchmark_test.go +++ b/utils/cache/benchmark_test.go @@ -116,7 +116,7 @@ func BenchmarkConcurrentCacheRead(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) @@ -152,7 +152,7 @@ func BenchmarkConcurrentCacheMiss(b *testing.B) { wg.Add(n) // All goroutines request the SAME key (not yet cached) item := &benchItem{key: fmt.Sprintf("miss-%d", i)} - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) From dad4203f9a93225a17d5474318372ae0e4a9069f Mon Sep 17 00:00:00 2001 From: Tales Costa Date: Tue, 2 Jun 2026 09:38:57 -0300 Subject: [PATCH 51/60] fix(ui): Gruvbox Dark colors (#5553) * Add Gruvbox Dark theme Add Gruvbox Dark color theme including: - gruvboxDark.js with full palette and component overrides - gruvboxDark.css.js with custom player styles * Fix: move error state to MuiFormHelperText --- ui/src/themes/gruvboxDark.css.js | 9 ++++- ui/src/themes/gruvboxDark.js | 65 ++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/ui/src/themes/gruvboxDark.css.js b/ui/src/themes/gruvboxDark.css.js index dc1f64041..f482451b2 100644 --- a/ui/src/themes/gruvboxDark.css.js +++ b/ui/src/themes/gruvboxDark.css.js @@ -5,7 +5,7 @@ const stylesheet = ` } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #458588 + background-color: #ebdbb2 } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { @@ -50,6 +50,13 @@ const stylesheet = ` .MuiCheckbox-colorSecondary.Mui-checked { color: #458588 !important } +.react-jinke-music-player-main .music-player-panel svg { + color: #ebdbb2; + fill: #ebdbb2; +} +.react-jinke-music-player-main .music-player-panel button { + color: #ebdbb2; +} ` export default stylesheet diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 20f5c732f..0f4cbd7c4 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -14,22 +14,34 @@ export default { background: { default: '#282828', }, + text: { + primary: '#ebdbb2', + secondary: '#a89984', + }, }, overrides: { MuiPaper: { root: { color: '#ebdbb2', backgroundColor: '#3c3836', - MuiSnackbarContent: { - root: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - message: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - }, + }, + }, + MuiSnackbarContent: { + root: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + message: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + }, + MuiTypography: { + root: { + color: '#ebdbb2', + }, + colorTextSecondary: { + color: '#a89984', }, }, MuiButton: { @@ -45,6 +57,19 @@ export default { color: '#ebdbb2', }, }, + MuiListItemIcon: { + root: { + color: '#ebdbb2', + }, + }, + MuiListItemText: { + primary: { + color: '#ebdbb2', + }, + secondary: { + color: '#a89984', + }, + }, MuiChip: { clickable: { background: '#49483e', @@ -57,11 +82,10 @@ export default { }, MuiFormHelperText: { root: { - Mui: { - error: { - color: '#cc241d', - }, - }, + color: '#ebdbb2', + }, + error: { + color: '#cc241d', }, }, MuiTableHead: { @@ -113,6 +137,17 @@ export default { 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', }, }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: '#ebdbb2', + }, + albumSubtitle: { + color: '#a89984', + }, + }, }, player: { theme: 'dark', From bc107d1ceed30f78abc1c5fb3d1a601a4f099e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 3 Jun 2026 20:03:08 -0400 Subject: [PATCH 52/60] fix(scrobbler): proxy NowPlaying even when ignoreScrobble is set (#5559) * fix(scrobbler): proxy NowPlaying even when ignoreScrobble is set When a client reports playback with ignoreScrobble=true, the reportPlayback handler suppressed both the scrobble submission and the NowPlaying update sent to external agents (Last.fm, ListenBrainz, plugins). These are independent concerns: ignoring the scrobble submission should not stop Navidrome from telling external services what is currently playing. The !params.IgnoreScrobble guard now applies only to the scrobble submission and play-count path; the NowPlaying dispatch is gated solely by the player's ScrobbleEnabled flag. This mirrors the legacy scrobble endpoint, where submission=false has always still set NowPlaying. * test(scrobbler): assert no scrobble dispatch when ignoreScrobble=true Address PR review feedback: explicitly verify that ignoreScrobble=true suppresses the scrobble submission (not just the play count) while NowPlaying is still dispatched, so the flag cannot regress into ignoring nothing. Also expand the NowPlaying gating comment to spell out the IgnoreScrobble vs ScrobbleEnabled rules and identify the external agents involved. --- core/scrobbler/play_tracker.go | 9 ++++++++- core/scrobbler/play_tracker_test.go | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index bdb261ef2..860a80bce 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - if !params.IgnoreScrobble && player.ScrobbleEnabled && + // NowPlaying gating, by design distinct from scrobble submission: + // - IgnoreScrobble=true -> still send NowPlaying (suppresses only the + // scrobble submission/play-count above), mirroring the legacy scrobble + // endpoint's submission=false behavior. + // - player.ScrobbleEnabled=false -> never send NowPlaying. + // External agents here are the active scrobblers (Last.fm, ListenBrainz, and + // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying. + if player.ScrobbleEnabled && (params.State == StateStarting || params.State == StatePlaying) { if info, err := p.playMap.Get(clientId); err == nil { p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 5383244cd..b5a478c2a 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -521,6 +521,7 @@ var _ = Describe("PlayTracker", func() { }) It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + fake.ScrobbleCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) @@ -531,6 +532,7 @@ var _ = Describe("PlayTracker", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) + Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse()) }) It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { @@ -715,14 +717,14 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) - It("does NOT dispatch when ignoreScrobble=true", func() { + It("still dispatches NowPlaying when ignoreScrobble=true", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("does NOT dispatch when ScrobbleEnabled=false", func() { From 37908d3cead9d982a1075b5f3fdd3314d32a87e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 4 Jun 2026 19:43:13 -0400 Subject: [PATCH 53/60] fix: enforce ownership atomically on player and share updates (#5563) * fix(player): enforce ownership atomically on player update The native API PUT /api/player/{id} authorized writes using the userId in the request body via isPermitted, while the actual write targeted the row by the URL id. A non-admin user could set userId to their own id in the body to pass the check, then overwrite and reassign ownership of another user's player row identified by the URL id (cross-tenant takeover). Add updateOwned on the base repository: an atomic, ownership-restricted UPDATE that folds the owner predicate (user_id = caller) into the WHERE clause for non-admins, so a row owned by another user simply does not match and no write happens. It also never writes user_id, so ownership is immutable on update and no caller (admin included) can reassign a player to a different owner. Unlike put, it never falls through to an INSERT, so a non-matching id returns ErrNotFound instead of creating a row. playerRepository.Update now uses updateOwned. Extract filterUpdateValues, shared by put and updateOwned, so the update-column filtering lives in one place. The create path (Save) keeps the body-based isPermitted check, which is correct for new records. Add regression tests covering the spoofed-userId hijack, regular-user and admin ownership reassignment, legitimate owner updates, and the nonexistent-player case. * fix(share): enforce ownership atomically on share update shareRepository.Update authorized writes with a separate checkOwnership SELECT, then wrote the row via put(). The check and the write were two statements (a TOCTOU window), put() could fall through to an INSERT on a missing id, and put() would write user_id if present in the update columns, so ownership was mutable on update. Switch Update to updateOwned, which folds the owner predicate into the UPDATE's WHERE clause, never writes user_id, and never inserts. This makes the write atomic and ownership immutable, and drops the extra ownership SELECT on the happy path. To preserve the previous 403/404 distinction, updateOwned now classifies a non-matching id: it runs a follow-up existence check only on the failure path (count == 0, where no write happened, so no TOCTOU) and returns ErrPermissionDenied when the row exists but is owned by another user, ErrNotFound when the id is missing. The player path inherits this: its tests now expect ErrPermissionDenied for a non-owner targeting an existing row, and ErrNotFound only for a genuinely missing id. Add share regression tests for the nonexistent-id and ownership- reassignment cases. checkOwnership remains in use by Delete. * refactor(persistence): extract canonical ownerFilter predicate The non-admin owner-restriction predicate (user_id = me, exempting admins and headless contexts) was spelled out independently in updateOwned and in playerRepository.addRestriction. The two copies had drifted: addRestriction did not exempt the headless/invalid user, so a headless context restricted to user_id = "-1" (matching nothing) while updateOwned exempted it. Extract sqlRepository.ownerFilter as the single definition and route both call sites through it. addRestriction now exempts the headless user too; that path is only reachable from the authenticated native API, so there is no production behavior change, but the latent divergence is removed. playlistRepository.userFilter is intentionally left alone: it encodes a different policy (public OR owner_id = me, on the owner_id column). * fix(share): preserve all-columns update path in Update shareRepository.Update unconditionally appended "updated_at" to cols. filterUpdateValues treats an empty cols as "update every column", so when a caller passes no columns, appending "updated_at" turned an all-columns update into an updated_at-only one, silently dropping every other field. The REST controller always populates cols from the request-body field names, so this path is not reachable through the native API and the behavior was latent (and pre-existing). Guard the append so the all-columns path is preserved, and add a regression test that updates with no columns and asserts the other fields persist. Signed-off-by: Deluan --------- Signed-off-by: Deluan --- persistence/player_repository.go | 20 +++--- persistence/player_repository_test.go | 87 +++++++++++++++++++++++- persistence/share_repository.go | 15 ++--- persistence/share_repository_test.go | 38 +++++++++++ persistence/sql_base_repository.go | 95 ++++++++++++++++++++++----- 5 files changed, 216 insertions(+), 39 deletions(-) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 6c8339378..c9c7d3b4b 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -67,11 +67,10 @@ func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { if len(sql) > 0 { s = append(s, sql[0]) } - u := loggedUser(r.ctx) - if u.IsAdmin { - return s + if owner := r.ownerFilter(); owner != nil { + s = append(s, owner) } - return append(s, Eq{"user_id": u.ID}) + return s } func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { @@ -125,6 +124,10 @@ func (r *playerRepository) NewInstance() any { return &model.Player{} } +// isPermitted authorizes creating a new record, based on the owner declared in the request body. +// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a +// player they own. Updates must not use this (the body owner is attacker-controlled); they go +// through updateOwned, which authorizes against the persisted user_id in the WHERE clause. func (r *playerRepository) isPermitted(p *model.Player) bool { u := loggedUser(r.ctx) return u.IsAdmin || p.UserId == u.ID @@ -145,14 +148,7 @@ func (r *playerRepository) Save(entity any) (string, error) { func (r *playerRepository) Update(id string, entity any, cols ...string) error { t := entity.(*model.Player) t.ID = id - if !r.isPermitted(t) { - return rest.ErrPermissionDenied - } - _, err := r.put(id, t, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.updateOwned(id, t, cols...) } func (r *playerRepository) Delete(id string) error { diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f6c669493..f640cd9a4 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -215,9 +215,12 @@ var _ = Describe("PlayerRepository", func() { clone.MaxBitRate = 10000 err := repo.Update(clone.ID, &clone, "ip") - if clone.UserId == "" { + if player.UserId == "" { Expect(err).To(HaveOccurred()) } else if !admin && player.Username == adminPlayer1.Username { + // A non-admin cannot target another user's player: the ownership-restricted + // update matches no owned row, so it reports permission-denied rather than + // touching it. Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.IP = player.IP } else { @@ -244,4 +247,86 @@ var _ = Describe("PlayerRepository", func() { Entry("admin context", true, players, adminPlayer1, regularPlayer), Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), ) + + Describe("Ownership enforcement (cross-tenant write protection)", func() { + var regularRepo *playerRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + regularRepo = NewPlayerRepository(ctx, database).(*playerRepository) + }) + + It("does not let a regular user hijack another user's player by spoofing userId in the body", func() { + // Attacker (regularUser) targets the victim's (adminUser) player by URL id, + // but sets userId in the body to their own id to try to pass the permission check. + spoofed := model.Player{ + ID: adminPlayer1.ID, + Name: "HIJACKED", + UserId: regularUser.ID, // attacker's own id, spoofed in the body + MaxBitRate: 1, + } + + // The ownership-restricted update matches no row owned by the attacker, so the write + // targets nothing and reports permission-denied rather than overwriting the victim's row. + err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The victim's player must remain untouched. + stored, err := adminRepo.Get(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*stored).To(Equal(adminPlayer1)) + }) + + It("does not let a regular user reassign their own player to another user", func() { + // Owner updates their own player but tries to give it away to the admin. The update + // succeeds for the other fields, but user_id is never written, so ownership stays put. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "given-away" + + err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // Ownership must not have changed. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("does not let an admin reassign a player to another user", func() { + // Even an admin cannot change a player's owner via update. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "admin-renamed" + + err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // The name change applies, but ownership must not have moved. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("admin-renamed")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("lets the owner update their own player", func() { + update := regularPlayer + update.Name = "renamed-by-owner" + + err := regularRepo.Update(regularPlayer.ID, &update, "name") + Expect(err).To(BeNil()) + + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("renamed-by-owner")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("returns not found when updating a nonexistent player", func() { + ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID} + err := regularRepo.Update("does-not-exist", &ghost, "name") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + }) }) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 415109640..bcd13ff3e 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -53,6 +53,10 @@ func (r *shareRepository) checkOwnership(id string) error { return nil } +// TODO: this still uses the legacy checkOwnership SELECT-then-delete pattern (a TOCTOU window), +// the same shape removed from Update. Once a base-repo deleteOwned exists (built on ownerFilter, +// mirroring updateOwned), route Delete through it and drop checkOwnership entirely. playerRepository +// .Delete (which restricts via addRestriction) should adopt the same primitive. func (r *shareRepository) Delete(id string) error { if err := r.checkOwnership(id); err != nil { return err @@ -166,17 +170,12 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { func (r *shareRepository) Update(id string, entity any, cols ...string) error { s := entity.(*model.Share) - if err := r.checkOwnership(id); err != nil { - return err - } s.ID = id s.UpdatedAt = time.Now() - cols = append(cols, "updated_at") - _, err := r.put(id, s, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound + if len(cols) > 0 { + cols = append(cols, "updated_at") } - return err + return r.updateOwned(id, s, cols...) } func (r *shareRepository) Save(entity any) (string, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 6988f323f..dcc84d66f 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -216,6 +216,44 @@ var _ = Describe("ShareRepository", func() { err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) + + It("returns not found when updating a nonexistent share", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + + It("updates all columns when no specific columns are given", func() { + insertShare("all-cols-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + // No cols: the update must write every column, not just updated_at. + err := repo.(rest.Persistable).Update("all-cols-share", + &model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) + Expect(err).ToNot(HaveOccurred()) + + got, err := repo.(rest.Repository).Read("all-cols-share") + Expect(err).ToNot(HaveOccurred()) + share := got.(*model.Share) + Expect(share.Description).To(Equal("All Updated")) + Expect(share.MaxBitRate).To(Equal(192)) + Expect(share.ResourceType).To(Equal("album")) + }) + + It("does not let an owner reassign their share to another user", func() { + insertShare("reassign-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("reassign-share", + &model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description") + Expect(err).ToNot(HaveOccurred()) + + // Ownership must not have moved, even though user_id was passed in the body and cols. + got, err := repo.(rest.Repository).Read("reassign-share") + Expect(err).ToNot(HaveOccurred()) + Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) + }) }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index fd263d37b..55e83d544 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -13,6 +13,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -57,6 +58,16 @@ func loggedUser(ctx context.Context) *model.User { } } +// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for +// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid +// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. +func (r sqlRepository) ownerFilter() Sqlizer { + if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { + return Eq{"user_id": usr.ID} + } + return nil +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -382,6 +393,47 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) { return res.Exist > 0, err } +// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only update rows they own: the +// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply +// does not match and no write happens. Ownership itself is immutable here: user_id is never written, +// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put, +// it never falls through to an INSERT, so a non-matching id never creates a row. +// +// When the update matches no row it classifies the failure: if the row exists but is owned by +// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is +// still atomic; the extra lookup happens only on the failure path (count == 0), where no write +// occurred, so there is no TOCTOU on the update. +func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error { + values, err := toSQLArgs(m) + if err != nil { + return fmt.Errorf("error preparing values to write to DB: %w", err) + } + updateValues := filterUpdateValues(values, id, colsToUpdate...) + delete(updateValues, "user_id") // ownership is immutable on update + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + if owner := r.ownerFilter(); owner != nil { + update = update.Where(owner) + } + count, err := r.executeSQL(update) + if err != nil { + return err + } + if count == 0 { + // The update matched no row: either the id is missing, or it exists but is owned by + // someone else. Disambiguate to return the more accurate error. + exists, err := r.exists(Eq{"id": id}) + if err != nil { + return err + } + if exists { + return rest.ErrPermissionDenied + } + return rest.ErrNotFound + } + return nil +} + func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). @@ -408,6 +460,30 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } +// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the +// row identified by id: only the requested colsToUpdate (or all columns when none are specified), +// dropping columns that must never be overwritten on update (created_at, birth_time). +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := map[string]any{} + + // This is a map of the columns that need to be updated, if specified + c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { + return toSnakeCase(s), struct{}{} + }) + for k, v := range values { + if _, found := c2upd[k]; len(c2upd) == 0 || found { + updateValues[k] = v + } + } + + updateValues["id"] = id + delete(updateValues, "created_at") + // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now + // TODO move to mediafile_repository when each repo has its own upsert method + delete(updateValues, "birth_time") + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { @@ -415,24 +491,7 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri } // If there's an ID, try to update first if id != "" { - updateValues := map[string]any{} - - // This is a map of the columns that need to be updated, if specified - c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { - return toSnakeCase(s), struct{}{} - }) - for k, v := range values { - if _, found := c2upd[k]; len(c2upd) == 0 || found { - updateValues[k] = v - } - } - - updateValues["id"] = id - delete(updateValues, "created_at") - // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now - // TODO move to mediafile_repository when each repo has its own upsert method - delete(updateValues, "birth_time") - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...)) count, err := r.executeSQL(update) if err != nil { return "", err From 11640f2e4d2e807bce3b473474a35af96dd4e9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 4 Jun 2026 23:07:13 -0400 Subject: [PATCH 54/60] fix: restrict transcoding config reads to admins (#5564) * fix(security): restrict transcoding config reads to admins Authenticated non-admin users could read transcoding configs through the native API (GET /api/transcoding and /api/transcoding/{id}) when EnableTranscodingConfig was enabled. The responses included the full command templates, disclosing admin-configured ffmpeg invocations and local command paths. Write operations were already admin-only. The /transcoding route was registered in the general authenticated group, and only the repository's write methods checked IsAdmin. This applies the boundary at two layers: - Move the route under adminOnlyMiddleware, alongside the other admin-only resources (/library, /config, /inspect). - Add an IsAdmin guard to the repository's rest.Repository read methods (Read, ReadAll, Count) as defense-in-depth. The guard is scoped to the REST methods only. The streaming pipeline resolves profiles via Get/FindByFormat (model.TranscodingRepository), which stay open so transcoding keeps working for non-admin users. Adds regression tests covering non-admin read denial and confirming non-admin streaming lookups (Get/FindByFormat) still succeed. * fix(security): redact transcoding Command for non-admins instead of blocking reads Reworks the previous approach after review (Codex P2): moving /transcoding under adminOnlyMiddleware and denying non-admin reads broke legitimate non-admin UI flows. The web UI reads the transcoding resource as a regular user in several places that need only the profile name and target format: the player edit dropdown (ReferenceInput), the player list (ReferenceField), and the share/download format pickers (useGetList -> {targetFormat, name}). The only sensitive field is Command (the admin-owned ffmpeg template). So: - Revert the route move; /transcoding stays in the authenticated group. - Read/ReadAll now return the profiles to any authenticated user but blank the Command field for non-admins (mirrors user_repository's field-level redaction). Count is no longer denied (the UI needs list pagination). - Writes remain admin-only (Save/Update/Delete/Put). - Streaming is unaffected: it resolves profiles via Get/FindByFormat, which are not redacted, so on-the-fly transcoding keeps working for non-admins. Tests updated: non-admin reads succeed with Command blank, admin reads keep Command, non-admin Get/FindByFormat keep Command, writes still denied. --- persistence/transcoding_repository.go | 19 ++++++- persistence/transcoding_repository_test.go | 60 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 870da61c8..96fd3efdb 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -53,14 +53,29 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro } func (r *transcodingRepository) Read(id string) (any, error) { - return r.Get(id) + res, err := r.Get(id) + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + res.Command = "" + } + return res, nil } func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") res := model.Transcodings{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + for i := range res { + res[i].Command = "" + } + } + return res, nil } func (r *transcodingRepository) EntityName() string { diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index eddc5047a..73250163c 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -64,9 +64,69 @@ var _ = Describe("TranscodingRepository", func() { _, err = adminRepo.Get("to-delete") Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("reads the Command field via the REST Read method", func() { + tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := adminRepo.(*transcodingRepository).Read("adminread") + Expect(err).ToNot(HaveOccurred()) + Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret")) + }) }) Describe("Regular User", func() { + It("reads a transcoding but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).Read("readreg") + Expect(err).ToNot(HaveOccurred()) + t := res.(*model.Transcoding) + Expect(t.Name).To(Equal("temp")) + Expect(t.TargetFormat).To(Equal("test_format")) + Expect(t.Command).To(BeEmpty()) + }) + + It("lists transcodings but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).ReadAll() + Expect(err).ToNot(HaveOccurred()) + list := res.(model.Transcodings) + Expect(list).ToNot(BeEmpty()) + for _, t := range list { + Expect(t.Command).To(BeEmpty()) + } + }) + + It("counts transcodings", func() { + count, err := repo.(*transcodingRepository).Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically(">=", 0)) + }) + + It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() { + tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.Get("streamreg") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("streamreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + + It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() { + tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.FindByFormat("test_format") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("fmtreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + It("fails to create", func() { err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) Expect(err).To(Equal(rest.ErrPermissionDenied)) From cf1f190bb57d1e8137f805553e9479031c2103f5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 5 Jun 2026 08:14:00 -0400 Subject: [PATCH 55/60] fix(subsonic): use SQLite RANDOM() sorting in getRandomSongs, for faster results Related to #5558 Signed-off-by: Deluan --- server/subsonic/filter/filters.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 8ba4f0ff9..856870a6c 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -92,7 +92,7 @@ func SongsByAlbum(albumId string) Options { func SongsByRandom(genre string, fromYear, toYear int) Options { options := Options{ - Sort: "random", + Sort: "random()", } ff := And{} if genre != "" { From 174621f2595ac9d2567de5296353acfab5182448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 5 Jun 2026 13:54:55 -0400 Subject: [PATCH 56/60] fix(nativeapi): make /api/song path filter work and use startsWith (#5566) The native API exposes a `path` query param on /api/song, but it was not registered in the media file filter map. Unmapped real columns fall through to a default LIKE predicate that emits an unqualified `path LIKE ?`. Since the song query joins the library table (which also has a `path` column), SQLite returned "ambiguous column name: path" and the request failed with HTTP 500. Register a dedicated path filter qualified to media_file.path, resolving the ambiguity. The value is matched with startsWith semantics (LIKE arg || '%') against the library-relative path stored in media_file.path. To register it inline (without a one-off wrapper), startsWithFilter now takes a bound field and returns a filterFunc, mirroring containsFilter. The two existing callers are updated accordingly, and the now-unused withTableName helper is removed. The user 'name' filter, which previously relied on withTableName, is now qualified directly as user.name; tests are added to guard that filter against the same column-ambiguity class (the user query also joins the library table, which has a name column). Signed-off-by: Deluan --- persistence/mediafile_repository.go | 1 + persistence/mediafile_repository_test.go | 28 ++++++++++++++ persistence/sql_base_repository.go | 9 ----- persistence/sql_restful.go | 8 ++-- persistence/user_repository.go | 2 +- persistence/user_repository_test.go | 47 ++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 13 deletions(-) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 264778ea0..559378262 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -104,6 +104,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "missing": booleanFilter, "artists_id": artistFilter, "library_id": libraryIdFilter, + "path": startsWithFilter("media_file.path"), } // Add all album tags as filters for tag := range model.TagMappings() { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 464d88288..2bc9d0267 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -524,6 +524,34 @@ var _ = Describe("MediaRepository", func() { } }) }) + + Describe("path", func() { + It("matches files whose path starts with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "test/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + + var found bool + for _, f := range files { + Expect(f.Path).To(HavePrefix("test/")) + if f.ID == mfWithoutAnnotation.ID { + found = true + } + } + Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included") + }) + + It("excludes files whose path does not start with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "no-such-prefix/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + Expect(files).To(BeEmpty()) + }) + }) }) Describe("Search", func() { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 55e83d544..c2ba4e073 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -197,15 +197,6 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti return sq } -func (r *sqlRepository) withTableName(filter filterFunc) filterFunc { - return func(field string, value any) Sqlizer { - if r.tableName != "" { - field = r.tableName + "." + field - } - return filter(field, value) - } -} - // libraryIdFilter is a filter function to be added to resources that have a library_id column. func libraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_id": value} diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 02162387c..1dcabcec6 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -46,7 +46,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query continue } // Default to a "starts with" filter - filters = append(filters, startsWithFilter(f, v)) + filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)}) } return filters } @@ -91,8 +91,10 @@ func eqFilter(field string, value any) Sqlizer { return Eq{field: value} } -func startsWithFilter(field string, value any) Sqlizer { - return Like{field: fmt.Sprintf("%s%%", value)} +func startsWithFilter(field string) func(string, any) Sqlizer { + return func(_ string, value any) Sqlizer { + return Like{field: fmt.Sprintf("%s%%", value)} + } } func containsFilter(field string) func(string, any) Sqlizer { diff --git a/persistence/user_repository.go b/persistence/user_repository.go index dc149e8ba..9decff4e5 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -59,7 +59,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r.registerModel(&model.User{}, map[string]filterFunc{ "id": idFilter(r.tableName), "password": invalidFilter(ctx), - "name": r.withTableName(startsWithFilter), + "name": startsWithFilter(r.tableName + ".name"), }) once.Do(func() { _ = r.initPasswordEncryptionKey() diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 8abbf76a9..6f8ab9161 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -207,6 +208,52 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("ReadAll name filter", func() { + var adminRepo model.ResourceRepository + + BeforeEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) + adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository) + + for _, u := range []model.User{ + {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"}, + {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"}, + } { + Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed()) + } + }) + + AfterEach(func() { + ur := adminRepo.(model.UserRepository) + _ = ur.Delete("filter-alice") + _ = ur.Delete("filter-bob") + }) + + It("matches users whose name starts with the given prefix", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + var names []string + for _, u := range users { + names = append(names, u.Name) + } + Expect(names).To(ContainElement("Alice Filter")) + Expect(names).ToNot(ContainElement("Bob Filter")) + }) + + It("does not match names by mid-string substring (startsWith, not contains)", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + for _, u := range users { + Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")), + "a mid-string substring should not match a startsWith filter") + } + }) + }) + Describe("validateUsernameUnique", func() { var repo *tests.MockedUserRepo var existingUser *model.User From 1e7996f5d708b0b2768688b62dae9354a22b427a Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 5 Jun 2026 15:50:59 -0400 Subject: [PATCH 57/60] fix(share): enforce per-user ownership on share reads Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count, CountAll) did not apply an owner filter, so non-admin users saw shares belonging to other users. The write paths already enforced per-user ownership; this brings reads in line with them. Add an addRestriction()/ownerFilter() based scope to share reads, keeping admins and the headless public-share resolution path unrestricted. Route share and player Delete through a new base-repo deleteOwned() primitive that applies the ownership predicate in the DELETE's WHERE clause (atomic, no select-then- delete window) and classifies a zero-row result as permission-denied vs not-found, mirroring updateOwned. The addRestriction helper and the write-miss classifier are hoisted onto the base repository so player and share share one implementation. Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error handler so ownership/not-found failures from the rest-backed repositories return the proper Subsonic codes (50 / 70) instead of a generic error. Covered by unit tests (persistence, subsonic error mapping) and an end-to-end cross-user sharing isolation test. --- persistence/player_repository.go | 18 +-- persistence/player_repository_test.go | 46 +++++--- persistence/share_repository.go | 41 +------ persistence/share_repository_test.go | 160 ++++++++++++++++++++++++-- persistence/sql_base_repository.go | 63 +++++++--- server/e2e/subsonic_sharing_test.go | 79 +++++++++++++ server/subsonic/api.go | 5 +- server/subsonic/api_test.go | 24 ++++ 8 files changed, 336 insertions(+), 100 deletions(-) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index c9c7d3b4b..353b0444f 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -62,17 +62,6 @@ func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBu return s.Where(r.addRestriction()) } -func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { - s := And{} - if len(sql) > 0 { - s = append(s, sql[0]) - } - if owner := r.ownerFilter(); owner != nil { - s = append(s, owner) - } - return s -} - func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { sel := r.newSelect(options...). Columns( @@ -152,12 +141,7 @@ func (r *playerRepository) Update(id string, entity any, cols ...string) error { } func (r *playerRepository) Delete(id string) error { - filter := r.addRestriction(And{Eq{"player.id": id}}) - err := r.delete(filter) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } var _ model.PlayerRepository = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f640cd9a4..b7085a1fb 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -110,33 +110,43 @@ var _ = Describe("PlayerRepository", func() { }) Describe("Delete", func() { - DescribeTable("item type", func(player model.Player) { - err := repo.Delete(player.ID) + It("deletes a player owned by the current user", func() { + err := repo.Delete(userPlayer.ID) Expect(err).To(BeNil()) - isReal := player.UserId != "" - canDelete := admin || player.UserId == userPlayer.UserId - count, err := repo.Count() Expect(err).To(BeNil()) + Expect(count).To(Equal(baseCount - 1)) - if isReal && canDelete { - Expect(count).To(Equal(baseCount - 1)) - } else { - Expect(count).To(Equal(baseCount)) - } + _, err = repo.Get(userPlayer.ID) + Expect(err).To(Equal(model.ErrNotFound)) + }) - item, err := repo.Get(player.ID) - if !isReal || canDelete { + It("does not delete another user's player when not admin", func() { + err := repo.Delete(otherPlayer.ID) + + if admin { + // Admins may delete any player. + Expect(err).To(BeNil()) + Expect(repo.Count()).To(Equal(baseCount - 1)) + _, err = repo.Get(otherPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) } else { - Expect(*item).To(Equal(player)) + // The ownership-restricted delete matches no owned row, so it reports + // permission-denied and leaves the other user's player untouched. + Expect(err).To(Equal(rest.ErrPermissionDenied)) + Expect(repo.Count()).To(Equal(baseCount)) + item, err := repo.Get(otherPlayer.ID) + Expect(err).To(BeNil()) + Expect(*item).To(Equal(otherPlayer)) } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), - ) + }) + + It("returns not-found for a nonexistent player", func() { + err := repo.Delete("i don't exist") + Expect(err).To(Equal(rest.ErrNotFound)) + Expect(repo.Count()).To(Equal(baseCount)) + }) }) Describe("Read", func() { diff --git a/persistence/share_repository.go b/persistence/share_repository.go index bcd13ff3e..89dc19e19 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -30,51 +30,18 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito return r } -// TODO: Ownership checks should be moved to the service layer (core/share.go) -func (r *shareRepository) checkOwnership(id string) error { - usr := loggedUser(r.ctx) - if usr.IsAdmin || usr.ID == invalidUserId { - return nil - } - sel := r.newSelect().Columns("user_id").Where(Eq{"id": id}) - var share struct { - UserID string `db:"user_id"` - } - err := r.queryOne(sel, &share) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err - } - if share.UserID != usr.ID { - return rest.ErrPermissionDenied - } - return nil -} - -// TODO: this still uses the legacy checkOwnership SELECT-then-delete pattern (a TOCTOU window), -// the same shape removed from Update. Once a base-repo deleteOwned exists (built on ownerFilter, -// mirroring updateOwned), route Delete through it and drop checkOwnership entirely. playerRepository -// .Delete (which restricts via addRestriction) should adopt the same primitive. func (r *shareRepository) Delete(id string) error { - if err := r.checkOwnership(id); err != nil { - return err - } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...).Join("user u on u.id = share.user_id"). - Columns("share.*", "user_name as username") + Columns("share.*", "user_name as username"). + Where(r.addRestriction()) } func (r *shareRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"id": id}) + return r.exists(r.addRestriction(And{Eq{"id": id}})) } func (r *shareRepository) Get(id string) (*model.Share, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index dcc84d66f..0b3ece598 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -20,7 +20,7 @@ var _ = Describe("ShareRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo = NewShareRepository(ctx, GetDBXBuilder()) // Insert the admin user into the database (required for foreign key constraint) @@ -38,7 +38,7 @@ var _ = Describe("ShareRepository", func() { Context("Repository creation and basic operations", func() { It("should create repository successfully with no user context", func() { // Create repository with no user context (headless) - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) Expect(headlessRepo).ToNot(BeNil()) }) @@ -60,7 +60,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should see all shares - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) shares, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) @@ -92,7 +92,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the share - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) share, err := headlessRepo.Get(shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) @@ -155,7 +155,7 @@ var _ = Describe("ShareRepository", func() { Describe("Delete", func() { It("allows a non-admin user to delete their own share", func() { insertShare("own-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("own-share-del") Expect(err).ToNot(HaveOccurred()) @@ -163,15 +163,21 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from deleting another user's share", func() { insertShare("other-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("other-share-del") Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The share was not deleted: the owner can still read it. + ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder()) + _, err = ownerRepo.(rest.Repository).Read("other-share-del") + Expect(err).ToNot(HaveOccurred()) }) It("allows an admin to delete any user's share", func() { insertShare("admin-del-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("admin-del-share") Expect(err).ToNot(HaveOccurred()) @@ -179,7 +185,7 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to delete a share", func() { insertShare("headless-del-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Delete("headless-del-share") Expect(err).ToNot(HaveOccurred()) }) @@ -188,7 +194,7 @@ var _ = Describe("ShareRepository", func() { Describe("Update", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -196,7 +202,7 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from updating another user's share", func() { insertShare("other-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description") Expect(err).To(Equal(rest.ErrPermissionDenied)) @@ -204,7 +210,7 @@ var _ = Describe("ShareRepository", func() { It("allows an admin to update any user's share", func() { insertShare("admin-upd-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -212,7 +218,7 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to update a share", func() { insertShare("headless-upd-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) @@ -255,5 +261,135 @@ var _ = Describe("ShareRepository", func() { Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) }) }) + + Describe("Read scoping", func() { + BeforeEach(func() { + // Persist owner/other users so the JOIN in selectShare resolves. + ur := NewUserRepository(ctx, GetDBXBuilder()) + Expect(ur.Put(&ownerUser)).To(Succeed()) + Expect(ur.Put(&otherUser)).To(Succeed()) + + insertShare("share-owner-1", ownerUser.ID) + insertShare("share-owner-2", ownerUser.ID) + insertShare("share-other-1", otherUser.ID) + }) + + Context("non-admin user", func() { + var nonAdminRepo model.ShareRepository + var nonAdminRest rest.Repository + + BeforeEach(func() { + nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder()) + nonAdminRest = nonAdminRepo.(rest.Repository) + }) + + It("GetAll returns only own shares", func() { + shares, err := nonAdminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("ReadAll returns only own shares", func() { + res, err := nonAdminRest.ReadAll() + Expect(err).ToNot(HaveOccurred()) + shares := res.(model.Shares) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("Get returns own share", func() { + s, err := nonAdminRepo.Get("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-owner-1")) + }) + + It("Get returns ErrNotFound for another user's share", func() { + _, err := nonAdminRepo.Get("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Read returns ErrNotFound for another user's share", func() { + _, err := nonAdminRest.Read("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Exists returns true for own share", func() { + exists, err := nonAdminRepo.Exists("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("Exists returns false for another user's share", func() { + exists, err := nonAdminRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("CountAll counts only own shares", func() { + count, err := nonAdminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + + It("Count (rest) counts only own shares", func() { + count, err := nonAdminRest.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + }) + + Context("admin user", func() { + It("GetAll returns all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + shares, err := adminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1")) + }) + + It("CountAll counts all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + count, err := adminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 3)) + }) + }) + + Context("headless context (public share route)", func() { + It("GetAll returns all shares", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + shares, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(shares).To(HaveLen(3)) + }) + + It("Get returns another user's share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + s, err := headlessRepo.Get("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-other-1")) + }) + + It("Exists returns true for any share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + exists, err := headlessRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) + }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index c2ba4e073..321e790db 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -61,6 +61,9 @@ func loggedUser(ctx context.Context) *model.User { // ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for // tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid // user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. +// +// The predicate uses an unqualified user_id, so it only works on queries where that column is +// unambiguous (no join introducing a second user_id). func (r sqlRepository) ownerFilter() Sqlizer { if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { return Eq{"user_id": usr.ID} @@ -68,6 +71,20 @@ func (r sqlRepository) ownerFilter() Sqlizer { return nil } +// addRestriction combines an optional caller predicate with the ownership filter, producing the +// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and +// only the caller's predicate (if any) remains. +func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer { + s := And{} + if len(sql) > 0 { + s = append(s, sql[0]) + } + if owner := r.ownerFilter(); owner != nil { + s = append(s, owner) + } + return s +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -402,29 +419,47 @@ func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) err } updateValues := filterUpdateValues(values, id, colsToUpdate...) delete(updateValues, "user_id") // ownership is immutable on update - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) - if owner := r.ownerFilter(); owner != nil { - update = update.Where(owner) - } + update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues) count, err := r.executeSQL(update) if err != nil { return err } if count == 0 { - // The update matched no row: either the id is missing, or it exists but is owned by - // someone else. Disambiguate to return the more accurate error. - exists, err := r.exists(Eq{"id": id}) - if err != nil { - return err - } - if exists { - return rest.ErrPermissionDenied - } - return rest.ErrNotFound + return r.classifyOwnedWriteMiss(id) } return nil } +// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only delete rows they own: the +// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply +// does not match and is left untouched. The failure path mirrors updateOwned (see +// classifyOwnedWriteMiss), so there is no TOCTOU on the delete. +func (r sqlRepository) deleteOwned(id string) error { + count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id}))) + if err != nil { + return err + } + if count == 0 { + return r.classifyOwnedWriteMiss(id) + } + return nil +} + +// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched +// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise +// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred. +func (r sqlRepository) classifyOwnedWriteMiss(id string) error { + exists, err := r.exists(Eq{"id": id}) + if err != nil { + return err + } + if exists { + return rest.ErrPermissionDenied + } + return rest.ErrNotFound +} + func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). diff --git a/server/e2e/subsonic_sharing_test.go b/server/e2e/subsonic_sharing_test.go index 1a082ba0f..03bf1f80f 100644 --- a/server/e2e/subsonic_sharing_test.go +++ b/server/e2e/subsonic_sharing_test.go @@ -125,3 +125,82 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + +var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { + var userA, userB model.User + var shareID string + var albumID string + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + userA = createUser("share-user-a", "share-user-a", "Share User A", false) + userB = createUser("share-user-b", "share-user-b", "Share User B", false) + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + shareID = resp.Shares.Share[0].ID + Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName)) + }) + + It("userB's getShares does not leak userA's share", func() { + resp := doReqWithUser(userB, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("userA still sees own share", func() { + resp := doReqWithUser(userA, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].ID).To(Equal(shareID)) + Expect(resp.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("admin sees userA's share", func() { + resp := doReqWithUser(adminUser, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + ids := make([]string, len(resp.Shares.Share)) + for i, s := range resp.Shares.Share { + ids[i] = s.ID + } + Expect(ids).To(ContainElement(shareID)) + }) + + It("userB cannot updateShare on userA's share", func() { + resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm description unchanged for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("userB cannot deleteShare on userA's share", func() { + resp := doReqWithUser(userB, "deleteShare", "id", shareID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm share still present for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].ID).To(Equal(shareID)) + }) +}) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index f39dec009..82e404228 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,6 +9,7 @@ import ( "regexp" "strconv" + "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" @@ -301,9 +302,9 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorMissingParameter, err.Error()) case errors.Is(err, req.ErrInvalidParam): err = newError(responses.ErrorGeneric, err.Error()) - case errors.Is(err, model.ErrNotFound): + case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied): err = newError(responses.ErrorAuthorizationFail) case errors.Is(err, stream.ErrTooManyTranscodes): err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index b565109a5..f8d5b6642 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "encoding/xml" + "errors" "fmt" "math" "net/http" "net/http/httptest" "strings" + "github.com/deluan/rest" "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -187,3 +190,24 @@ var _ = Describe("sendResponse", func() { Expect(pointer).To(Equal(responses.ErrorDataNotFound)) }) }) + +var _ = Describe("mapToSubsonicError", func() { + DescribeTable("maps repository errors to the correct Subsonic error code", + func(err error, expectedCode int32) { + subErr := mapToSubsonicError(err) + Expect(subErr.code).To(Equal(expectedCode)) + }, + Entry("rest.ErrPermissionDenied -> not authorized (50)", + rest.ErrPermissionDenied, responses.ErrorAuthorizationFail), + Entry("rest.ErrNotFound -> data not found (70)", + rest.ErrNotFound, responses.ErrorDataNotFound), + Entry("model.ErrNotAuthorized -> not authorized (50)", + model.ErrNotAuthorized, responses.ErrorAuthorizationFail), + Entry("model.ErrNotFound -> data not found (70)", + model.ErrNotFound, responses.ErrorDataNotFound), + Entry("wrapped rest.ErrPermissionDenied is still mapped", + fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail), + Entry("unknown error -> generic (0)", + errors.New("boom"), responses.ErrorGeneric), + ) +}) From fb61827ab6c23ccbcb2096a581b07496ee3820c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 5 Jun 2026 18:06:52 -0400 Subject: [PATCH 58/60] test: fix flaky tests in utils/cache (#5567) * test: fix flaky tests in utils/cache Two tests in the utils/cache suite were timing- and ordering-dependent and failed intermittently on CI (notably on the Windows runner). The FileHaunter tests raced the asynchronous cache-cleanup goroutine with a fixed 400ms sleep, then asserted the directory state once. On slow runners the haunter had not finished scrubbing, so the assertion saw the original files and failed. Replace the fixed sleep with Eventually polling so the assertions wait for the haunter to converge. While doing so, the exact set and count of reaped files proved nondeterministic (the empty file is double-counted in the size loop and LRU survivors depend on OS access-time ordering), so the assertions now check the haunter's actual guarantees: the empty file is always scrubbed and the cache stays within the configured maxSize/maxItems bound. This also lets the previously-disabled maxItems context and its commented-out assertions be re-enabled. The HTTPClient 'caches repeated requests' test relied on a shared requestsReceived counter that was never reset in BeforeEach. Under randomized spec order another spec could run first and leave the counter non-zero, breaking the first assertion. Reset the counter and header in BeforeEach to make the spec independent of execution order. Verified with: ginkgo -race -repeat=80 --randomize-all ./utils/cache/ * test: surface errors in dirSize and align Eventually with house style Address code review feedback on the cache flaky-test fix: - dirSize now returns (uint64, error) and the maxSize spec asserts the error is nil. Previously a ReadDir/Info failure silently returned 0, which always satisfies '<= maxSize' and would mask a real filesystem error as a passing test. - dirSize skips non-regular entries (info.Mode().IsRegular()) to match its doc comment and avoid counting directories or symlinks. - The Eventually blocks now use .WithTimeout()/.WithPolling() with time.Duration values instead of string-literal durations, matching the prevailing pattern in the test suite. --- utils/cache/cached_http_client_test.go | 2 + utils/cache/file_haunter_test.go | 61 ++++++++++++++++++++------ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/utils/cache/cached_http_client_test.go b/utils/cache/cached_http_client_test.go index 1ec1a3a27..5f8b0029c 100644 --- a/utils/cache/cached_http_client_test.go +++ b/utils/cache/cached_http_client_test.go @@ -20,6 +20,8 @@ var _ = Describe("HTTPClient", func() { var header string BeforeEach(func() { + requestsReceived = 0 + header = "" ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestsReceived++ header = r.Header.Get("head") diff --git a/utils/cache/file_haunter_test.go b/utils/cache/file_haunter_test.go index 47440cc22..6c5151abb 100644 --- a/utils/cache/file_haunter_test.go +++ b/utils/cache/file_haunter_test.go @@ -29,15 +29,15 @@ var _ = Describe("FileHaunter", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + // Use a short haunter period so cleanup runs promptly; the assertions + // below poll with Eventually instead of racing a fixed sleep. fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy( - cache.NewFileHaunter("", maxItems, maxSize, 300*time.Millisecond), + cache.NewFileHaunter("", maxItems, maxSize, 100*time.Millisecond), )) Expect(err).ToNot(HaveOccurred()) DeferCleanup(fsCache.Clean) Expect(createTestFiles(fsCache)).To(Succeed()) - - <-time.After(400 * time.Millisecond) }) Context("When maxSize is defined", func() { @@ -46,24 +46,39 @@ var _ = Describe("FileHaunter", func() { }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(4)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") + // stream-0..4 hold "hello" (5 bytes each) and stream-5 is empty. + // With maxSize=20, the haunter scrubs the empty file plus enough of + // the oldest files to bring the total size down to <= 20 bytes. + // Which files survive (and therefore the exact count) depends on + // access-time ordering, so we only assert the haunter's guarantees: + // the empty file is always scrubbed and the total size stays within + // the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + size, err := dirSize(cacheDir) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(size).To(BeNumerically("<=", maxSize)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) - XContext("When maxItems is defined", func() { + Context("When maxItems is defined", func() { BeforeEach(func() { maxItems = 3 }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(maxItems)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") - //Expect(fsCache.Exists("stream-1")).To(BeFalse(), "stream-1 should have been scrubbed") + // With maxItems=3, the haunter scrubs the empty file plus enough of + // the oldest files to bring the count within the limit. As above, the + // exact survivors depend on access-time ordering, so we assert the + // guaranteed invariants: the empty file is gone and the item count + // stays within the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + entries, readErr := os.ReadDir(cacheDir) + g.Expect(readErr).ToNot(HaveOccurred()) + g.Expect(len(entries)).To(BeNumerically("<=", maxItems)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) }) @@ -93,6 +108,26 @@ func createTestFiles(c *fscache.FSCache) error { return nil } +// dirSize returns the total size in bytes of all regular files in dir. +func dirSize(dir string) (uint64, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + var total uint64 + for _, e := range entries { + info, err := e.Info() + if err != nil { + return 0, err + } + if !info.Mode().IsRegular() { + continue + } + total += uint64(info.Size()) + } + return total, nil +} + func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser { r, w, _ := c.Get(name) _, _ = w.Write([]byte(contents)) From 03841ffe965d637fbcd6a003870bd6b40c94ee8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 5 Jun 2026 18:16:21 -0400 Subject: [PATCH 59/60] fix(ui): update German, Finnish, Galician, Dutch, Slovak, Thai, Chinese (traditional) translations from POEditor (#5351) Co-authored-by: navidrome-bot --- resources/i18n/de.json | 4 +- resources/i18n/fi.json | 4 +- resources/i18n/gl.json | 4 +- resources/i18n/nl.json | 4 +- resources/i18n/sk.json | 170 ++++++++++++++++++------------------ resources/i18n/th.json | 18 ++-- resources/i18n/zh-Hant.json | 6 +- 7 files changed, 114 insertions(+), 96 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index c540dee05..1a516d393 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -38,7 +38,9 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "Disc %{discNumber}" + "disc": "Disc %{discNumber}", + "albumGain": "Album Gain", + "trackGain": "Titel Gain" }, "actions": { "addToQueue": "Später abspielen", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index bbad47bd6..0e6149f87 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,7 +38,9 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "Levy %{discNumber}" + "disc": "Levy %{discNumber}", + "albumGain": "Albumin äänenvoimakkuus", + "trackGain": "Kappaleen äänenvoimakkuus" }, "actions": { "addToQueue": "Lisää jonoon", diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index d62ca2ab2..444998d03 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,7 +38,9 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "Disco %{discNumber}" + "disc": "Disco %{discNumber}", + "albumGain": "Gañancia de Album", + "trackGain": "Gañancia de Canción" }, "actions": { "addToQueue": "Ao final da cola", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 3f638c13c..46c3df9de 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -38,7 +38,9 @@ "missing": "Ontbrekend", "libraryName": "Bibliotheek", "composer": "Componist", - "disc": "Schijf %{discNumber}" + "disc": "Schijf %{discNumber}", + "albumGain": "Album gain", + "trackGain": "Nummer gain" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json index af5afade7..f294d1602 100644 --- a/resources/i18n/sk.json +++ b/resources/i18n/sk.json @@ -2,7 +2,7 @@ "languageName": "Slovenčina", "resources": { "song": { - "name": "Skladba |||| Skladieb", + "name": "Skladba |||| Skladby", "fields": { "albumArtist": "Interpret albumu", "duration": "Dĺžka", @@ -10,20 +10,14 @@ "playCount": "Počet prehratí", "title": "Názov", "artist": "Interpret", - "composer": "Skladateľ", "album": "Album", "path": "Cesta k súboru", - "libraryName": "Knižnica", "genre": "Žáner", "compilation": "Kompilácia", "year": "Rok", "size": "Veľkosť súboru", "updatedAt": "Nahrané", "bitRate": "Prenosová rýchlosť", - "bitDepth": "Bitová hĺbka", - "sampleRate": "Vzorkovacia frekvencia", - "channels": "Kanály", - "disc": "Disk %{discNumber}", "discSubtitle": "Podtitul disku", "starred": "Obľúbené", "comment": "Komentár", @@ -31,6 +25,7 @@ "quality": "Kvalita", "bpm": "BPM", "playDate": "Naposledy prehraná skladba", + "channels": "Kanály", "createdAt": "Pridané", "grouping": "Zoskupovanie", "mood": "Nálada", @@ -38,17 +33,24 @@ "tags": "Ďalšie značky", "mappedTags": "Mapované značky", "rawTags": "Nespracované značky", - "missing": "Chýbajúce" + "bitDepth": "Bitová hĺbka", + "sampleRate": "Vzorkovacia frekvencia", + "missing": "Chýbajúce", + "libraryName": "Knižnica", + "composer": "Skladateľ", + "disc": "Disk %{discNumber}", + "albumGain": "Zosilnenie albumu", + "trackGain": "Zosilnenie stopy" }, "actions": { "addToQueue": "Prehrať neskôr", "playNow": "Prehrať teraz", "addToPlaylist": "Pridať do zoznamu skladieb", - "showInPlaylist": "Zobraziť v zozname skladieb", "shuffleAll": "Zamiešať všetko", "download": "Stiahnuť", "playNext": "Prehrať ako ďalšie", "info": "Získať informácie", + "showInPlaylist": "Zobraziť v zozname skladieb", "instantMix": "Okamžitý mix" } }, @@ -60,38 +62,38 @@ "duration": "Dĺžka", "songCount": "Skladby", "playCount": "Počet prehratí", - "size": "Veľkosť", "name": "Názov", - "libraryName": "Knižnica", "genre": "Žáner", "compilation": "Kompilácia", "year": "Rok", - "date": "Dátum záznamu", - "originalDate": "Pôvodné", - "releaseDate": "Vydané", - "releases": "Vydanie |||| Vydania", - "released": "Vydané", "updatedAt": "Aktualizované", "comment": "Komentár", "rating": "Hodnotenie", "createdAt": "Pridané", + "size": "Veľkosť", + "originalDate": "Pôvodné", + "releaseDate": "Vydané", + "releases": "Vydanie |||| Vydania", + "released": "Vydané", "recordLabel": "Štítok", "catalogNum": "Katalógové číslo", "releaseType": "Typ vydania", "grouping": "Zoskupovanie", "media": "Médiá", "mood": "Nálada", - "missing": "Chýbajúce" + "date": "Dátum záznamu", + "missing": "Chýbajúce", + "libraryName": "Knižnica" }, "actions": { "playAll": "Prehrať", "playNext": "Prehrať ako ďalšie", "addToQueue": "Prehrať neskôr", - "share": "Zdieľať", "shuffle": "Zamiešať", "addToPlaylist": "Pridať do zoznamu skladieb", "download": "Stiahnuť", - "info": "Získať informácie" + "info": "Získať informácie", + "share": "Zdieľať" }, "lists": { "all": "Všetko", @@ -109,10 +111,10 @@ "name": "Názov", "albumCount": "Počet albumov", "songCount": "Počet skladieb", - "size": "Veľkosť", "playCount": "Prehrania", "rating": "Hodnotenie", "genre": "Žáner", + "size": "Veľkosť", "role": "Rola", "missing": "Chýbajúci" }, @@ -133,9 +135,9 @@ "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" }, "actions": { - "topSongs": "Najpopulárnejšie skladby", "shuffle": "Zamiešať", - "radio": "Rádio" + "radio": "Rádio", + "topSongs": "Najpopulárnejšie skladby" } }, "user": { @@ -144,7 +146,6 @@ "userName": "Používateľské meno", "isAdmin": "Správca", "lastLoginAt": "Naposledy prihlásený", - "lastAccessAt": "Posledný Prístup", "updatedAt": "Upravený", "name": "Meno", "password": "Heslo", @@ -153,6 +154,7 @@ "currentPassword": "Súčastné heslo", "newPassword": "Nové heslo", "token": "Token", + "lastAccessAt": "Posledný Prístup", "libraries": "Knižnice" }, "helperTexts": { @@ -164,14 +166,14 @@ "updated": "Používateľ upravený", "deleted": "Používateľ odstránený" }, - "validation": { - "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" - }, "message": { "listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.", "clickHereForToken": "Kliknite sem pre získanie svojho tokenu", "selectAllLibraries": "Vybrať všetky knižnice", "adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam" + }, + "validation": { + "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" } }, "player": { @@ -214,9 +216,9 @@ "selectPlaylist": "Vybrať zoznam skladieb:", "addNewPlaylist": "Vytvoriť \"%{name}\"", "export": "Export", - "saveQueue": "Uložiť rad do zoznamu skladieb", "makePublic": "Zverejniť", "makePrivate": "Nastaviť ako súkromné", + "saveQueue": "Uložiť rad do zoznamu skladieb", "searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...", "pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb", "removeFromSelection": "Odstrániť z výberu" @@ -247,7 +249,6 @@ "username": "Zdieľané", "url": "URL", "description": "Popis", - "downloadable": "Povoliť sťahovanie?", "contents": "Obsah", "expiresAt": "Vyprší", "lastVisitedAt": "Naposledy navštívené", @@ -255,19 +256,17 @@ "format": "Formát", "maxBitRate": "Max. Bit Rate", "updatedAt": "Nahrané", - "createdAt": "Vytvorené" - }, - "notifications": {}, - "actions": {} + "createdAt": "Vytvorené", + "downloadable": "Povoliť sťahovanie?" + } }, "missing": { "name": "Chýbajúci súbor |||| Chýbajúce súbory", - "empty": "Žiadne chýbajúce súbory", "fields": { "path": "Cesta", "size": "Veľkosť", - "libraryName": "Knižnica", - "updatedAt": "Zmizol dňa" + "updatedAt": "Zmizol dňa", + "libraryName": "Knižnica" }, "actions": { "remove": "Odstrániť", @@ -275,7 +274,8 @@ }, "notifications": { "removed": "Chýbajúce súbory odstránené" - } + }, + "empty": "Žiadne chýbajúce súbory" }, "library": { "name": "Knižnica |||| Knižnice", @@ -305,20 +305,20 @@ }, "actions": { "scan": "Skenovať knižnicu", - "quickScan": "Rýchly sken", - "fullScan": "Úplný sken", "manageUsers": "Spravovať prístup používateľov", - "viewDetails": "Zobraziť detaily" + "viewDetails": "Zobraziť detaily", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken" }, "notifications": { "created": "Knižnica úspešne vytvorená", "updated": "Knižnica úspešne aktualizovaná", "deleted": "Knižnica úspešne odstránená", "scanStarted": "Skenovanie knižnice spustené", + "scanCompleted": "Skenovanie knižnice dokončené", "quickScanStarted": "Rýchly sken spustený", "fullScanStarted": "Úplný sken spustený", - "scanError": "Chyba pri spustení skenu. Skontrolujte logy", - "scanCompleted": "Skenovanie knižnice dokončené" + "scanError": "Chyba pri spustení skenu. Skontrolujte logy" }, "validation": { "nameRequired": "Názov knižnice je povinný", @@ -391,8 +391,6 @@ }, "messages": { "configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.", - "configValidationError": "Overenie konfigurácie zlyhalo:", - "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", "clickPermissions": "Kliknite na oprávnenie pre detaily", "noConfig": "Žiadna konfigurácia nastavená", "allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.", @@ -402,8 +400,10 @@ "allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.", "noLibraries": "Žiadne knižnice nevybrané", "librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.", - "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.", - "requiredHosts": "Požadovaní hostitelia" + "requiredHosts": "Požadovaní hostitelia", + "configValidationError": "Overenie konfigurácie zlyhalo:", + "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", + "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie." }, "placeholders": { "configKey": "kľúč", @@ -446,7 +446,6 @@ "add": "Pridať", "back": "Ísť späť", "bulk_actions": "1 vybraná |||| %{smart_count} vybraných", - "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Zrušiť", "clear_input_value": "Vymazať hodnotu", "clone": "Klonovať", @@ -470,6 +469,7 @@ "close_menu": "Zavrieť ponuku", "unselect": "Zrušiť výber", "skip": "Preskočiť", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "Zdieľať", "download": "Stiahnuť" }, @@ -557,58 +557,52 @@ } }, "message": { - "uploadCover": "Nahrať obrázok obalu", - "removeCover": "Odstrániť obrázok obalu", - "coverUploaded": "Obrázok obalu albumu aktualizovaný", - "coverRemoved": "Obrázok obalu albumu odstránený", - "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", - "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu", "note": "POZNÁMKA", "transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.", "transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.", "songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb", - "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", - "startingInstantMix": "Načítava sa Instant Mix...", - "noTopSongsFound": "Nenašli sa žiadne top skladby", "noPlaylistsAvailable": "Žiadne nie sú dostupné", "delete_user_title": "Odstrániť používateľa '%{name}'", "delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?", - "remove_missing_title": "Odstráňte chýbajúce súbory", - "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", - "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", - "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", "notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača", "notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https", "lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý", "lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť", "lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý", "lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť", - "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", - "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", - "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", - "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", "openIn": { "lastfm": "Otvoriť na Last.fm", "musicbrainz": "Otvoriť na MusicBrainz" }, "lastfmLink": "Čítať ďalej...", + "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", + "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", + "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", + "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte", "shareOriginalFormat": "Zdieľať v pôvodnom formáte", "shareDialogTitle": "Zdieľať %{resource} '%{name}'", "shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}", - "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", "shareSuccess": "URL skopírovaná do schránky: %{url}", "shareFailure": "Chyba pri kopírovaní URL %{url} do schránky", "downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})", - "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte" + "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", + "remove_missing_title": "Odstráňte chýbajúce súbory", + "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", + "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", + "noTopSongsFound": "Nenašli sa žiadne top skladby", + "startingInstantMix": "Načítava sa Instant Mix...", + "uploadCover": "Nahrať obrázok obalu", + "removeCover": "Odstrániť obrázok obalu", + "coverUploaded": "Obrázok obalu albumu aktualizovaný", + "coverRemoved": "Obrázok obalu albumu odstránený", + "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", + "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu" }, "menu": { "library": "Knižnica", - "librarySelector": { - "allLibraries": "Všetky knižnice (%{count})", - "multipleLibraries": "%{selected} z %{total} knižníc", - "selectLibraries": "Vyberte knižnice", - "none": "Žiadne" - }, "settings": "Nastavenia", "version": "Verzia", "theme": "Téma", @@ -619,7 +613,6 @@ "language": "Jazyk", "defaultView": "Predvolená stránka", "desktop_notifications": "Oznámenia na ploche", - "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný", "lastfmScrobbling": "Scrobblovať na Last.fm", "listenBrainzScrobbling": "Scrobblovať na ListenBrainz", "replaygain": "Mód ReplayGain", @@ -628,13 +621,20 @@ "none": "Vypnuté", "album": "Použiť Album Gain", "track": "Použiť Track Gain" - } + }, + "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný" } }, "albumList": "Albumy", + "about": "O Navidrome", "playlists": "Zoznamy skladieb", "sharedPlaylists": "Zdieľané zoznamy skladieb", - "about": "O Navidrome" + "librarySelector": { + "allLibraries": "Všetky knižnice (%{count})", + "multipleLibraries": "%{selected} z %{total} knižníc", + "selectLibraries": "Vyberte knižnice", + "none": "Žiadne" + } }, "player": { "playListsText": "Rad", @@ -682,11 +682,11 @@ "currentValue": "Aktuálna hodnota", "configurationFile": "Konfiguračný súbor", "exportToml": "Exportovať konfiguráciu (TOML)", - "downloadToml": "Stiahnuť konfiguráciu (TOML)", "exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML", "exportFailed": "Nepodarilo sa skopírovať konfiguráciu", "devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)", - "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách" + "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách", + "downloadToml": "Stiahnuť konfiguráciu (TOML)" } }, "activity": { @@ -694,17 +694,12 @@ "totalScanned": "Naskenované priečinky", "quickScan": "Rýchly sken", "fullScan": "Úplný sken", - "selectiveScan": "Selektívne", "serverUptime": "Doba od spustenia", "serverDown": "OFFLINE", "scanType": "Posledný Sken", "status": "Chyba skenovania", - "elapsedTime": "Uplynutý čas" - }, - "nowPlaying": { - "title": "Práve hrá", - "empty": "Nič sa neprehráva", - "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" + "elapsedTime": "Uplynutý čas", + "selectiveScan": "Selektívne" }, "help": { "title": "Klávesové skratky Navidrome", @@ -714,10 +709,15 @@ "toggle_play": "Prehrať / Pozastaviť", "prev_song": "Predchádzajúca skladba", "next_song": "Nasledujúca skladba", - "current_song": "Prejsť na aktuálnu skladbu", "vol_up": "Zvýšiť hlasitosť", "vol_down": "Znížiť hlasitosť", - "toggle_love": "Pridať túto skladbu do obľúbených" + "toggle_love": "Pridať túto skladbu do obľúbených", + "current_song": "Prejsť na aktuálnu skladbu" } + }, + "nowPlaying": { + "title": "Práve hrá", + "empty": "Nič sa neprehráva", + "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" } } \ No newline at end of file diff --git a/resources/i18n/th.json b/resources/i18n/th.json index b445d7464..fde89494e 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -38,7 +38,9 @@ "missing": "หายไป", "libraryName": "ห้องสมุด", "composer": "ผู้แต่ง", - "disc": "" + "disc": "พื้นที่ %{discNumber}", + "albumGain": "เนื้อหาในอัลบั้ม", + "trackGain": "เนื้อหาในเพลง" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -355,7 +357,7 @@ "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", - "allowWriteAccess": "" + "allowWriteAccess": "อนุญาตให้เขียน" }, "sections": { "status": "สถานะ", @@ -401,7 +403,7 @@ "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น" }, "placeholders": { "configKey": "คีย์", @@ -591,7 +593,13 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...", + "uploadCover": "อัพโหลดภาพหน้าปก", + "removeCover": "ลบถาพหน้าปก", + "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", + "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", + "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", + "coverRemoveError": "ลบภาพหน้าปกผิดพลาด" }, "menu": { "library": "ห้องสมุดเพลง", @@ -712,4 +720,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} +} \ No newline at end of file diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 92b4af3d0..d00ae2ac3 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,7 +38,9 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "光碟 %{discNumber}" + "disc": "光碟 %{discNumber}", + "albumGain": "專輯增益", + "trackGain": "曲目增益" }, "actions": { "addToQueue": "加入至播放佇列", @@ -718,4 +720,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file From a6451f75d6bfab01a612ed05e20d7345bddc7544 Mon Sep 17 00:00:00 2001 From: Xabi <888924+xabirequejo@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:32:10 +0200 Subject: [PATCH 60/60] fix(ui): update Basque localisation (#5364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added two strings (gain), imrpoved some, fixed a typo Co-authored-by: Deluan Quintão --- resources/i18n/eu.json | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 6bfd09d0e..30db91cde 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -2,7 +2,7 @@ "languageName": "Euskara", "resources": { "song": { - "name": "Abestia |||| Abesti", + "name": "Abestia |||| Abestiak", "fields": { "albumArtist": "Albumaren artista", "duration": "Iraupena", @@ -22,6 +22,8 @@ "bitRate": "Bit-tasa", "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", + "albumGain": "Album-irabazia", + "trackGain": "Pista-irabazia", "channels": "Kanalak", "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", @@ -53,7 +55,7 @@ } }, "album": { - "name": "Albuma |||| Album", + "name": "Albuma |||| Albumak", "fields": { "albumArtist": "Albumaren artista", "artist": "Artista", @@ -104,7 +106,7 @@ } }, "artist": { - "name": "Artista |||| Artista", + "name": "Artista |||| Artistak", "fields": { "name": "Izena", "albumCount": "Album kopurua", @@ -117,7 +119,7 @@ "missing": "Ez da aurkitu" }, "roles": { - "albumartist": "Albumeko egilea |||| Albumeko artistak", + "albumartist": "Albumeko artista |||| Albumeko artistak", "artist": "Artista |||| Artistak", "composer": "Konpositorea |||| Konpositoreak", "conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak", @@ -335,7 +337,7 @@ } }, "plugin": { - "name": "Plugina |||| Plugin", + "name": "Plugina |||| Pluginak", "fields": { "id": "IDa", "name": "Izena", @@ -492,7 +494,7 @@ "input": { "file": { "upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.", - "upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia." + "upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia." }, "image": { "upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.", @@ -537,9 +539,9 @@ "skip_nav": "Joan edukira" }, "notification": { - "updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira", + "updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira", "created": "Elementua sortu da", - "deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.", + "deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.", "bad_item": "Elementu okerra", "item_doesnt_exist": "Elementua ez dago", "http_error": "Errorea zerbitzariarekin komunikatzerakoan", @@ -588,7 +590,7 @@ "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", "openIn": { - "lastfm": "Ikusi Last.fm-n", + "lastfm": "Ikusi Last.fm-en", "musicbrainz": "Ikusi MusicBrainz-en" }, "lastfmLink": "Irakurri gehiago…",