diff --git a/core/stream/token.go b/core/stream/token.go
index 24a154b54..21a26ca93 100644
--- a/core/stream/token.go
+++ b/core/stream/token.go
@@ -92,17 +92,10 @@ func paramsFromToken(token jwt.Token) (*params, error) {
return &p, nil
}
-// getIntClaim extracts an int claim from a JWT token, handling the case where
-// the value may be stored as int64 or float64 (common in JSON-based JWT libraries).
+// getIntClaim extracts a numeric claim from a JWT token. Numeric claims in a
+// parsed token are always deserialized as float64, regardless of the type used
+// when encoding.
func getIntClaim(token jwt.Token, key string) int {
- var v int
- if err := token.Get(key, &v); err == nil {
- return v
- }
- var v64 int64
- if err := token.Get(key, &v64); err == nil {
- return int(v64)
- }
var f float64
if err := token.Get(key, &f); err == nil {
return int(f)
diff --git a/db/db.go b/db/db.go
index 168c12122..6e5b2f569 100644
--- a/db/db.go
+++ b/db/db.go
@@ -51,7 +51,6 @@ func Db() *sql.DB {
_, err = db.Exec("PRAGMA optimize=0x10002")
if err != nil {
log.Error("Error applying PRAGMA optimize", err)
- return nil
}
}
return db
diff --git a/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql b/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql
new file mode 100644
index 000000000..8bd8ad5b8
--- /dev/null
+++ b/db/migrations/20260612171826_add_media_file_missing_library_id_index.sql
@@ -0,0 +1,13 @@
+-- +goose Up
+-- Covering index for the rowid-only pagination query used by search3 with an empty query
+-- (full library sync). It must cover both `missing` and `library_id` so SQLite never touches
+-- the (wide) media_file rows while skipping over large offsets.
+-- Replaces media_file_missing: the composite serves all `missing = ?` lookups via its prefix.
+create index if not exists media_file_missing_library_id
+ on media_file(missing, library_id);
+drop index if exists media_file_missing;
+
+-- +goose Down
+create index if not exists media_file_missing
+ on media_file(missing);
+drop index if exists media_file_missing_library_id;
diff --git a/model/criteria/fields.go b/model/criteria/fields.go
index 9eafff7ab..36206712f 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -4,13 +4,14 @@ import "strings"
// FieldInfo contains semantic metadata about a criteria field.
type FieldInfo struct {
- Alias string // If set, this field is a backward-compat alias for another canonical name
- IsTag bool
- IsRole bool
- Numeric bool
- Boolean bool
+ Alias string // If set, this field is a backward-compat alias for another canonical name
+ IsTag bool
+ IsRole bool
+ Numeric bool
+ Boolean bool
+ Nullable bool // If set, this column field can be NULL, so isMissing/isPresent are supported on it
- tagAlias string // If set, a tag name from mappings.yml that resolves to this field
+ tagAlias string // If set, a tag name from mappings.yaml that resolves to this field
name string // Canonical name, populated by LookupField from the map key
}
@@ -80,15 +81,23 @@ var fieldMap = map[string]FieldInfo{
"mbz_recording_id": {},
"mbz_release_track_id": {},
"mbz_release_group_id": {},
- "rgalbumgain": {Numeric: true},
- "rgalbumpeak": {Numeric: true},
- "rgtrackgain": {Numeric: true},
- "rgtrackpeak": {Numeric: true},
+ "rgalbumgain": {Numeric: true, Nullable: true},
+ "rgalbumpeak": {Numeric: true, Nullable: true},
+ "rgtrackgain": {Numeric: true, Nullable: true},
+ "rgtrackpeak": {Numeric: true, Nullable: true},
"library_id": {Numeric: true},
// Backward compatibility: albumtype is an alias for the releasetype tag.
"albumtype": {Alias: "releasetype", IsTag: true},
+ // Backward compatibility: the replaygain_* tag names (as written in metadata and in the
+ // PR #5256 example) are aliases for the canonical rg* column fields. Without these, the tag
+ // names would be registered as empty tags from mappings.yaml and isMissing would always match.
+ "replaygain_album_gain": {Alias: "rgalbumgain", Numeric: true, Nullable: true},
+ "replaygain_album_peak": {Alias: "rgalbumpeak", Numeric: true, Nullable: true},
+ "replaygain_track_gain": {Alias: "rgtrackgain", Numeric: true, Nullable: true},
+ "replaygain_track_peak": {Alias: "rgtrackpeak", Numeric: true, Nullable: true},
+
// Pseudo-field for random sorting
"random": {},
}
@@ -128,7 +137,7 @@ func AddRoles(roles []string) {
}
}
-// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml`
+// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml`
// configuration file.
func AddTagNames(tagNames []string) {
for _, tagName := range tagNames {
diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go
index 5b6f53341..3dc0c7b90 100644
--- a/model/criteria/fields_test.go
+++ b/model/criteria/fields_test.go
@@ -53,5 +53,27 @@ var _ = Describe("fields", func() {
gomega.Expect(field.IsRole).To(gomega.BeTrue())
})
+ It("marks ReplayGain column fields as nullable", func() {
+ field, ok := LookupField("rgAlbumGain")
+
+ gomega.Expect(ok).To(gomega.BeTrue())
+ gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain"))
+ gomega.Expect(field.Nullable).To(gomega.BeTrue())
+ gomega.Expect(field.IsTag).To(gomega.BeFalse())
+ })
+
+ It("resolves replaygain_* tag names as aliases to nullable column fields", func() {
+ // AddTagNames skips names already in the field map, so the startup tag registration
+ // (from mappings.yaml) must not convert the pre-registered alias into a tag field.
+ AddTagNames([]string{"replaygain_album_gain"})
+
+ field, ok := LookupField("replaygain_album_gain")
+
+ gomega.Expect(ok).To(gomega.BeTrue())
+ gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain"))
+ gomega.Expect(field.Nullable).To(gomega.BeTrue())
+ gomega.Expect(field.IsTag).To(gomega.BeFalse())
+ })
+
})
})
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index cfdc499e0..aa3bc0776 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -540,13 +540,28 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
return totalRowsAffected, nil
}
+// applyLibraryFilterToSearchQuery is applyLibraryFilterToArtistQuery with the join order
+// pinned via CROSS JOIN (SQLite's explicit join-order override): the search Phase 1 paginates
+// rowids by artist.id, and when the planner drives from library_artist it must sort every
+// junction row on every page (temp b-tree over the whole table). Keeping artist as the outer
+// table streams rows in artist.id order from its primary key index, so LIMIT/OFFSET
+// short-circuits. Search-only: other artist queries keep the planner's freedom.
+func (r *artistRepository) applyLibraryFilterToSearchQuery(query SelectBuilder) SelectBuilder {
+ user := loggedUser(r.ctx)
+ query = query.CrossJoin("library_artist on library_artist.artist_id = artist.id")
+ if user.ID != invalidUserId && !user.IsAdmin {
+ query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID)
+ }
+ return query
+}
+
func (r *artistRepository) searchCfg() searchConfig {
return searchConfig{
// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
NaturalOrder: "artist.id",
OrderBy: []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
MBIDFields: []string{"mbz_artist_id"},
- LibraryFilter: r.applyLibraryFilterToArtistQuery,
+ LibraryFilter: r.applyLibraryFilterToSearchQuery,
}
}
diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go
index 076a9da3b..7003efec3 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -3,6 +3,7 @@ package persistence
import (
"context"
"encoding/json"
+ "fmt"
"os"
"path/filepath"
@@ -594,6 +595,66 @@ var _ = Describe("ArtistRepository", func() {
})
})
+ Context("Empty Query (sync pagination)", func() {
+ It("does not duplicate artists that belong to multiple libraries", func() {
+ // An artist in two libraries has two library_artist rows; pagination
+ // must still enumerate it exactly once, at a stable offset.
+ Expect(lr.AddArtist(lib2.ID, artistBeatles.ID)).To(Succeed())
+
+ all, err := repo.Search("", model.QueryOptions{Max: 1000})
+ Expect(err).ToNot(HaveOccurred())
+
+ seen := map[string]bool{}
+ var paged model.Artists
+ for offset := range len(all) {
+ page, err := repo.Search("", model.QueryOptions{Max: 1, Offset: offset})
+ Expect(err).ToNot(HaveOccurred())
+ for _, a := range page {
+ Expect(seen[a.ID]).To(BeFalse(), fmt.Sprintf("artist %s returned twice", a.ID))
+ seen[a.ID] = true
+ }
+ paged = append(paged, page...)
+ }
+ Expect(paged).To(HaveLen(len(all)))
+ })
+
+ It("paginates all artists in natural order without overlaps or gaps", func() {
+ all, err := repo.Search("", model.QueryOptions{Max: 1000})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(all)).To(BeNumerically(">", 1))
+
+ var paged model.Artists
+ pageSize := 2
+ for offset := 0; offset < len(all); offset += pageSize {
+ page, err := repo.Search("", model.QueryOptions{Max: pageSize, Offset: offset})
+ Expect(err).ToNot(HaveOccurred())
+ paged = append(paged, page...)
+ }
+ Expect(paged).To(HaveLen(len(all)))
+ for i := range all {
+ Expect(paged[i].ID).To(Equal(all[i].ID))
+ }
+ })
+
+ It("respects library filtering for restricted users", func() {
+ // Create an artist only in library 2 (not accessible to restricted user)
+ lib2Artist := model.Artist{ID: "empty-query-lib2-artist", Name: "Empty Query Lib2 Artist"}
+ Expect(repo.Put(&lib2Artist)).To(Succeed())
+ Expect(lr.AddArtist(lib2.ID, lib2Artist.ID)).To(Succeed())
+
+ results, err := restrictedRepo.Search("", model.QueryOptions{Max: 1000})
+ Expect(err).ToNot(HaveOccurred())
+ for _, a := range results {
+ Expect(a.ID).ToNot(Equal(lib2Artist.ID), "Empty query search should respect library filtering")
+ }
+
+ // Clean up
+ if raw, ok := repo.(*artistRepository); ok {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": lib2Artist.ID}))
+ }
+ })
+ })
+
Context("Headless Processes (No User Context)", func() {
It("should see all artists from all libraries when no user is in context", func() {
// Add artists to different libraries
diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go
index 37e4ae340..ee0baca18 100644
--- a/persistence/criteria_sql.go
+++ b/persistence/criteria_sql.go
@@ -218,8 +218,8 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er
}
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)
+ if !info.IsTag && !info.IsRole && !info.Nullable {
+ return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field)
}
b, ok := value.(bool)
@@ -227,6 +227,20 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er
return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
}
negate := checkAbsence == b
+
+ // Nullable column fields (e.g. ReplayGain) are stored in dedicated columns, not in the tags
+ // JSON, so "missing" maps to a NULL check on the column rather than a json_tree lookup.
+ if info.Nullable && !info.IsTag && !info.IsRole {
+ col, ok := fieldExpr(info.Name())
+ if !ok || col == "" {
+ return nil, fmt.Errorf("invalid field in criteria: %s", field)
+ }
+ if negate {
+ return squirrel.Eq{col: nil}, nil
+ }
+ return squirrel.NotEq{col: nil}, nil
+ }
+
return jsonExpr(info, nil, negate), nil
}
diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go
index 5c8909e1c..8e801a703 100644
--- a/persistence/criteria_sql_test.go
+++ b/persistence/criteria_sql_test.go
@@ -14,7 +14,7 @@ import (
var _ = Describe("Smart playlist criteria SQL", func() {
BeforeEach(func() {
criteria.AddRoles([]string{"artist", "composer", "producer"})
- criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"})
+ criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate", "replaygain_album_gain"})
criteria.AddNumericTags([]string{"rate"})
})
@@ -85,6 +85,20 @@ var _ = Describe("Smart playlist criteria SQL", func() {
"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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"),
+ // isMissing/isPresent — nullable column fields (ReplayGain)
+ Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true},
+ "media_file.rg_album_gain IS NULL"),
+ Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false},
+ "media_file.rg_album_gain IS NOT NULL"),
+ Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true},
+ "media_file.rg_track_peak IS NOT NULL"),
+ Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false},
+ "media_file.rg_track_peak IS NULL"),
+ // isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584)
+ Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true},
+ "media_file.rg_album_gain IS NULL"),
+ Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true},
+ "media_file.rg_album_gain IS NOT NULL"),
)
Describe("playlist permissions", func() {
@@ -143,12 +157,12 @@ var _ = Describe("Smart playlist criteria SQL", func() {
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")))
+ Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
})
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")))
+ Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field")))
})
It("returns an error when isMissing has a non-boolean value", func() {
diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go
index f42292f02..1ff2139e6 100644
--- a/persistence/e2e/e2e_suite_test.go
+++ b/persistence/e2e/e2e_suite_test.go
@@ -70,11 +70,13 @@ var (
func buildTestFS() {
abbeyRoad := template(_t{
- "albumartist": "The Beatles",
- "artist": "The Beatles",
- "album": "Abbey Road",
- "year": 1969,
- "genre": "Rock;Blues",
+ "albumartist": "The Beatles",
+ "artist": "The Beatles",
+ "album": "Abbey Road",
+ "year": 1969,
+ "genre": "Rock;Blues",
+ "replaygain_album_gain": "-6.5 dB",
+ "replaygain_album_peak": "0.98",
})
ledZepIV := template(_t{
"albumartist": "Led Zeppelin",
@@ -116,12 +118,16 @@ 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, "grouping": "Beatles Tracks"})),
+ _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks",
+ "replaygain_track_gain": "-7.1 dB", "replaygain_track_peak": "0.95"})),
"Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something",
- _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})),
+ _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks",
+ "replaygain_track_gain": "-6.0 dB", "replaygain_track_peak": "0.92"})),
+ // Stairway To Heaven has track gain but no album gain, to distinguish the two fields
"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})),
+ "bitrate": 900, "samplerate": 44100, "bitdepth": 16,
+ "replaygain_track_gain": "-8.25 dB", "replaygain_track_peak": "0.99"})),
"Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog",
_t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac",
"bitrate": 900, "samplerate": 44100, "bitdepth": 16})),
diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go
index a844dc982..4966d3b8a 100644
--- a/persistence/e2e/smartplaylist_test.go
+++ b/persistence/e2e/smartplaylist_test.go
@@ -371,4 +371,61 @@ var _ = Describe("Smart Playlists", func() {
Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower"))
})
})
+
+ // ReplayGain values are stored in nullable media_file columns (not in the tags JSON), so
+ // isMissing/isPresent translate to IS [NOT] NULL checks on those columns (issue #5584).
+ Describe("isMissing/isPresent on ReplayGain fields", func() {
+ It("isMissing finds tracks without album gain", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":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 album gain", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":false}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something"))
+ })
+
+ It("isPresent finds tracks with album gain", func() {
+ results := evaluateRule(`{"all":[{"isPresent":{"rgalbumgain":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something"))
+ })
+
+ It("isPresent finds tracks with album peak", func() {
+ results := evaluateRule(`{"all":[{"isPresent":{"rgalbumpeak":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something"))
+ })
+
+ It("isMissing distinguishes track gain from album gain", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"rgtrackgain":true}}]}`)
+ Expect(results).To(ConsistOf("Black Dog", "So What", "Bohemian Rhapsody",
+ "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("isPresent finds tracks with track gain", func() {
+ results := evaluateRule(`{"all":[{"isPresent":{"rgtrackgain":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven"))
+ })
+
+ It("resolves the replaygain_album_gain alias to the rgalbumgain column", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"replaygain_album_gain":true}}]}`)
+ Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What",
+ "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions"))
+ })
+
+ It("resolves the replaygain_track_gain alias to the rgtrackgain column", func() {
+ results := evaluateRule(`{"all":[{"isPresent":{"replaygain_track_gain":true}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven"))
+ })
+
+ It("supports numeric comparisons through the replaygain_* alias", func() {
+ results := evaluateRule(`{"all":[{"gt":{"replaygain_track_gain":-7.5}}]}`)
+ Expect(results).To(ConsistOf("Come Together", "Something"))
+ })
+
+ It("combines isMissing on ReplayGain with other operators", func() {
+ results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}},{"is":{"genre":"Blues"}}]}`)
+ Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower"))
+ })
+ })
})
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..532e9c10f 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() {
@@ -624,6 +652,49 @@ var _ = Describe("MediaRepository", func() {
_, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID}))
})
})
+
+ Context("empty query (natural order pagination)", func() {
+ It("returns all non-missing files in natural order", func() {
+ results, err := mr.Search("", model.QueryOptions{Max: 1000})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).ToNot(BeEmpty())
+ for _, result := range results {
+ Expect(result.Missing).To(BeFalse())
+ }
+ })
+
+ It(`treats quoted empty query ("") the same as empty`, func() {
+ all, err := mr.Search("", model.QueryOptions{Max: 1000})
+ Expect(err).ToNot(HaveOccurred())
+ quoted, err := mr.Search(`""`, model.QueryOptions{Max: 1000})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(quoted).To(HaveLen(len(all)))
+ })
+
+ It("paginates without overlaps or gaps", func() {
+ all, err := mr.Search("", model.QueryOptions{Max: 1000})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(all)).To(BeNumerically(">", 3))
+
+ var paged model.MediaFiles
+ pageSize := 3
+ for offset := 0; offset < len(all); offset += pageSize {
+ page, err := mr.Search("", model.QueryOptions{Max: pageSize, Offset: offset})
+ Expect(err).ToNot(HaveOccurred())
+ paged = append(paged, page...)
+ }
+ Expect(paged).To(HaveLen(len(all)))
+ for i := range all {
+ Expect(paged[i].ID).To(Equal(all[i].ID), fmt.Sprintf("row %d differs", i))
+ }
+ })
+
+ It("returns empty page when offset is beyond the total", func() {
+ results, err := mr.Search("", model.QueryOptions{Max: 10, Offset: 100000})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+ })
})
Describe("FindByPaths", func() {
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 55e83d544..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.")
@@ -197,15 +214,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}
@@ -411,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/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/sql_search.go b/persistence/sql_search.go
index 43965ebb7..19cbaf24f 100644
--- a/persistence/sql_search.go
+++ b/persistence/sql_search.go
@@ -1,6 +1,7 @@
package persistence
import (
+ "fmt"
"strings"
. "github.com/Masterminds/squirrel"
@@ -20,8 +21,10 @@ type searchConfig struct {
NaturalOrder string // ORDER BY for empty-query results (e.g. "album.rowid")
OrderBy []string // ORDER BY for text search results (e.g. ["name"])
MBIDFields []string // columns to match when query is a UUID
- // LibraryFilter overrides the default applyLibraryFilter for FTS Phase 1.
- // Needed when library access requires a junction table (e.g. artist → library_artist).
+ // LibraryFilter overrides the default applyLibraryFilter for the rowid Phase 1 of
+ // two-phase searches (FTS and empty-query). Needed when library access goes through a
+ // junction table (e.g. artist → library_artist), whose JOIN can fan out rowids for
+ // entities in multiple libraries — Phase 1 dedups whenever this is set.
LibraryFilter func(sq SelectBuilder) SelectBuilder
}
@@ -57,8 +60,8 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea
// Empty query (OpenSubsonic `search3?query=""`) — return all in natural order.
if q == "" || q == `""` {
- sq = sq.OrderBy(cfg.NaturalOrder)
- return r.queryAll(sq, results, options)
+ rowidCore := Select(r.tableName + ".rowid").From(r.tableName).OrderBy(cfg.NaturalOrder)
+ return r.executeTwoPhase(sq, results, rowidCore, cfg, options)
}
// MBID search: if query is a valid UUID, search by MBID fields instead
@@ -82,6 +85,53 @@ func (r sqlRepository) doSearch(sq SelectBuilder, q string, results any, cfg sea
return strategy.execute(r, sq, results, cfg, options)
}
+// executeTwoPhase runs a search in two phases:
+// - Phase 1: rowidCore (strategy-specific FROM/JOINs and ORDER BY) plus the shared search
+// contract applied here — non-missing rows only, library access, options.Filters, and
+// pagination. Keeping Phase 1 free of the full SELECT's JOINs lets SQLite paginate via a
+// covering index; with those JOINs, large offsets degrade to O(offset) join probes —
+// multi-second responses on 100k+ libraries.
+// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid page.
+func (r sqlRepository) executeTwoPhase(sq SelectBuilder, results any, rowidCore SelectBuilder, cfg searchConfig, options model.QueryOptions) error {
+ rowidQuery := rowidCore.
+ Where(Eq{r.tableName + ".missing": false})
+ if options.Max > 0 {
+ rowidQuery = rowidQuery.Limit(uint64(options.Max))
+ }
+ if options.Offset > 0 {
+ rowidQuery = rowidQuery.Offset(uint64(options.Offset))
+ }
+ if cfg.LibraryFilter != nil {
+ // Junction-table library filters can repeat rowids for entities in multiple
+ // libraries, which would corrupt offset-based pagination — dedup before paginating.
+ // (DISTINCT, not GROUP BY: bm25() can't be evaluated in a grouped query.)
+ rowidQuery = cfg.LibraryFilter(rowidQuery).Distinct()
+ } else {
+ rowidQuery = r.applyLibraryFilter(rowidQuery)
+ }
+ if options.Filters != nil {
+ rowidQuery = rowidQuery.Where(options.Filters)
+ }
+ return r.hydrateRowidPage(sq, rowidQuery, results)
+}
+
+// hydrateRowidPage joins sq to the ordered rowid set produced by rowidQuery, preserving its
+// ordering. rowidQuery must handle pagination itself; sq's LIMIT/OFFSET are stripped.
+func (r sqlRepository) hydrateRowidPage(sq SelectBuilder, rowidQuery SelectBuilder, results any) error {
+ rowidSQL, rowidArgs, err := rowidQuery.ToSql()
+ if err != nil {
+ return fmt.Errorf("building rowid query: %w", err)
+ }
+ sq = sq.RemoveLimit().RemoveOffset()
+ rankedSubquery := fmt.Sprintf(
+ "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
+ rowidSQL,
+ )
+ sq = sq.Join(rankedSubquery+" ON "+r.tableName+".rowid = _ranked._rid", rowidArgs...)
+ sq = sq.OrderBy("_ranked._rn")
+ return r.queryAll(sq, results)
+}
+
func mbidExpr(tableName, mbid string, mbidFields ...string) Sqlizer {
if uuid.Validate(mbid) != nil || len(mbidFields) == 0 {
return nil
diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go
index b90dc937b..bbae47fe8 100644
--- a/persistence/sql_search_fts.go
+++ b/persistence/sql_search_fts.go
@@ -284,11 +284,9 @@ func (s *ftsSearch) ToSql() (string, []any, error) {
return sql, []any{s.matchExpr}, nil
}
-// execute runs a two-phase FTS5 search:
-// - Phase 1: lightweight rowid query (main table + FTS + library filter) for ranking and pagination.
-// - Phase 2: full SELECT with all JOINs, scoped to Phase 1's rowid set.
-//
-// Complex ORDER BY (function calls, aggregations) are dropped from Phase 1.
+// execute runs a two-phase FTS5 search (see executeTwoPhase): Phase 1 here contributes the
+// FTS MATCH join and BM25 rank ordering. Complex ORDER BY (function calls, aggregations) are
+// dropped from Phase 1.
func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg searchConfig, options model.QueryOptions) error {
qualifiedOrderBys := []string{s.rankExpr}
for _, ob := range cfg.OrderBy {
@@ -297,45 +295,11 @@ func (s *ftsSearch) execute(r sqlRepository, sq SelectBuilder, dest any, cfg sea
}
}
- // Phase 1: fresh query — must set LIMIT/OFFSET from options explicitly.
- // Mirror applyOptions behavior: Max=0 means no limit, not LIMIT 0.
- rowidQuery := Select(s.tableName+".rowid").
+ rowidCore := Select(s.tableName+".rowid").
From(s.tableName).
Join(s.ftsTable+" ON "+s.ftsTable+".rowid = "+s.tableName+".rowid AND "+s.ftsTable+" MATCH ?", s.matchExpr).
- Where(Eq{s.tableName + ".missing": false}).
OrderBy(qualifiedOrderBys...)
- if options.Max > 0 {
- rowidQuery = rowidQuery.Limit(uint64(options.Max))
- }
- if options.Offset > 0 {
- rowidQuery = rowidQuery.Offset(uint64(options.Offset))
- }
-
- // Library filter + musicFolderId must be applied here, before pagination.
- if cfg.LibraryFilter != nil {
- rowidQuery = cfg.LibraryFilter(rowidQuery)
- } else {
- rowidQuery = r.applyLibraryFilter(rowidQuery)
- }
- if options.Filters != nil {
- rowidQuery = rowidQuery.Where(options.Filters)
- }
-
- rowidSQL, rowidArgs, err := rowidQuery.ToSql()
- if err != nil {
- return fmt.Errorf("building FTS rowid query: %w", err)
- }
-
- // Phase 2: strip LIMIT/OFFSET from sq (Phase 1 handled pagination),
- // join on the ranked rowid set to hydrate with full columns.
- sq = sq.RemoveLimit().RemoveOffset()
- rankedSubquery := fmt.Sprintf(
- "(SELECT rowid as _rid, row_number() OVER () AS _rn FROM (%s)) AS _ranked",
- rowidSQL,
- )
- sq = sq.Join(rankedSubquery+" ON "+s.tableName+".rowid = _ranked._rid", rowidArgs...)
- sq = sq.OrderBy("_ranked._rn")
- return r.queryAll(sq, dest)
+ return r.executeTwoPhase(sq, dest, rowidCore, cfg, options)
}
// qualifyOrderBy prepends tableName to a simple column name. Returns empty string for
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
index e4071195a..617669b6c 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
diff --git a/release/goreleaser.yml b/release/goreleaser.yml
index e5035adda..103f2beaf 100644
--- a/release/goreleaser.yml
+++ b/release/goreleaser.yml
@@ -114,7 +114,7 @@ release:
## Where to go next?
* Read installation instructions on our [website](https://www.navidrome.org/docs/installation/).
- * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) for a simple cloud solution.
+ * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) or [Danian](https://danian.co/navidrome?nd) for a simple cloud solution.
* Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)!
# Add the MSI installers to the release
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/es.json b/resources/i18n/es.json
index a018eda3d..555c165d6 100644
--- a/resources/i18n/es.json
+++ b/resources/i18n/es.json
@@ -35,6 +35,8 @@
"rawTags": "Etiquetas sin procesar",
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
+ "albumGain": "Ganancia del álbum",
+ "trackGain": "Ganancia de pista",
"missing": "Faltante",
"libraryName": "Biblioteca",
"composer": "Compositor",
@@ -693,7 +695,7 @@
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"serverUptime": "Uptime del servidor",
- "serverDown": "OFFLINE",
+ "serverDown": "DESCONECTADO",
"scanType": "Tipo",
"status": "Error de escaneo",
"elapsedTime": "Tiempo transcurrido",
diff --git a/resources/i18n/et.json b/resources/i18n/et.json
index d511c1246..b0131f4ea 100644
--- a/resources/i18n/et.json
+++ b/resources/i18n/et.json
@@ -154,12 +154,12 @@
"currentPassword": "Senine salasõna",
"newPassword": "Uus salasõna",
"token": "Tunnusluba",
- "lastAccessAt": "Viimasti avatud",
+ "lastAccessAt": "Viimati 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"
+ "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikud või jäta vaikimisi väärtuse kasutamiseks tühjaks"
},
"notifications": {
"created": "Kasutaja on lisatud",
@@ -413,10 +413,10 @@
},
"ra": {
"auth": {
- "welcome1": "Aitäh, et paigaldasite Navidrome'i!",
+ "welcome1": "Aitäh, et paigaldasid Navidrome'i!",
"welcome2": "Alustamiseks lisa peakasutaja",
"confirmPassword": "Korda salasõna",
- "buttonCreateAdmin": "Loo admin",
+ "buttonCreateAdmin": "Lisa peakasutaja",
"auth_check_error": "Jätkamiseks palun logi sisse",
"user_menu": "Profiil",
"username": "Kasutajanimi",
@@ -427,7 +427,7 @@
"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",
+ "invalidChars": "Palun kasuta ainult tähti ja numbreid",
"passwordDoesNotMatch": "Salasõnad ei kattu",
"required": "Nõutav",
"minLength": "Pikkus peab olema vähemalt %{min} tähemärki",
@@ -558,8 +558,8 @@
},
"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.",
+ "transcodingDisabled": "Teisendusseadistuste muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovid muuta või lisada teisendamisega seotud seadistusi, taaskäivita server %{config} valikuga.",
+ "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese teisendusseadistuste käivitada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult teisendusvalikute muutmiseks.",
"songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse",
"noPlaylistsAvailable": "Pole saadaval",
"delete_user_title": "Kustuta kasutaja „%{name}“",
@@ -603,13 +603,13 @@
},
"menu": {
"library": "Kogumik",
- "settings": "Seaded",
+ "settings": "Seadistused",
"version": "Versioon",
- "theme": "Teema",
+ "theme": "Kujundus",
"personal": {
"name": "Isiklik",
"options": {
- "theme": "Teema",
+ "theme": "Kujundus",
"language": "Keel",
"defaultView": "Vaikimisi vaade",
"desktop_notifications": "Teavitused töölaual",
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…",
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/id.json b/resources/i18n/id.json
index cdba66663..762ce4ebb 100644
--- a/resources/i18n/id.json
+++ b/resources/i18n/id.json
@@ -37,7 +37,10 @@
"sampleRate": "Sample rate",
"missing": "Hilang",
"libraryName": "Pustaka",
- "composer": "Komposer"
+ "composer": "Komposer",
+ "disc": "Disk %{discNumber}",
+ "albumGain": "Album gain",
+ "trackGain": "Trek gain"
},
"actions": {
"addToQueue": "Tambah ke antrean",
@@ -353,7 +356,8 @@
"allUsers": "Izinkan semua pengguna",
"selectedUsers": "Pengguna yang dipilih",
"allLibraries": "Izinkan semua pustaka",
- "selectedLibraries": "Pustaka dipilih"
+ "selectedLibraries": "Pustaka dipilih",
+ "allowWriteAccess": "Izinkan akses tulis"
},
"sections": {
"status": "Status",
@@ -398,7 +402,8 @@
"librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.",
"requiredHosts": "Hosts diperlukan",
"configValidationError": "Validasi konfigurasi gagal:",
- "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid."
+ "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid.",
+ "allowWriteAccessHelp": "Ketika diaktifkan, plugin dapat mengubah file di direktori pustaka. Bawaannya, plugin hanya memiliki akses read-only"
},
"placeholders": {
"configKey": "key",
@@ -588,7 +593,13 @@
"remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.",
"noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan",
"noTopSongsFound": "Tidak ada lagu teratas ditemukan",
- "startingInstantMix": "Memuat Mix Instan..."
+ "startingInstantMix": "Memuat Mix Instan...",
+ "uploadCover": "Unggah Sampul",
+ "removeCover": "Hapus Sampul",
+ "coverUploaded": "Sampul diperbarui",
+ "coverRemoved": "Sampul dihapus",
+ "coverUploadError": "Kesalahan mengunggah sampul",
+ "coverRemoveError": "Kesalahan menghapus sampul"
},
"menu": {
"library": "Pustaka",
@@ -674,7 +685,8 @@
"exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML",
"exportFailed": "Gagal menyalin konfigurasi",
"devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)",
- "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang"
+ "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang",
+ "downloadToml": "Unduh Konfigurasi (TOML)"
}
},
"activity": {
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
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),
+ )
+})
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 != "" {
diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go
index 4e494b324..578ad44fc 100644
--- a/server/subsonic/transcode.go
+++ b/server/subsonic/transcode.go
@@ -370,6 +370,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (*
if err != nil {
switch {
case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale):
+ log.Warn(ctx, "Invalid or stale transcode token", "mediaID", mediaID, err)
http.Error(w, "Gone", http.StatusGone)
default:
log.Error(ctx, "Error validating transcode params", err)
diff --git a/ui/public/fonts/Unbounded-Variable.woff2 b/ui/public/fonts/Unbounded-Variable.woff2
index 96d8ff5fa..a2f74491f 100644
Binary files a/ui/public/fonts/Unbounded-Variable.woff2 and b/ui/public/fonts/Unbounded-Variable.woff2 differ
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 35eaee3eb..d10aa5a33 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -1,7 +1,12 @@
import ReactGA from 'react-ga'
import { Provider } from 'react-redux'
import { createHashHistory } from 'history'
-import { Admin as RAAdmin, Resource } from 'react-admin'
+import {
+ Admin as RAAdmin,
+ Resource,
+ useSetLocale,
+ useRefresh,
+} from 'react-admin'
import { HotKeys } from 'react-hotkeys'
import dataProvider from './dataProvider'
import authProvider from './authProvider'
@@ -36,7 +41,7 @@ import {
transcodingReducer,
} from './reducers'
import createAdminStore from './store/createAdminStore'
-import { i18nProvider } from './i18n'
+import { i18nProvider, retrieveTranslation } from './i18n'
import config, { shareInfo } from './config'
import { keyMap } from './hotkeys'
import useChangeThemeColor from './useChangeThemeColor'
@@ -44,6 +49,7 @@ import SharePlayer from './share/SharePlayer'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { DndProvider } from 'react-dnd'
import missing from './missing/index.js'
+import { useEffect } from 'react'
const history = createHashHistory()
@@ -84,6 +90,24 @@ const App = () => (
)
const Admin = (props) => {
+ const setLocale = useSetLocale()
+ const refresh = useRefresh()
+ useEffect(() => {
+ if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
+ retrieveTranslation(config.defaultLanguage)
+ .then(() => setLocale(config.defaultLanguage))
+ .then(() => {
+ localStorage.setItem('locale', config.defaultLanguage)
+ refresh(true)
+ })
+ .catch((e) => {
+ // eslint-disable-next-line no-console
+ console.error(
+ 'Cannot load language "' + config.defaultLanguage + '": ' + e,
+ )
+ })
+ }
+ }, [setLocale, refresh])
useChangeThemeColor()
/* eslint-disable react/jsx-key */
return (
diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx
index 91f56b273..a7763cff3 100644
--- a/ui/src/layout/Login.jsx
+++ b/ui/src/layout/Login.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, useEffect } from 'react'
+import React, { useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import { Field, Form } from 'react-final-form'
import { useDispatch } from 'react-redux'
@@ -13,8 +13,6 @@ import {
createMuiTheme,
useLogin,
useNotify,
- useRefresh,
- useSetLocale,
useTranslate,
useVersion,
} from 'react-admin'
@@ -24,7 +22,6 @@ import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { clearQueue } from '../actions'
-import { retrieveTranslation } from '../i18n'
import { INSIGHTS_DOC_URL } from '../consts.js'
const useStyles = makeStyles(
@@ -101,8 +98,13 @@ const renderInput = ({
}) => (
@@ -402,27 +404,8 @@ Login.propTypes = {
// the right theme
const LoginWithTheme = (props) => {
const theme = useCurrentTheme()
- const setLocale = useSetLocale()
- const refresh = useRefresh()
const version = useVersion()
- useEffect(() => {
- if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
- retrieveTranslation(config.defaultLanguage)
- .then(() => {
- setLocale(config.defaultLanguage).then(() => {
- localStorage.setItem('locale', config.defaultLanguage)
- })
- refresh(true)
- })
- .catch((e) => {
- throw new Error(
- 'Cannot load language "' + config.defaultLanguage + '": ' + e,
- )
- })
- }
- }, [refresh, setLocale])
-
return (
diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js
new file mode 100644
index 000000000..84c8d2d7f
--- /dev/null
+++ b/ui/src/themes/catppuccinLatte.css.js
@@ -0,0 +1,203 @@
+const stylesheet = `
+ .react-jinke-music-player-main.light-theme svg,
+ .react-jinke-music-player .music-player-controller,
+ .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] {
+ color: #6c6f85;
+ stroke: #6c6f85;
+ }
+
+ .react-jinke-music-player-main svg:active,
+ .react-jinke-music-player-main svg:hover {
+ color: #7c7f93;
+ }
+
+ .react-jinke-music-player-main.light-theme svg:active,
+ .react-jinke-music-player-main.light-theme svg:hover {
+ color: #7c7f93;
+ }
+
+ .react-jinke-music-player-mobile-play-model-tip,
+ .react-jinke-music-player-main.light-theme .play-mode-title {
+ background-color: #6c6f85;
+ color: #eff1f5;
+ }
+
+ .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: #6c6f85;
+ }
+
+ .react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #6c6f85;
+ }
+
+ .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #6c6f85;
+ }
+
+ .react-jinke-music-player-main .audio-item.playing svg {
+ color: #6c6f85;
+ }
+
+ .react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #6c6f85 !important;
+ }
+
+ .react-jinke-music-player-main .loading svg {
+ color: #6c6f85 !important;
+ }
+
+ .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
+ border: hidden;
+ box-shadow:
+ rgba(76, 79, 105, 0.12) 0px 4px 6px,
+ rgba(76, 79, 105, 0.08) 0px 5px 7px;
+ }
+
+ .rc-slider-rail,
+ .rc-slider-track {
+ height: 6px;
+ }
+
+ .rc-slider {
+ padding: 3px 0;
+ }
+
+ .react-jinke-music-player-main.light-theme .rc-switch-checked {
+ background-color: #6c6f85 !important;
+ border: 1px solid #6c6f85;
+ }
+
+ .sound-operation > div:nth-child(4) {
+ transform: translateX(-50%) translateY(5%) !important;
+ }
+
+ .sound-operation {
+ padding: 4px 0;
+ }
+
+ .react-jinke-music-player-main .music-player-panel {
+ background-color: #e6e9ef;
+ color: #4c4f69;
+ box-shadow: 0 0 8px rgba(76, 79, 105, 0.15);
+ }
+
+ .react-jinke-music-player-main.light-theme .music-player-panel {
+ color: #4c4f69;
+ }
+
+ .audio-lists-panel {
+ background-color: #e6e9ef;
+ bottom: 6.25rem;
+ box-shadow:
+ rgba(76, 79, 105, 0.12) 0px 4px 6px,
+ rgba(76, 79, 105, 0.08) 0px 5px 7px;
+ }
+
+ .audio-lists-panel-content .audio-item.playing {
+ background-color: rgba(0, 0, 0, 0);
+ }
+
+ .audio-lists-panel-content .audio-item:nth-child(2n+1) {
+ background-color: rgba(0, 0, 0, 0);
+ }
+
+ .audio-lists-panel-content .audio-item:active,
+ .audio-lists-panel-content .audio-item:hover {
+ background-color: rgba(76, 79, 105, 0.08);
+ }
+
+ .audio-lists-panel-header {
+ border-bottom: 1px solid #ccd0da;
+ box-shadow: none;
+ }
+
+ .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
+ background-color: rgba(0, 0, 0, 0);
+ box-shadow: 0 0 0 0;
+ }
+
+ .react-jinke-music-player-main.light-theme .audio-lists-panel-header {
+ background-color: #e6e9ef;
+ color: #4c4f69;
+ }
+
+ .audio-lists-panel-content .audio-item {
+ line-height: 32px;
+ color: #4c4f69;
+ }
+
+ .react-jinke-music-player-main .music-player-panel .panel-content .img-content {
+ box-shadow:
+ rgba(76, 79, 105, 0.12) 0px 4px 6px,
+ rgba(76, 79, 105, 0.08) 0px 5px 7px;
+ }
+
+ .react-jinke-music-player-main .music-player-lyric {
+ color: #6c6f85; /* subtext0 */
+ -webkit-text-stroke: 0.35px #eff1f5;
+ font-weight: bolder;
+ }
+
+ .react-jinke-music-player-main .lyric-btn-active,
+ .react-jinke-music-player-main .lyric-btn-active svg {
+ color: #6c6f85 !important;
+ }
+
+ .audio-lists-panel-content .audio-item.playing,
+ .audio-lists-panel-content .audio-item.playing svg {
+ color: #6c6f85;
+ }
+
+ .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg,
+ .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg {
+ color: #6c6f85;
+ }
+
+ .audio-lists-panel-content .audio-item .player-icons {
+ scale: 75%;
+ }
+
+ .audio-lists-panel-content .audio-item:active,
+ .audio-lists-panel-content .audio-item:hover {
+ background-color: #dce0e8; /* surface1 */
+ }
+
+ /* Mobile */
+ .react-jinke-music-player-mobile-cover {
+ border: none;
+ box-shadow:
+ rgba(76, 79, 105, 0.12) 0px 4px 6px,
+ rgba(76, 79, 105, 0.08) 0px 5px 7px;
+ }
+
+ .react-jinke-music-player .music-player-controller {
+ border: none;
+ background-color: #e6e9ef;
+ border-color: #e6e9ef;
+ box-shadow:
+ rgba(76, 79, 105, 0.12) 0px 4px 6px,
+ rgba(76, 79, 105, 0.08) 0px 5px 7px;
+ color: #6c6f85;
+ }
+
+ .react-jinke-music-player .music-player-controller.music-player-playing:before {
+ border: 1px solid rgba(76, 79, 105, 0.18);
+ }
+
+ .react-jinke-music-player .music-player-controller .music-player-controller-setting {
+ background: rgba(108, 111, 133, 0.2);
+ color: #eff1f5;
+ }
+
+ .react-jinke-music-player-mobile-progress .rc-slider-handle,
+ .react-jinke-music-player-mobile-progress .rc-slider-track {
+ background-color: #6c6f85;
+ }
+
+ .react-jinke-music-player-mobile-progress .rc-slider-handle {
+ border: none;
+ }
+`
+
+export default stylesheet
diff --git a/ui/src/themes/catppuccinLatte.js b/ui/src/themes/catppuccinLatte.js
new file mode 100644
index 000000000..3624cd853
--- /dev/null
+++ b/ui/src/themes/catppuccinLatte.js
@@ -0,0 +1,104 @@
+import stylesheet from './catppuccinLatte.css.js'
+
+export default {
+ themeName: 'Catppuccin Latte',
+ palette: {
+ primary: { main: '#8839ef' }, // mauve
+ secondary: {
+ main: '#ccd0da', // surface0
+ contrastText: '#4c4f69', // text
+ },
+ type: 'light',
+ background: {
+ default: '#eff1f5', // base
+ },
+ },
+
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: '#4c4f69', // text
+ backgroundColor: '#e6e9ef', // mantle
+ },
+ },
+
+ MuiButton: {
+ textPrimary: {
+ color: '#1e66f5', // blue
+ },
+ textSecondary: {
+ color: '#4c4f69', // text
+ },
+ },
+
+ MuiChip: {
+ clickable: {
+ background: '#ccd0da', // surface0
+ },
+ },
+
+ MuiFormGroup: {
+ root: {
+ color: '#4c4f69',
+ },
+ },
+
+ MuiFormHelperText: {
+ root: {
+ Mui: {
+ error: {
+ color: '#d20f39', // red
+ },
+ },
+ },
+ },
+
+ MuiTableHead: {
+ root: {
+ color: '#4c4f69',
+ background: '#e6e9ef',
+ },
+ },
+
+ MuiTableCell: {
+ root: {
+ color: '#4c4f69',
+ background: '#e6e9ef !important',
+ },
+ head: {
+ color: '#4c4f69',
+ background: '#e6e9ef !important',
+ },
+ },
+
+ NDLogin: {
+ systemNameLink: {
+ color: '#8839ef', // mauve
+ },
+ icon: {},
+ welcome: {
+ color: '#4c4f69',
+ },
+ card: {
+ minWidth: 300,
+ background: '#eff1f5',
+ },
+ avatar: {},
+ button: {
+ boxShadow: '3px 3px 5px #ccd0da',
+ },
+ },
+
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background:
+ 'linear-gradient(to bottom, rgba(255 255 255 / 72%), rgb(239 241 245))!important',
+ },
+ },
+ },
+
+ player: {
+ theme: 'light',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js
index f65948438..f79a6a999 100644
--- a/ui/src/themes/index.js
+++ b/ui/src/themes/index.js
@@ -9,12 +9,17 @@ import ElectricPurpleTheme from './electricPurple'
import NordTheme from './nord'
import GruvboxDarkTheme from './gruvboxDark'
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
+import CatppuccinLatteTheme from './catppuccinLatte'
import DraculaTheme from './dracula'
import NuclearTheme from './nuclear'
import NutballTheme from './nutball'
import AmusicTheme from './amusic'
import SquiddiesGlassTheme from './SquiddiesGlass'
import NautilineTheme from './nautiline'
+import MoonbaseAlphaTheme from './moonbaseAlpha'
+import MoonbaseBravoTheme from './moonbaseBravo'
+import TokyoNightLightTheme from './tokyoNightLight'
+import TokyoNightTheme from './tokyoNight'
export default {
// Classic default themes
@@ -24,6 +29,7 @@ export default {
// New themes should be added here, in alphabetic order
AmusicTheme,
CatppuccinMacchiatoTheme,
+ CatppuccinLatteTheme,
DraculaTheme,
ElectricPurpleTheme,
ExtraDarkTheme,
@@ -31,10 +37,14 @@ export default {
GruvboxDarkTheme,
LigeraTheme,
MonokaiTheme,
+ MoonbaseAlphaTheme,
+ MoonbaseBravoTheme,
NautilineTheme,
NordTheme,
NuclearTheme,
NutballTheme,
SpotifyTheme,
SquiddiesGlassTheme,
+ TokyoNightLightTheme,
+ TokyoNightTheme,
}
diff --git a/ui/src/themes/moonbaseAlpha.css.js b/ui/src/themes/moonbaseAlpha.css.js
new file mode 100644
index 000000000..757cfc03c
--- /dev/null
+++ b/ui/src/themes/moonbaseAlpha.css.js
@@ -0,0 +1,63 @@
+const stylesheet = `
+
+.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
+ color: #9a7420
+}
+
+.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: #b8862e;
+ border-color: #9a7420
+}
+
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #c9b896;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #b8862e
+}
+
+.react-jinke-music-player-main .audio-item.playing svg {
+ color: #9a7420
+}
+
+.react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #9a7420 !important
+}
+
+.react-jinke-music-player-main .rc-slider-rail {
+ background-color: #ddd7cc !important
+}
+
+.react-jinke-music-player-main .lyric-btn {
+ color: #1a1917 !important
+}
+
+.react-jinke-music-player-main .music-player-panel {
+ color: #1a1917 !important
+}
+
+.react-jinke-music-player-main .lyric-btn-active svg {
+ color: #9a7420 !important
+}
+
+.music-player-lyric {
+ color: #9a7420 !important
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #9a7420
+}
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #9a7420
+}
+
+.progress-bar-content .audio-title a {
+ color: #1a1917
+}
+
+.MuiCheckbox-colorSecondary.Mui-checked {
+ color: #b8862e !important
+}
+`
+export default stylesheet
diff --git a/ui/src/themes/moonbaseAlpha.js b/ui/src/themes/moonbaseAlpha.js
new file mode 100644
index 000000000..51d8a2696
--- /dev/null
+++ b/ui/src/themes/moonbaseAlpha.js
@@ -0,0 +1,90 @@
+import stylesheet from './moonbaseAlpha.css.js'
+
+export default {
+ themeName: 'Moonbase - Alpha',
+ palette: {
+ primary: {
+ main: '#9a7420',
+ },
+ secondary: {
+ main: '#ede8df',
+ contrastText: '#1a1917',
+ },
+ type: 'light',
+ background: {
+ default: '#f5f0e8',
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: '#1a1917',
+ backgroundColor: '#faf8f4',
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: '#9a7420',
+ },
+ textSecondary: {
+ color: '#1a1917',
+ },
+ },
+ MuiChip: {
+ clickable: {
+ background: '#ede8df',
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: '#1a1917',
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: '#b04a2e',
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: '#6b635a',
+ background: '#f5f0e8 !important',
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: '#1a1917',
+ background: '#faf8f4 !important',
+ },
+ head: {
+ color: '#6b635a',
+ background: '#f5f0e8 !important',
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: '#9a7420',
+ },
+ welcome: {
+ color: '#1a1917',
+ },
+ card: {
+ minWidth: 300,
+ background: '#faf8f4',
+ },
+ button: {
+ boxShadow: '3px 3px 5px rgba(0, 0, 0, 0.12)',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background:
+ 'linear-gradient(to bottom, rgba(245, 240, 232, 0.72), #faf8f4)!important',
+ },
+ },
+ },
+ player: {
+ theme: 'light',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/moonbaseBravo.css.js b/ui/src/themes/moonbaseBravo.css.js
new file mode 100644
index 000000000..580b054cc
--- /dev/null
+++ b/ui/src/themes/moonbaseBravo.css.js
@@ -0,0 +1,63 @@
+const stylesheet = `
+
+.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
+ color: #d4a039
+}
+
+.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: #d4a039;
+ border-color: #b8862e
+}
+
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #d4a039;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #d4a039
+}
+
+.react-jinke-music-player-main .audio-item.playing svg {
+ color: #d4a039
+}
+
+.react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #d4a039 !important
+}
+
+.react-jinke-music-player-main .rc-slider-rail {
+ background-color: #2a2a27 !important
+}
+
+.react-jinke-music-player-main .lyric-btn {
+ color: #e5ddd3 !important
+}
+
+.react-jinke-music-player-main .music-player-panel {
+ color: #e5ddd3 !important
+}
+
+.react-jinke-music-player-main .lyric-btn-active svg {
+ color: #d4a039 !important
+}
+
+.music-player-lyric {
+ color: #d4a039 !important
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #d4a039
+}
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #d4a039
+}
+
+.progress-bar-content .audio-title a {
+ color: #e5ddd3
+}
+
+.MuiCheckbox-colorSecondary.Mui-checked {
+ color: #d4a039 !important
+}
+`
+export default stylesheet
diff --git a/ui/src/themes/moonbaseBravo.js b/ui/src/themes/moonbaseBravo.js
new file mode 100644
index 000000000..87585df29
--- /dev/null
+++ b/ui/src/themes/moonbaseBravo.js
@@ -0,0 +1,90 @@
+import stylesheet from './moonbaseBravo.css.js'
+
+export default {
+ themeName: 'Moonbase - Bravo',
+ palette: {
+ primary: {
+ main: '#d4a039',
+ },
+ secondary: {
+ main: '#1e1e1c',
+ contrastText: '#e5ddd3',
+ },
+ type: 'dark',
+ background: {
+ default: '#0a0a09',
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: '#e5ddd3',
+ backgroundColor: '#141413',
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: '#d4a039',
+ },
+ textSecondary: {
+ color: '#e5ddd3',
+ },
+ },
+ MuiChip: {
+ clickable: {
+ background: '#1e1e1c',
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: '#e5ddd3',
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: '#c45c3c',
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: '#8a8278',
+ background: '#0a0a09 !important',
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: '#e5ddd3',
+ background: '#141413 !important',
+ },
+ head: {
+ color: '#8a8278',
+ background: '#0a0a09 !important',
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: '#d4a039',
+ },
+ welcome: {
+ color: '#e5ddd3',
+ },
+ card: {
+ minWidth: 300,
+ background: '#1e1e1c',
+ },
+ button: {
+ boxShadow: '3px 3px 5px #0a0a09',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background:
+ 'linear-gradient(to bottom, rgba(10, 10, 9, 0.72), #141413)!important',
+ },
+ },
+ },
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/nautiline.js b/ui/src/themes/nautiline.js
index 65ded5fc5..0c13dc0ec 100644
--- a/ui/src/themes/nautiline.js
+++ b/ui/src/themes/nautiline.js
@@ -627,7 +627,6 @@ const NautilineTheme = {
root: {
[`@media (max-width: ${breakpoints.xs}px)`]: {
padding: '0.7em',
- width: '100%',
minWidth: 'unset',
},
},
diff --git a/ui/src/themes/tokyoNight.css.js b/ui/src/themes/tokyoNight.css.js
new file mode 100644
index 000000000..882fcd3eb
--- /dev/null
+++ b/ui/src/themes/tokyoNight.css.js
@@ -0,0 +1,143 @@
+const stylesheet = `
+
+.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
+ color: #7aa2f7
+}
+
+.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: #7aa2f7
+}
+
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #7aa2f7;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #7aa2f7
+}
+
+.react-jinke-music-player-main .audio-item.playing svg {
+ color: #7aa2f7
+}
+
+.react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #7aa2f7 !important
+}
+
+.react-jinke-music-player-main .loading svg {
+ color: #7aa2f7 !important
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
+ border: hidden;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.rc-slider-rail, .rc-slider-track {
+ height: 6px;
+}
+
+.rc-slider {
+ padding: 3px 0;
+}
+
+.sound-operation > div:nth-child(4) {
+ transform: translateX(-50%) translateY(5%) !important;
+}
+
+.sound-operation {
+ padding: 4px 0;
+}
+
+.react-jinke-music-player-main .music-player-panel {
+ background-color: #24283b;
+ color: #c0caf5;
+ box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
+}
+
+.audio-lists-panel {
+ background-color: #24283b;
+ bottom: 6.25rem;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.audio-lists-panel-content .audio-item.playing {
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.audio-lists-panel-content .audio-item:nth-child(2n+1) {
+ background-color: rgba(0, 0, 0, 0);
+}
+
+.audio-lists-panel-content .audio-item:active,
+.audio-lists-panel-content .audio-item:hover {
+ background-color: #292e42;
+}
+
+.audio-lists-panel-header {
+ border-bottom: 1px solid rgba(0, 0, 0, 0.25);
+ box-shadow: none;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn {
+ background-color: rgba(0, 0, 0, 0);
+ box-shadow: 0 0 0 0;
+}
+
+.audio-lists-panel-content .audio-item {
+ line-height: 32px;
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .img-content {
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.react-jinke-music-player-main .music-player-lyric {
+ color: #c0caf5;
+ -webkit-text-stroke: 0.5px #1a1b26;
+ font-weight: bolder;
+}
+
+.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg {
+ color: #7aa2f7 !important;
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #7aa2f7
+}
+
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #7aa2f7
+}
+
+.audio-lists-panel-content .audio-item .player-icons {
+ scale: 75%;
+}
+
+/* Mobile */
+
+.react-jinke-music-player-mobile-cover {
+ border: none;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+}
+
+.react-jinke-music-player .music-player-controller {
+ border: none;
+ box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px;
+ color: #7aa2f7;
+}
+
+.react-jinke-music-player .music-player-controller .music-player-controller-setting {
+ color: rgba(122, 162, 247, .3);
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track {
+ background-color: #7aa2f7;
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle {
+ border: none;
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js
new file mode 100644
index 000000000..07d372a6b
--- /dev/null
+++ b/ui/src/themes/tokyoNight.js
@@ -0,0 +1,382 @@
+import stylesheet from './tokyoNight.css.js'
+
+const background = '#1a1b26'
+const surface = '#24283b'
+const currentLine = '#292e42'
+const foreground = '#c0caf5'
+const comment = '#565f89'
+const blue = '#7aa2f7'
+const cyan = '#7dcfff'
+const purple = '#bb9af7'
+const red = '#f7768e'
+
+// For Album, Playlist play button
+const musicListActions = {
+ alignItems: 'center',
+ '@global': {
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.5)',
+ margin: '1rem',
+ '&:hover': {
+ transform: 'scale(1.6) !important',
+ },
+ },
+ transform: 'scale(2)',
+ margin: '1.5rem',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ backgroundColor: `${blue} !important`,
+ color: background,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${blue} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ margin: '1.5rem',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: foreground,
+ },
+ },
+}
+
+export default {
+ themeName: 'Tokyo Night',
+ palette: {
+ primary: {
+ main: blue,
+ },
+ secondary: {
+ main: purple,
+ contrastText: foreground,
+ },
+ error: {
+ main: red,
+ },
+ type: 'dark',
+ background: {
+ default: background,
+ paper: surface,
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: foreground,
+ backgroundColor: surface,
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${surface} !important`,
+ boxShadow:
+ 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px',
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: background,
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: blue,
+ },
+ textSecondary: {
+ color: foreground,
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiChip: {
+ root: {
+ backgroundColor: currentLine,
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiFormLabel: {
+ root: {
+ color: comment,
+ '&$focused': {
+ color: blue,
+ },
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: red,
+ },
+ },
+ MuiToolbar: {
+ root: {
+ backgroundColor: `${surface} !important`,
+ },
+ },
+ MuiOutlinedInput: {
+ root: {
+ '& $notchedOutline': {
+ borderColor: currentLine,
+ },
+ '&:hover $notchedOutline': {
+ borderColor: comment,
+ },
+ '&$focused $notchedOutline': {
+ borderColor: blue,
+ },
+ },
+ },
+ MuiFilledInput: {
+ root: {
+ backgroundColor: currentLine,
+ '&:hover': {
+ backgroundColor: comment,
+ },
+ '&$focused': {
+ backgroundColor: currentLine,
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${currentLine} !important`,
+ },
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: foreground,
+ background: surface,
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: foreground,
+ background: `${surface} !important`,
+ borderBottom: `1px solid ${currentLine}`,
+ },
+ head: {
+ color: `${blue} !important`,
+ background: `${currentLine} !important`,
+ },
+ body: {
+ color: `${foreground} !important`,
+ },
+ },
+ MuiSwitch: {
+ colorSecondary: {
+ '&$checked': {
+ color: blue,
+ },
+ '&$checked + $track': {
+ backgroundColor: blue,
+ },
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ color: foreground,
+ },
+ albumSubtitle: {
+ color: comment,
+ },
+ albumContainer: {
+ backgroundColor: surface,
+ borderRadius: '8px',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: currentLine,
+ },
+ },
+ albumPlayButton: {
+ backgroundColor: blue,
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${blue} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumDetails: {
+ root: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ boxShadow: 'none',
+ },
+ cardContents: {
+ alignItems: 'center',
+ paddingTop: '1.5rem',
+ },
+ recordName: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ recordArtist: {
+ fontSize: '.875rem',
+ fontWeight: 700,
+ color: purple,
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: foreground,
+ fontSize: '0.875rem',
+ },
+ songTitle: {
+ fontWeight: 400,
+ },
+ songInfo: {
+ fontSize: '0.675rem',
+ color: comment,
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: blue,
+ },
+ welcome: {
+ color: foreground,
+ },
+ card: {
+ minWidth: 300,
+ background: surface,
+ },
+ button: {
+ boxShadow: '3px 3px 5px #15161e',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: `linear-gradient(to bottom, rgba(26 27 38 / 72%), ${background})!important`,
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: background,
+ },
+ root: {
+ backgroundColor: background,
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: background,
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ backgroundColor: background,
+ padding: '0 .55rem !important',
+ },
+ },
+ RaSidebar: {
+ fixed: {
+ backgroundColor: background,
+ },
+ drawerPaper: {
+ backgroundColor: `${background} !important`,
+ },
+ },
+ RaMenuItemLink: {
+ root: {
+ color: foreground,
+ '&[aria-current="page"]': {
+ color: `${blue} !important`,
+ },
+ '&[aria-current="page"] .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ active: {
+ color: `${blue} !important`,
+ '& .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ },
+ RaLink: {
+ link: {
+ color: cyan,
+ },
+ },
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `2px solid ${blue}`,
+ },
+ button: {
+ backgroundColor: currentLine,
+ minWidth: 48,
+ margin: '0 4px',
+ },
+ },
+ },
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/tokyoNightLight.css.js b/ui/src/themes/tokyoNightLight.css.js
new file mode 100644
index 000000000..a22c82d03
--- /dev/null
+++ b/ui/src/themes/tokyoNightLight.css.js
@@ -0,0 +1,123 @@
+const stylesheet = `
+.react-jinke-music-player-main.light-theme .loading svg {
+ color: #2e7de9;
+ font-size: 24px
+}
+
+.react-jinke-music-player-mobile-play-model-tip {
+ background-color: #2e7de9;
+}
+
+.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: #2e7de9
+}
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #2e7de9
+}
+
+.react-jinke-music-player-main.light-theme .audio-item.playing svg {
+ color: #2e7de9
+}
+
+.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer {
+ color: #2e7de9 !important
+}
+
+.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg {
+ color: #2e7de9
+}
+
+.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg {
+ color: #2e7de9
+}
+
+.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb {
+ background-color: #2e7de9;
+}
+
+.react-jinke-music-player-main.light-theme svg {
+ color: #3760bf
+}
+
+.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover {
+ color: #2e7de9
+}
+
+.react-jinke-music-player-main.light-theme .rc-slider-rail {
+ background-color: rgba(55, 96, 191, .12) !important
+}
+
+.react-jinke-music-player-main.light-theme .music-player-controller {
+ background-color: #d5d6db;
+ border-color: #d5d6db
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel {
+ background-color: #d5d6db;
+ box-shadow: 0 1px 2px 0 rgba(0, 34, 77, .05);
+ color: #3760bf
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel .img-content {
+ box-shadow: 0 0 10px #c4c8da
+}
+
+.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar {
+ background-color: rgba(55, 96, 191, .08) !important
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch {
+ color: #fff
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch:after {
+ background-color: #fff
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch-checked {
+ background-color: #2e7de9 !important;
+ border: 1px solid #2e7de9
+}
+
+.react-jinke-music-player-main.light-theme .rc-switch-inner {
+ color: #fff
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn {
+ background-color: #e1e2e7 !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover {
+ background-color: #ebebed;
+ color: #3760bf
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg {
+ color: #2e7de9
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel {
+ background-color: #d5d6db;
+ box-shadow: 0 0 2px #c4c8da;
+ color: #3760bf
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item {
+ background-color: #d5d6db
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) {
+ background-color: #dadbe0 !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing {
+ background-color: #c4c8da !important
+}
+
+.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg {
+ color: #2e7de9 !important
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js
new file mode 100644
index 000000000..f84cd0be9
--- /dev/null
+++ b/ui/src/themes/tokyoNightLight.js
@@ -0,0 +1,382 @@
+import stylesheet from './tokyoNightLight.css.js'
+
+const background = '#e1e2e7'
+const surface = '#d5d6db'
+const currentLine = '#c4c8da'
+const foreground = '#3760bf'
+const comment = '#848cb5'
+const blue = '#2e7de9'
+const cyan = '#007197'
+const purple = '#9854f1'
+const red = '#f52a65'
+
+// For Album, Playlist play button
+const musicListActions = {
+ alignItems: 'center',
+ '@global': {
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.5)',
+ margin: '1rem',
+ '&:hover': {
+ transform: 'scale(1.6) !important',
+ },
+ },
+ transform: 'scale(2)',
+ margin: '1.5rem',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ backgroundColor: `${blue} !important`,
+ color: background,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${blue} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ margin: '1.5rem',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: foreground,
+ },
+ },
+}
+
+export default {
+ themeName: 'Tokyo Night Light',
+ palette: {
+ primary: {
+ main: blue,
+ },
+ secondary: {
+ main: purple,
+ contrastText: foreground,
+ },
+ error: {
+ main: red,
+ },
+ type: 'light',
+ background: {
+ default: background,
+ paper: surface,
+ },
+ },
+ overrides: {
+ MuiPaper: {
+ root: {
+ color: foreground,
+ backgroundColor: surface,
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${surface} !important`,
+ boxShadow:
+ 'rgba(15, 17, 21, 0.15) 0px 4px 6px, rgba(15, 17, 21, 0.08) 0px 5px 7px',
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: background,
+ },
+ },
+ MuiButton: {
+ textPrimary: {
+ color: blue,
+ },
+ textSecondary: {
+ color: foreground,
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiChip: {
+ root: {
+ backgroundColor: currentLine,
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: foreground,
+ },
+ },
+ MuiFormLabel: {
+ root: {
+ color: comment,
+ '&$focused': {
+ color: blue,
+ },
+ },
+ },
+ MuiFormHelperText: {
+ error: {
+ color: red,
+ },
+ },
+ MuiToolbar: {
+ root: {
+ backgroundColor: `${surface} !important`,
+ },
+ },
+ MuiOutlinedInput: {
+ root: {
+ '& $notchedOutline': {
+ borderColor: currentLine,
+ },
+ '&:hover $notchedOutline': {
+ borderColor: comment,
+ },
+ '&$focused $notchedOutline': {
+ borderColor: blue,
+ },
+ },
+ },
+ MuiFilledInput: {
+ root: {
+ backgroundColor: currentLine,
+ '&:hover': {
+ backgroundColor: comment,
+ },
+ '&$focused': {
+ backgroundColor: currentLine,
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${currentLine} !important`,
+ },
+ },
+ },
+ MuiTableHead: {
+ root: {
+ color: foreground,
+ background: surface,
+ },
+ },
+ MuiTableCell: {
+ root: {
+ color: foreground,
+ background: `${surface} !important`,
+ borderBottom: `1px solid ${currentLine}`,
+ },
+ head: {
+ color: `${blue} !important`,
+ background: `${currentLine} !important`,
+ },
+ body: {
+ color: `${foreground} !important`,
+ },
+ },
+ MuiSwitch: {
+ colorSecondary: {
+ '&$checked': {
+ color: blue,
+ },
+ '&$checked + $track': {
+ backgroundColor: blue,
+ },
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ color: foreground,
+ },
+ albumSubtitle: {
+ color: comment,
+ },
+ albumContainer: {
+ backgroundColor: surface,
+ borderRadius: '8px',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: currentLine,
+ },
+ },
+ albumPlayButton: {
+ backgroundColor: blue,
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 20%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${blue} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumDetails: {
+ root: {
+ background: `linear-gradient(${currentLine}, transparent)`,
+ borderRadius: 0,
+ boxShadow: 'none',
+ },
+ cardContents: {
+ alignItems: 'center',
+ paddingTop: '1.5rem',
+ },
+ recordName: {
+ fontWeight: 700,
+ color: foreground,
+ },
+ recordArtist: {
+ fontSize: '.875rem',
+ fontWeight: 700,
+ color: purple,
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: comment,
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: foreground,
+ fontSize: '0.875rem',
+ },
+ songTitle: {
+ fontWeight: 400,
+ },
+ songInfo: {
+ fontSize: '0.675rem',
+ color: comment,
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: blue,
+ },
+ welcome: {
+ color: foreground,
+ },
+ card: {
+ minWidth: 300,
+ background: surface,
+ },
+ button: {
+ boxShadow: '3px 3px 5px #a8aecb',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: `linear-gradient(to bottom, rgba(225 226 231 / 72%), ${background})!important`,
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: background,
+ },
+ root: {
+ backgroundColor: background,
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: background,
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ backgroundColor: background,
+ padding: '0 .55rem !important',
+ },
+ },
+ RaSidebar: {
+ fixed: {
+ backgroundColor: background,
+ },
+ drawerPaper: {
+ backgroundColor: `${background} !important`,
+ },
+ },
+ RaMenuItemLink: {
+ root: {
+ color: foreground,
+ '&[aria-current="page"]': {
+ color: `${blue} !important`,
+ },
+ '&[aria-current="page"] .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ active: {
+ color: `${blue} !important`,
+ '& .MuiListItemIcon-root': {
+ color: `${blue} !important`,
+ },
+ },
+ },
+ RaLink: {
+ link: {
+ color: cyan,
+ },
+ },
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `2px solid ${blue}`,
+ },
+ button: {
+ backgroundColor: currentLine,
+ minWidth: 48,
+ margin: '0 4px',
+ },
+ },
+ },
+ player: {
+ theme: 'light',
+ stylesheet,
+ },
+}
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))