From b1b488be77c0ee8fdebf41f269d51df981406b8d Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Wed, 21 Jan 2026 10:45:17 -0800
Subject: [PATCH 01/25] fix(db): Include items with no annotation for
starred=false, handle has_rating=false (#4921)
* fix(db): Include items with no annotation for starred=false, handle has_rating=false
* hardcode starred instead
* test: ensure albums and artists without annotations are included in starred and has_rating filters
Signed-off-by: Deluan
* refactor: replace starred and has_rating filters with annotationBoolFilter for consistency
Signed-off-by: Deluan
* fix: update annotationBoolFilter to handle boolean values correctly in SQL expressions
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
Co-authored-by: Deluan
---
persistence/album_repository.go | 8 +-
persistence/album_repository_test.go | 77 ++++++++++++
persistence/artist_repository.go | 2 +-
persistence/artist_repository_test.go | 49 ++++++++
persistence/mediafile_repository.go | 2 +-
persistence/mediafile_repository_test.go | 45 +++++++
persistence/sql_annotations.go | 14 +++
persistence/sql_annotations_test.go | 153 +++++++++++++++++++++++
8 files changed, 342 insertions(+), 8 deletions(-)
create mode 100644 persistence/sql_annotations_test.go
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index dab255784..adca058c2 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -119,8 +119,8 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
"artist_id": artistFilter,
"year": yearFilter,
"recently_played": recentlyPlayedFilter,
- "starred": booleanFilter,
- "has_rating": hasRatingFilter,
+ "starred": annotationBoolFilter("starred"),
+ "has_rating": annotationBoolFilter("rating"),
"missing": booleanFilter,
"genre_id": tagIDFilter,
"role_total_id": allRolesFilter,
@@ -149,10 +149,6 @@ func recentlyPlayedFilter(string, interface{}) Sqlizer {
return Gt{"play_count": 0}
}
-func hasRatingFilter(string, interface{}) Sqlizer {
- return Gt{"rating": 0}
-}
-
func yearFilter(_ string, value interface{}) Sqlizer {
return Or{
And{
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 612e459f0..2705653ab 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/model"
@@ -77,6 +78,82 @@ var _ = Describe("AlbumRepository", func() {
})
})
+ Context("Filters", func() {
+ var albumWithoutAnnotation model.Album
+
+ BeforeEach(func() {
+ // Create album without any annotation (no star, no rating)
+ albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
+ Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
+ })
+
+ Describe("starred", func() {
+ It("false includes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+ })
+
+ Describe("has_rating", func() {
+ It("false includes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"has_rating": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album without annotation should be included in has_rating=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"has_rating": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+ })
+ })
+
Describe("Album.PlayCount", func() {
// Implementation is in withAnnotation() method
DescribeTable("normalizes play count when AlbumPlayCountMode is absolute",
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index c9e38a1ee..5c34ace5d 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -133,7 +133,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.registerModel(&model.Artist{}, map[string]filterFunc{
"id": idFilter(r.tableName),
"name": fullTextFilter(r.tableName, "mbz_artist_id"),
- "starred": booleanFilter,
+ "starred": annotationBoolFilter("starred"),
"role": roleFilter,
"missing": booleanFilter,
"library_id": artistLibraryIdFilter,
diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go
index dfaf499ac..18883378d 100644
--- a/persistence/artist_repository_test.go
+++ b/persistence/artist_repository_test.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
@@ -386,6 +387,54 @@ var _ = Describe("ArtistRepository", func() {
})
})
+ Describe("Filters", func() {
+ var artistWithoutAnnotation model.Artist
+
+ BeforeEach(func() {
+ // Create artist without any annotation
+ artistWithoutAnnotation = model.Artist{ID: "no-annotation-artist", Name: "No Annotation Artist"}
+ err := createArtistWithLibrary(repo, &artistWithoutAnnotation, 1)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ if raw, ok := repo.(*artistRepository); ok {
+ _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithoutAnnotation.ID}))
+ }
+ })
+
+ Describe("starred", func() {
+ It("false includes items without annotations", func() {
+ res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ artists := res.(model.Artists)
+
+ var found bool
+ for _, a := range artists {
+ if a.ID == artistWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Artist without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := repo.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ artists := res.(model.Artists)
+
+ for _, a := range artists {
+ Expect(a.ID).ToNot(Equal(artistWithoutAnnotation.ID))
+ }
+ })
+ })
+ })
+
Describe("MBID and Text Search", func() {
var lib2 model.Library
var lr model.LibraryRepository
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index 7f65540c7..9c682369a 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -95,7 +95,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc {
filters := map[string]filterFunc{
"id": idFilter("media_file"),
"title": fullTextFilter("media_file", "mbz_recording_id", "mbz_release_track_id"),
- "starred": booleanFilter,
+ "starred": annotationBoolFilter("starred"),
"genre_id": tagIDFilter,
"missing": booleanFilter,
"artists_id": artistFilter,
diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go
index e33639721..9f62a6a7c 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -5,6 +5,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/log"
@@ -417,6 +418,50 @@ var _ = Describe("MediaRepository", func() {
})
})
+ Context("Filters", func() {
+ var mfWithoutAnnotation model.MediaFile
+
+ BeforeEach(func() {
+ mfWithoutAnnotation = model.MediaFile{ID: "no-annotation-file", LibraryID: 1, Path: "/test/no-annotation.mp3", Title: "No Annotation"}
+ Expect(mr.Put(&mfWithoutAnnotation)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _ = mr.Delete(mfWithoutAnnotation.ID)
+ })
+
+ Describe("starred", func() {
+ It("false includes items without annotations", func() {
+ res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "false"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ files := res.(model.MediaFiles)
+
+ var found bool
+ for _, f := range files {
+ if f.ID == mfWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "MediaFile without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": "true"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ files := res.(model.MediaFiles)
+
+ for _, f := range files {
+ Expect(f.ID).ToNot(Equal(mfWithoutAnnotation.ID))
+ }
+ })
+ })
+ })
+
Describe("Search", func() {
Context("text search", func() {
It("finds media files by title", func() {
diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go
index fac519829..cf95d39a2 100644
--- a/persistence/sql_annotations.go
+++ b/persistence/sql_annotations.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -43,6 +44,19 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
return query
}
+func annotationBoolFilter(field string) func(string, any) Sqlizer {
+ return func(_ string, value any) Sqlizer {
+ v, ok := value.(string)
+ if !ok {
+ return nil
+ }
+ if strings.ToLower(v) == "true" {
+ return Expr(fmt.Sprintf("COALESCE(%s, 0) > 0", field))
+ }
+ return Expr(fmt.Sprintf("COALESCE(%s, 0) = 0", field))
+ }
+}
+
func (r sqlRepository) annId(itemID ...string) And {
userID := loggedUser(r.ctx).ID
return And{
diff --git a/persistence/sql_annotations_test.go b/persistence/sql_annotations_test.go
new file mode 100644
index 000000000..1848bbc8b
--- /dev/null
+++ b/persistence/sql_annotations_test.go
@@ -0,0 +1,153 @@
+package persistence
+
+import (
+ "context"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/deluan/rest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Annotation Filters", func() {
+ var (
+ albumRepo *albumRepository
+ albumWithoutAnnotation model.Album
+ )
+
+ BeforeEach(func() {
+ ctx := request.WithUser(context.Background(), model.User{ID: "userid", UserName: "johndoe"})
+ albumRepo = NewAlbumRepository(ctx, GetDBXBuilder()).(*albumRepository)
+
+ // Create album without any annotation (no star, no rating)
+ albumWithoutAnnotation = model.Album{ID: "no-annotation-album", Name: "No Annotation", LibraryID: 1}
+ Expect(albumRepo.Put(&albumWithoutAnnotation)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": albumWithoutAnnotation.ID}))
+ })
+
+ Describe("annotationBoolFilter", func() {
+ DescribeTable("creates correct SQL expressions",
+ func(field, value string, expectedSQL string, expectedArgs []interface{}) {
+ sqlizer := annotationBoolFilter(field)(field, value)
+ sql, args, err := sqlizer.ToSql()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(sql).To(Equal(expectedSQL))
+ Expect(args).To(Equal(expectedArgs))
+ },
+ Entry("starred=true", "starred", "true", "COALESCE(starred, 0) > 0", []interface{}(nil)),
+ Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []interface{}(nil)),
+ Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []interface{}(nil)),
+ Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []interface{}(nil)),
+ )
+
+ It("returns nil if value is not a string", func() {
+ sqlizer := annotationBoolFilter("starred")("starred", 123)
+ Expect(sqlizer).To(BeNil())
+ })
+ })
+
+ Describe("starredFilter", func() {
+ It("false includes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("starred")("starred", "false"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Item without annotation should be included in starred=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("starred")("starred", "true"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+ })
+
+ Describe("hasRatingFilter", func() {
+ It("false includes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("rating")("rating", "false"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Item without annotation should be included in has_rating=false filter")
+ })
+
+ It("true excludes items without annotations", func() {
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("rating")("rating", "true"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ for _, a := range albums {
+ Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
+ }
+ })
+
+ It("true includes items with rating > 0", func() {
+ // Create album with rating 1
+ ratedAlbum := model.Album{ID: "rated-album", Name: "Rated Album", LibraryID: 1}
+ Expect(albumRepo.Put(&ratedAlbum)).To(Succeed())
+ Expect(albumRepo.SetRating(1, ratedAlbum.ID)).To(Succeed())
+ defer func() {
+ _, _ = albumRepo.executeSQL(squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": ratedAlbum.ID}))
+ _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": ratedAlbum.ID}))
+ }()
+
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: annotationBoolFilter("rating")("rating", "true"),
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == ratedAlbum.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Album with rating 5 should be included in has_rating=true filter")
+ })
+ })
+
+ It("ignores invalid filter values (not strings)", func() {
+ res, err := albumRepo.ReadAll(rest.QueryOptions{
+ Filters: map[string]any{"starred": 123},
+ })
+ Expect(err).ToNot(HaveOccurred())
+ albums := res.(model.Albums)
+
+ var found bool
+ for _, a := range albums {
+ if a.ID == albumWithoutAnnotation.ID {
+ found = true
+ break
+ }
+ }
+ Expect(found).To(BeTrue(), "Item without annotation should be included when filter is ignored")
+ })
+})
From 1c4a7e8556f019324653c52580c072bff53b74f1 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 21 Jan 2026 13:44:14 -0500
Subject: [PATCH 02/25] fix(scanner): prevent infinite recursion in pid
configuration
closes #4920
Signed-off-by: Deluan
---
model/metadata/persistent_ids.go | 9 +++++++--
model/metadata/persistent_ids_test.go | 18 ++++++++++++++++++
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/model/metadata/persistent_ids.go b/model/metadata/persistent_ids.go
index b45882946..d4222441c 100644
--- a/model/metadata/persistent_ids.go
+++ b/model/metadata/persistent_ids.go
@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/id"
"github.com/navidrome/navidrome/utils"
@@ -26,10 +27,14 @@ type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibI
func createGetPID(hash hashFunc) getPIDFunc {
var getPID getPIDFunc
- getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string {
+ getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string) string {
attr = strings.TrimSpace(strings.ToLower(attr))
switch attr {
case "albumid":
+ if spec == conf.Server.PID.Album {
+ log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
+ return ""
+ }
return getPID(mf, md, conf.Server.PID.Album, prependLibId)
case "folder":
return filepath.Dir(mf.Path)
@@ -49,7 +54,7 @@ func createGetPID(hash hashFunc) getPIDFunc {
attributes := strings.Split(field, ",")
hasValue := false
values := slice.Map(attributes, func(attr string) string {
- v := getAttr(mf, md, attr, prependLibId)
+ v := getAttr(mf, md, attr, prependLibId, spec)
if v != "" {
hasValue = true
}
diff --git a/model/metadata/persistent_ids_test.go b/model/metadata/persistent_ids_test.go
index 7ae0c91f7..ad81eaa53 100644
--- a/model/metadata/persistent_ids_test.go
+++ b/model/metadata/persistent_ids_test.go
@@ -114,6 +114,24 @@ var _ = Describe("getPID", func() {
Expect(getPID(mf, md, spec, false)).To(Equal("(album name)"))
})
})
+
+ When("albumid configuration refers to albumid recursively", func() {
+ It("should avoid infinite recursion", func() {
+ // Reproduce the issue from #4920
+ conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
+ spec := conf.Server.PID.Album
+ md.tags = map[model.TagName][]string{
+ "album": {"Album Name"},
+ "albumversion": {"Version"},
+ "releasedate": {"2022"},
+ }
+ // Should not panic and return a valid PID ignoring the recursive "albumid"
+ Expect(func() {
+ pid := getPID(mf, md, spec, false)
+ Expect(pid).To(Equal("(\\album name\\Version\\2022)"))
+ }).To(Not(Panic()))
+ })
+ })
})
Context("edge cases", func() {
From 75dd28678f3b51e27b6c8839441530bfb9a575e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Wed, 21 Jan 2026 19:25:45 -0500
Subject: [PATCH 03/25] fix(ui): fine-tune plugins config form (#4916)
* fix(ui): use stock array renderer for plugins config form
Signed-off-by: Deluan
* fix(plugins): enforce minimum user tokens and require users field
Signed-off-by: Deluan
* fix(ui): simplify error handling in control state hook
Signed-off-by: Deluan
* fix(ui): remove "None" MenuItem from OutlinedEnumControl
Signed-off-by: Deluan
* fix(ui): enhance error handling by returning field info and path in validation errors
Signed-off-by: Deluan
* fix(ui): update OutlinedEnumControl to handle empty values and remove "None" option when required
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
.../discord-rich-presence-rs/manifest.json | 4 +-
.../discord-rich-presence/manifest.json | 4 +-
ui/src/plugin/AlwaysExpandedArrayLayout.jsx | 276 ------------------
ui/src/plugin/ConfigCard.jsx | 111 +++----
ui/src/plugin/OutlinedRenderers.jsx | 39 ++-
ui/src/plugin/SchemaConfigEditor.jsx | 4 +-
6 files changed, 64 insertions(+), 374 deletions(-)
delete mode 100644 ui/src/plugin/AlwaysExpandedArrayLayout.jsx
diff --git a/plugins/examples/discord-rich-presence-rs/manifest.json b/plugins/examples/discord-rich-presence-rs/manifest.json
index d92069762..4cf64b557 100644
--- a/plugins/examples/discord-rich-presence-rs/manifest.json
+++ b/plugins/examples/discord-rich-presence-rs/manifest.json
@@ -42,7 +42,7 @@
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
- "default": [{}],
+ "minItems": 1,
"items": {
"type": "object",
"properties": {
@@ -63,7 +63,7 @@
}
}
},
- "required": ["clientid"]
+ "required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",
diff --git a/plugins/examples/discord-rich-presence/manifest.json b/plugins/examples/discord-rich-presence/manifest.json
index 403cb917e..ac8eec010 100644
--- a/plugins/examples/discord-rich-presence/manifest.json
+++ b/plugins/examples/discord-rich-presence/manifest.json
@@ -46,7 +46,7 @@
"type": "array",
"title": "User Tokens",
"description": "Discord tokens for each Navidrome user. WARNING: Store tokens securely!",
- "default": [{}],
+ "minItems": 1,
"items": {
"type": "object",
"properties": {
@@ -67,7 +67,7 @@
}
}
},
- "required": ["clientid"]
+ "required": ["clientid", "users"]
},
"uiSchema": {
"type": "VerticalLayout",
diff --git a/ui/src/plugin/AlwaysExpandedArrayLayout.jsx b/ui/src/plugin/AlwaysExpandedArrayLayout.jsx
deleted file mode 100644
index 2c833a1f8..000000000
--- a/ui/src/plugin/AlwaysExpandedArrayLayout.jsx
+++ /dev/null
@@ -1,276 +0,0 @@
-import React, { useCallback, useMemo } from 'react'
-import {
- composePaths,
- computeLabel,
- createDefaultValue,
- isObjectArrayWithNesting,
- isPrimitiveArrayControl,
- rankWith,
- findUISchema,
- Resolve,
-} from '@jsonforms/core'
-import {
- JsonFormsDispatch,
- withJsonFormsArrayLayoutProps,
-} from '@jsonforms/react'
-import range from 'lodash/range'
-import merge from 'lodash/merge'
-import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'
-import { Add, Delete } from '@material-ui/icons'
-import { makeStyles } from '@material-ui/core/styles'
-
-const useStyles = makeStyles((theme) => ({
- arrayItem: {
- position: 'relative',
- padding: theme.spacing(2),
- marginBottom: theme.spacing(2),
- border: `1px solid ${theme.palette.divider}`,
- borderRadius: theme.shape.borderRadius,
- '&:last-child': {
- marginBottom: 0,
- },
- },
- deleteButton: {
- position: 'absolute',
- top: theme.spacing(1),
- right: theme.spacing(1),
- },
- itemContent: {
- paddingRight: theme.spacing(4), // Space for delete button
- },
-}))
-
-// Default translations for array controls
-const defaultTranslations = {
- addTooltip: 'Add',
- addAriaLabel: 'Add button',
- removeTooltip: 'Delete',
- removeAriaLabel: 'Delete button',
- noDataMessage: 'No data',
-}
-
-// Simplified array item renderer - clean card layout
-// eslint-disable-next-line react-refresh/only-export-components
-const ArrayItem = ({
- index,
- path,
- schema,
- uischema,
- uischemas,
- rootSchema,
- renderers,
- cells,
- enabled,
- removeItems,
- translations,
- disableRemove,
-}) => {
- const classes = useStyles()
- const childPath = composePaths(path, `${index}`)
-
- const foundUISchema = useMemo(
- () =>
- findUISchema(
- uischemas,
- schema,
- uischema.scope,
- path,
- undefined,
- uischema,
- rootSchema,
- ),
- [uischemas, schema, path, uischema, rootSchema],
- )
-
- return (
-
- {enabled && !disableRemove && (
-
- removeItems(path, [index])()}
- size="small"
- aria-label={translations.removeAriaLabel}
- >
-
-
-
- )}
-
-
-
-
- )
-}
-
-// Array toolbar with add button
-// eslint-disable-next-line react-refresh/only-export-components
-const ArrayToolbar = ({
- label,
- description,
- enabled,
- addItem,
- path,
- createDefault,
- translations,
- disableAdd,
-}) => (
-
-
- {label}
- {!disableAdd && (
-
-
-
-
-
- )}
-
- {description && (
-
- {description}
-
- )}
-
-)
-
-const useArrayStyles = makeStyles((theme) => ({
- container: {
- marginBottom: theme.spacing(2),
- },
-}))
-
-// Main array layout component - items always expanded
-// eslint-disable-next-line react-refresh/only-export-components
-const AlwaysExpandedArrayLayoutComponent = (props) => {
- const arrayClasses = useArrayStyles()
- const {
- enabled,
- data,
- path,
- schema,
- uischema,
- addItem,
- removeItems,
- renderers,
- cells,
- label,
- description,
- required,
- rootSchema,
- config,
- uischemas,
- disableAdd,
- disableRemove,
- } = props
-
- const innerCreateDefaultValue = useCallback(
- () => createDefaultValue(schema, rootSchema),
- [schema, rootSchema],
- )
-
- const appliedUiSchemaOptions = merge({}, config, uischema.options)
- const doDisableAdd = disableAdd || appliedUiSchemaOptions.disableAdd
- const doDisableRemove = disableRemove || appliedUiSchemaOptions.disableRemove
- const translations = defaultTranslations
-
- return (
-
-
-
- {data > 0 ? (
- range(data).map((index) => (
-
- ))
- ) : (
-
- {translations.noDataMessage}
-
- )}
-
-
- )
-}
-
-// Wrap with JSONForms HOC
-const WrappedArrayLayout = withJsonFormsArrayLayoutProps(
- AlwaysExpandedArrayLayoutComponent,
-)
-
-// Custom tester that matches arrays but NOT enum arrays
-// Enum arrays should be handled by MaterialEnumArrayRenderer (for checkboxes)
-const isNonEnumArrayControl = (uischema, schema) => {
- // First check if it matches our base conditions (object array or primitive array)
- const baseCheck =
- isObjectArrayWithNesting(uischema, schema) ||
- isPrimitiveArrayControl(uischema, schema)
-
- if (!baseCheck) {
- return false
- }
-
- // Resolve the actual schema for this control using JSONForms utility
- const rootSchema = schema
- const resolved = Resolve.schema(rootSchema, uischema?.scope, rootSchema)
-
- // Exclude enum arrays (uniqueItems + oneOf/enum) - let MaterialEnumArrayRenderer handle them
- if (resolved?.uniqueItems && resolved?.items) {
- const { items } = resolved
- if (items.oneOf?.every((e) => e.const !== undefined) || items.enum) {
- return false
- }
- }
-
- return true
-}
-
-// Export as a renderer entry with high priority (5 > default 4)
-// Matches both object arrays with nesting and primitive arrays, but NOT enum arrays
-export const AlwaysExpandedArrayLayout = {
- tester: rankWith(5, isNonEnumArrayControl),
- renderer: WrappedArrayLayout,
-}
diff --git a/ui/src/plugin/ConfigCard.jsx b/ui/src/plugin/ConfigCard.jsx
index 4e5bd6294..d9815aa3e 100644
--- a/ui/src/plugin/ConfigCard.jsx
+++ b/ui/src/plugin/ConfigCard.jsx
@@ -4,76 +4,37 @@ import { Card, CardContent, Typography, Box } from '@material-ui/core'
import Alert from '@material-ui/lab/Alert'
import { SchemaConfigEditor } from './SchemaConfigEditor'
-// Navigate schema by path parts to find the title for a field
-const findFieldTitle = (schema, parts) => {
+// Format error with field title and full path for nested fields
+const formatError = (error, schema) => {
+ // Get path parts from various error formats
+ const rawPath =
+ error.dataPath || error.property || error.instancePath?.replace(/\//g, '.')
+ const parts = rawPath?.split('.').filter(Boolean) || []
+
+ // Navigate schema to find field title, build bracket-notation path
let currentSchema = schema
- let fieldName = parts[parts.length - 1] // Default to last part
+ let fieldName = parts[parts.length - 1]
+ const pathParts = []
for (const part of parts) {
- if (!currentSchema) break
-
- // Skip array indices (just move to items schema)
if (/^\d+$/.test(part)) {
- if (currentSchema.items) {
- currentSchema = currentSchema.items
- }
- continue
- }
-
- // Navigate to property and always update fieldName
- if (currentSchema.properties?.[part]) {
- const propSchema = currentSchema.properties[part]
- fieldName = propSchema.title || part
- currentSchema = propSchema
+ pathParts.push(`[${part}]`)
+ currentSchema = currentSchema?.items
+ } else {
+ fieldName = currentSchema?.properties?.[part]?.title || part
+ pathParts.push(part)
+ currentSchema = currentSchema?.properties?.[part]
}
}
- return fieldName
-}
+ const path = pathParts.join('.').replace(/\.\[/g, '[')
+ const isNested = path.includes('[') || path.includes('.')
+ // Replace property name in message with full path for nested fields
+ const message = isNested
+ ? error.message.replace(/'[^']+'\s*$/, `'${path}'`)
+ : error.message
-// Extract human-readable field name from JSONForms error
-const getFieldName = (error, schema) => {
- // JSONForms errors can have different path formats:
- // - dataPath: "users.1.token" (dot-separated)
- // - instancePath: "/users/1/token" (slash-separated)
- // - property: "users.1.username" (dot-separated)
- const dataPath = error.dataPath || ''
- const instancePath = error.instancePath || ''
- const property = error.property || ''
-
- // Try dataPath first (dot-separated like "users.1.token")
- if (dataPath) {
- const parts = dataPath.split('.').filter(Boolean)
- if (parts.length > 0) {
- return findFieldTitle(schema, parts)
- }
- }
-
- // Try property (also dot-separated)
- if (property) {
- const parts = property.split('.').filter(Boolean)
- if (parts.length > 0) {
- return findFieldTitle(schema, parts)
- }
- }
-
- // Fall back to instancePath (slash-separated like "/users/1/token")
- if (instancePath) {
- const parts = instancePath.split('/').filter(Boolean)
- if (parts.length > 0) {
- return findFieldTitle(schema, parts)
- }
- }
-
- // Try to extract from schemaPath like "#/properties/users/items/properties/username/minLength"
- const schemaPath = error.schemaPath || ''
- const propMatches = [...schemaPath.matchAll(/\/properties\/([^/]+)/g)]
- if (propMatches.length > 0) {
- const parts = propMatches.map((m) => m[1])
- return findFieldTitle(schema, parts)
- }
-
- return null
+ return { fieldName, message }
}
export const ConfigCard = ({
@@ -99,14 +60,10 @@ export const ConfigCard = ({
// Format validation errors with proper field names
const formattedErrors = useMemo(() => {
- if (!hasConfigSchema) {
- return []
- }
- const { schema } = manifest.config
- return validationErrors.map((error) => ({
- fieldName: getFieldName(error, schema),
- message: error.message,
- }))
+ if (!hasConfigSchema) return []
+ return validationErrors.map((error) =>
+ formatError(error, manifest.config.schema),
+ )
}, [validationErrors, manifest, hasConfigSchema])
if (!hasConfigSchema) {
@@ -139,12 +96,14 @@ export const ConfigCard = ({
)}
-
+ 0 ? 0 : 2}>
+
+
)
diff --git a/ui/src/plugin/OutlinedRenderers.jsx b/ui/src/plugin/OutlinedRenderers.jsx
index 5156038f1..8020a5e4f 100644
--- a/ui/src/plugin/OutlinedRenderers.jsx
+++ b/ui/src/plugin/OutlinedRenderers.jsx
@@ -40,18 +40,14 @@ const useStyles = makeStyles(
/**
* Hook for common control state (focus, validation, description visibility)
- * Tracks "touched" state to only show errors after the user has interacted with the field
*/
const useControlState = (props) => {
const { config, uischema, description, visible, errors } = props
const [isFocused, setIsFocused] = useState(false)
- const [isTouched, setIsTouched] = useState(false)
const appliedUiSchemaOptions = merge({}, config, uischema?.options)
// errors is a string when there are validation errors, empty/undefined when valid
- const hasErrors = errors && errors.length > 0
- // Only show as invalid after the field has been touched (blurred)
- const showError = isTouched && hasErrors
+ const showError = errors && errors.length > 0
const showDescription = !isDescriptionHidden(
visible,
@@ -63,10 +59,7 @@ const useControlState = (props) => {
const helperText = showError ? errors : showDescription ? description : ''
const handleFocus = () => setIsFocused(true)
- const handleBlur = () => {
- setIsFocused(false)
- setIsTouched(true)
- }
+ const handleBlur = () => setIsFocused(false)
return {
isFocused,
@@ -185,8 +178,17 @@ const OutlinedNumberControl = (props) => {
// Enum/Select control wrapper
const OutlinedEnumControl = (props) => {
const classes = useStyles()
- const { data, id, enabled, path, handleChange, options, label, visible } =
- props
+ const {
+ data,
+ id,
+ enabled,
+ path,
+ handleChange,
+ options,
+ label,
+ visible,
+ required,
+ } = props
const {
appliedUiSchemaOptions,
showError,
@@ -212,7 +214,12 @@ const OutlinedEnumControl = (props) => {
labelId={`${id}-label`}
id={id}
value={data ?? ''}
- onChange={(ev) => handleChange(path, ev.target.value)}
+ onChange={(ev) => {
+ handleChange(
+ path,
+ ev.target.value === '' ? undefined : ev.target.value,
+ )
+ }}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={!enabled}
@@ -220,9 +227,11 @@ const OutlinedEnumControl = (props) => {
label={label}
fullWidth
>
-
+ {!required && (
+
+ )}
{options?.map((option) => (
"
+ }
+ }
+ },
+ "extensions": {
+ "queryCost": 3
+ }
+}
diff --git a/tests/fixtures/deezer.artist.bio.fr.json b/tests/fixtures/deezer.artist.bio.fr.json
new file mode 100644
index 000000000..435f6fcf0
--- /dev/null
+++ b/tests/fixtures/deezer.artist.bio.fr.json
@@ -0,0 +1,12 @@
+{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": "Guy-Manuel de Homem Christo et Thomas Bangalter se rencontrent en 1987 au lycée Carnot de Paris. Partageant une même passion pour la musique, les deux amis fondent en 1992 Darlin', un groupe de rock influencé par les Stooges et MC5, dont la production sera taxée par un critique de la presse anglaise de «daft punk» (« punk idiot »).
\n
\nDécouragés face à l'apathie du milieu rock, ils décident un peu plus tard de se lancer à corps perdus dans le courant Techno alors en pleine explosion. Arrive alors la découverte de la House, des clubs et des raves, dont une en particulier qui déterminera leur avenir : en 1993 est organisé à EuroDisney une rave où notre duo rencontre les dirigeants du label techno écossais Soma."
+ }
+ }
+ },
+ "extensions": {
+ "queryCost": 3
+ }
+}
diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json
deleted file mode 100644
index 80e439bae..000000000
--- a/tests/fixtures/deezer.artist.bio.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "data": {
- "artist": {
- "bio": {
- "full": "Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.
"
- }
- }
- }
-}
diff --git a/tests/fixtures/lastfm.album.getinfo.empty.json b/tests/fixtures/lastfm.album.getinfo.empty.json
new file mode 100644
index 000000000..06403ff8c
--- /dev/null
+++ b/tests/fixtures/lastfm.album.getinfo.empty.json
@@ -0,0 +1 @@
+{"album":{"artist":"Legião Urbana","mbid":"1749dd07-5aa9-436e-babc-e3e982deb273","tags":{"tag":[{"url":"https:\/\/www.last.fm\/tag\/rock","name":"rock"},{"url":"https:\/\/www.last.fm\/tag\/80s","name":"80s"},{"url":"https:\/\/www.last.fm\/tag\/brazilian","name":"brazilian"},{"url":"https:\/\/www.last.fm\/tag\/brasil","name":"brasil"},{"url":"https:\/\/www.last.fm\/tag\/brazilian+rock","name":"brazilian rock"}]},"name":"Dois","image":[{"size":"small","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/34s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"medium","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/64s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"large","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/174s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"extralarge","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"mega","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"}],"tracks":{"track":[{"streamable":{"fulltrack":"0","#text":"0"},"duration":232,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Daniel+na+Cova+dos+Le%C3%B5es","name":"Daniel na Cova dos Leões","@attr":{"rank":1},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":null,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Quase+Sem+Querer","name":"Quase Sem Querer","@attr":{"rank":2},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":280,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Acrilic+On+Canvas","name":"Acrilic On Canvas","@attr":{"rank":3},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":271,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Eduardo+e+M%C3%B4nica","name":"Eduardo e Mônica","@attr":{"rank":4},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":94,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Central+Do+Brasil","name":"Central Do Brasil","@attr":{"rank":5},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":302,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Tempo+Perdido","name":"Tempo Perdido","@attr":{"rank":6},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":170,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Metr%C3%B3pole","name":"Metrópole","@attr":{"rank":7},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":175,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Plantas+Em+Baixo+Do+Aqu%C3%A1rio","name":"Plantas Em Baixo Do Aquário","@attr":{"rank":8},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":162,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/M%C3%BAsica+urbana+2","name":"Música urbana 2","@attr":{"rank":9},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":184,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Andrea+Doria","name":"Andrea Doria","@attr":{"rank":10},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":231,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/F%C3%A1brica","name":"Fábrica","@attr":{"rank":11},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":258,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/%C3%8Dndios","name":"Índios","@attr":{"rank":12},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}}]},"listeners":"494356","playcount":"9833783","url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois"}}
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.album.getinfo.en.json b/tests/fixtures/lastfm.album.getinfo.en.json
new file mode 100644
index 000000000..f5444f35b
--- /dev/null
+++ b/tests/fixtures/lastfm.album.getinfo.en.json
@@ -0,0 +1 @@
+{"album":{"artist":"Legião Urbana","mbid":"1749dd07-5aa9-436e-babc-e3e982deb273","tags":{"tag":[{"url":"https:\/\/www.last.fm\/tag\/rock","name":"rock"},{"url":"https:\/\/www.last.fm\/tag\/80s","name":"80s"},{"url":"https:\/\/www.last.fm\/tag\/brazilian","name":"brazilian"},{"url":"https:\/\/www.last.fm\/tag\/brasil","name":"brasil"},{"url":"https:\/\/www.last.fm\/tag\/brazilian+rock","name":"brazilian rock"}]},"playcount":"9833783","image":[{"size":"small","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/34s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"medium","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/64s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"large","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/174s\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"extralarge","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"mega","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"},{"size":"","#text":"https:\/\/lastfm.freetls.fastly.net\/i\/u\/300x300\/e7dbfc64cde5478484af18f0e30662e0.png"}],"tracks":{"track":[{"streamable":{"fulltrack":"0","#text":"0"},"duration":232,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Daniel+na+Cova+dos+Le%C3%B5es","name":"Daniel na Cova dos Leões","@attr":{"rank":1},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":null,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Quase+Sem+Querer","name":"Quase Sem Querer","@attr":{"rank":2},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":280,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Acrilic+On+Canvas","name":"Acrilic On Canvas","@attr":{"rank":3},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":271,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Eduardo+e+M%C3%B4nica","name":"Eduardo e Mônica","@attr":{"rank":4},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":94,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Central+Do+Brasil","name":"Central Do Brasil","@attr":{"rank":5},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":302,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Tempo+Perdido","name":"Tempo Perdido","@attr":{"rank":6},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":170,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Metr%C3%B3pole","name":"Metrópole","@attr":{"rank":7},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":175,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Plantas+Em+Baixo+Do+Aqu%C3%A1rio","name":"Plantas Em Baixo Do Aquário","@attr":{"rank":8},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":162,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/M%C3%BAsica+urbana+2","name":"Música urbana 2","@attr":{"rank":9},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":184,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/Andrea+Doria","name":"Andrea Doria","@attr":{"rank":10},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":231,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/F%C3%A1brica","name":"Fábrica","@attr":{"rank":11},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":258,"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois\/%C3%8Dndios","name":"Índios","@attr":{"rank":12},"artist":{"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana","name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99"}}]},"url":"https:\/\/www.last.fm\/music\/Legi%C3%A3o+Urbana\/Dois","name":"Dois","listeners":"494356","wiki":{"published":"10 Oct 2023, 13:56","summary":"Dois é o segundo álbum de estúdio da banda brasileira de rock Legião Urbana, lançado em 20 de julho de 1986 pela EMI. Ocupa a 21ª posição da lista dos 100 maiores discos da música brasileira pela Rolling Stone Brasil. Em setembro de 2012, foi eleito pelo público da rádio Eldorado FM, do portal Estadao.com e do Caderno C2+Música (estes dois últimos pertencentes ao jornal O Estado de S. Paulo) como o terceiro melhor disco brasileiro da história. O álbum vendeu mais de 900 mil cópias no Brasil. \"Tempo Perdido\" fez um grande sucesso e se tornou num dos clássicos Read more on Last.fm<\/a>.","content":"Dois é o segundo álbum de estúdio da banda brasileira de rock Legião Urbana, lançado em 20 de julho de 1986 pela EMI. Ocupa a 21ª posição da lista dos 100 maiores discos da música brasileira pela Rolling Stone Brasil. Em setembro de 2012, foi eleito pelo público da rádio Eldorado FM, do portal Estadao.com e do Caderno C2+Música (estes dois últimos pertencentes ao jornal O Estado de S. Paulo) como o terceiro melhor disco brasileiro da história. O álbum vendeu mais de 900 mil cópias no Brasil. \"Tempo Perdido\" fez um grande sucesso e se tornou num dos clássicos da Legião. \"Eduardo e Mônica\", \"\"Índios\"\" e \"Quase sem Querer\" também fizeram sucesso. Read more on Last.fm<\/a>. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}}
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.artist.getinfo.empty.json b/tests/fixtures/lastfm.artist.getinfo.empty.json
new file mode 100644
index 000000000..015b51701
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.getinfo.empty.json
@@ -0,0 +1 @@
+{"artist":{"name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99","url":"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0","ontour":"0","stats":{"listeners":"740591","playcount":"44493504"},"similar":{"artist":[{"name":"Renato Russo","url":"https://www.last.fm/music/Renato+Russo","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Engenheiros Do Hawaii","url":"https://www.last.fm/music/Engenheiros+Do+Hawaii","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Os Paralamas Do Sucesso","url":"https://www.last.fm/music/Os+Paralamas+Do+Sucesso","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Barão Vermelho","url":"https://www.last.fm/music/Bar%C3%A3o+Vermelho","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Capital Inicial","url":"https://www.last.fm/music/Capital+Inicial","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]}]},"tags":{"tag":[{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"brazilian rock","url":"https://www.last.fm/tag/brazilian+rock"},{"name":"80s","url":"https://www.last.fm/tag/80s"},{"name":"brazilian","url":"https://www.last.fm/tag/brazilian"},{"name":"brasil","url":"https://www.last.fm/tag/brasil"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/+noredirect/Legi%C3%A3o+Urbana/+wiki"}},"published":"01 Jan 1970, 00:00","summary":" Read more on Last.fm","content":""}}}
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.artist.getinfo.en.json b/tests/fixtures/lastfm.artist.getinfo.en.json
new file mode 100644
index 000000000..6c643b8e2
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.getinfo.en.json
@@ -0,0 +1 @@
+{"artist":{"name":"Legião Urbana","mbid":"47685be0-926f-4be9-b1ae-e32da47a3b99","url":"https://www.last.fm/music/+noredirect/Legi%C3%A3o+Urbana","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}],"streamable":"0","ontour":"0","stats":{"listeners":"740367","playcount":"44476703"},"similar":{"artist":[{"name":"Renato Russo","url":"https://www.last.fm/music/Renato+Russo","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Engenheiros Do Hawaii","url":"https://www.last.fm/music/Engenheiros+Do+Hawaii","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Os Paralamas Do Sucesso","url":"https://www.last.fm/music/Os+Paralamas+Do+Sucesso","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Barão Vermelho","url":"https://www.last.fm/music/Bar%C3%A3o+Vermelho","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]},{"name":"Capital Inicial","url":"https://www.last.fm/music/Capital+Inicial","image":[{"#text":"https://lastfm.freetls.fastly.net/i/u/34s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"small"},{"#text":"https://lastfm.freetls.fastly.net/i/u/64s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"medium"},{"#text":"https://lastfm.freetls.fastly.net/i/u/174s/2a96cbd8b46e442fc41c2b86b821562f.png","size":"large"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"extralarge"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":"mega"},{"#text":"https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png","size":""}]}]},"tags":{"tag":[{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"brazilian rock","url":"https://www.last.fm/tag/brazilian+rock"},{"name":"80s","url":"https://www.last.fm/tag/80s"},{"name":"brazilian","url":"https://www.last.fm/tag/brazilian"},{"name":"brasil","url":"https://www.last.fm/tag/brasil"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/+noredirect/Legi%C3%A3o+Urbana/+wiki"}},"published":"03 Mar 2006, 04:04","summary":"Legião Urbana was a Brazilian post-punk band from Brasília, Distrito Federal, Brazil.\n\nFronted by lead singer and lyricist Renato Russo, Legião Urbana was founded in 1983 and existed until 1996, when Renato passed away due to complications caused by AIDS. Besides being the lead vocalist, Renato was an occasional guitar, bass and keyboards player. He also wrote most of the band's songs. In 13 years of career, they released 8 studio albums - the last being posthumous - and one live. Read more on Last.fm","content":"Legião Urbana was a Brazilian post-punk band from Brasília, Distrito Federal, Brazil.\n\nFronted by lead singer and lyricist Renato Russo, Legião Urbana was founded in 1983 and existed until 1996, when Renato passed away due to complications caused by AIDS. Besides being the lead vocalist, Renato was an occasional guitar, bass and keyboards player. He also wrote most of the band's songs. In 13 years of career, they released 8 studio albums - the last being posthumous - and one live.\n\nLegião Urbana is probably the most famous Brazilian rock bands, especially known for Renato's poetic lyrics, which range from love and spiritualism to politics, family, sex and drugs.\n\nNowadays, Dado Villa-Lobos (ex-guitar player of Legião Urbana) has a solo career and recorded his first album, named \"Jardim De Cactus\", in 2005. Drum player Marcelo Bonfá also tried a solo career. Read more on Last.fm. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}}
\ No newline at end of file
From 4c2bd7509c1b5f3030a77dbb9666b3b3354d2bae Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 29 Jan 2026 17:04:10 -0500
Subject: [PATCH 18/25] fix(ui): disable shuffle for instant mix playback
Signed-off-by: Deluan
---
ui/src/common/SongContextMenu.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx
index ce6604f63..ac5b10e13 100644
--- a/ui/src/common/SongContextMenu.jsx
+++ b/ui/src/common/SongContextMenu.jsx
@@ -96,7 +96,7 @@ export const SongContextMenu = ({
const id = record.mediaFileId || record.id
await playSimilar(dispatch, notify, id, {
seedRecord: record,
- shuffle: true,
+ shuffle: false,
})
} catch (e) {
// eslint-disable-next-line no-console
From 7d5e13672d588e0195137f909afc2e480e7a9a67 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 29 Jan 2026 17:27:16 -0500
Subject: [PATCH 19/25] refactor(plugins): remove unnecessary configuration
permissions from manifest files
Signed-off-by: Deluan
---
plugins/examples/crypto-ticker/manifest.json | 3 ---
plugins/manifest-schema.json | 11 -----------
plugins/manifest_gen.go | 6 ------
3 files changed, 20 deletions(-)
diff --git a/plugins/examples/crypto-ticker/manifest.json b/plugins/examples/crypto-ticker/manifest.json
index 59d00cbaa..362fcd0e9 100644
--- a/plugins/examples/crypto-ticker/manifest.json
+++ b/plugins/examples/crypto-ticker/manifest.json
@@ -60,9 +60,6 @@
}
},
"permissions": {
- "config": {
- "reason": "To read ticker symbols configuration"
- },
"scheduler": {
"reason": "To schedule reconnection attempts on connection loss"
},
diff --git a/plugins/manifest-schema.json b/plugins/manifest-schema.json
index 881592c28..4e64ca6ea 100644
--- a/plugins/manifest-schema.json
+++ b/plugins/manifest-schema.json
@@ -153,17 +153,6 @@
}
}
},
- "ConfigPermission": {
- "type": "object",
- "description": "Configuration access permissions for a plugin",
- "additionalProperties": false,
- "properties": {
- "reason": {
- "type": "string",
- "description": "Explanation for why config access is needed"
- }
- }
- },
"SubsonicAPIPermission": {
"type": "object",
"description": "SubsonicAPI service permissions. Requires 'users' permission to be declared.",
diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go
index 9762babbf..27c3c0677 100644
--- a/plugins/manifest_gen.go
+++ b/plugins/manifest_gen.go
@@ -45,12 +45,6 @@ func (j *ConfigDefinition) UnmarshalJSON(value []byte) error {
return nil
}
-// Configuration access permissions for a plugin
-type ConfigPermission struct {
- // Explanation for why config access is needed
- Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
-}
-
// Experimental features that may change or be removed in future versions
type Experimental struct {
// Threads corresponds to the JSON schema field "threads".
From 36252823ce1c8c22db19090fa80db3c802f54c6b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 30 Jan 2026 15:25:00 +0100
Subject: [PATCH 20/25] fix(agents): deduplicate mismatched songs in similar
songs matching (#4956)
* feat(agents): enhance song matching by removing unwanted duplicates while preserving identical entries
Signed-off-by: Deluan
* refactor: consolidate duplicate checks
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
core/external/provider_matching.go | 85 ++++++++++-----
core/external/provider_matching_test.go | 135 ++++++++++++++++++++++++
2 files changed, 192 insertions(+), 28 deletions(-)
diff --git a/core/external/provider_matching.go b/core/external/provider_matching.go
index 8a6c6a097..74ad56d42 100644
--- a/core/external/provider_matching.go
+++ b/core/external/provider_matching.go
@@ -119,17 +119,24 @@ func (e *provider) matchSongsToLibrary(ctx context.Context, songs []agents.Song,
// songMatchedIn checks if a song has already been matched in any of the provided match maps.
// It checks the song's ID, MBID, and ISRC fields against the corresponding map keys.
func songMatchedIn(s agents.Song, priorMatches ...map[string]model.MediaFile) bool {
+ _, found := lookupByIdentifiers(s, priorMatches...)
+ return found
+}
+
+// lookupByIdentifiers searches for a song's identifiers (ID, MBID, ISRC) in the provided maps.
+// Returns the first matching MediaFile found and true, or an empty MediaFile and false if no match.
+func lookupByIdentifiers(s agents.Song, maps ...map[string]model.MediaFile) (model.MediaFile, bool) {
keys := []string{s.ID, s.MBID, s.ISRC}
- for _, m := range priorMatches {
+ for _, m := range maps {
for _, key := range keys {
if key != "" {
if mf, ok := m[key]; ok && mf.ID != "" {
- return true
+ return mf, true
}
}
}
}
- return false
+ return model.MediaFile{}, false
}
// loadTracksByID fetches MediaFiles from the library using direct ID matching.
@@ -405,6 +412,9 @@ func (e *provider) findBestMatch(q songQuery, tracks model.MediaFiles, threshold
return bestMatch, found
}
+// buildTitleQueries converts agent songs into normalized songQuery structs for title+artist matching.
+// It skips songs that have already been matched in prior phases (by ID, MBID, or ISRC) and sanitizes
+// all string fields for consistent comparison (lowercase, diacritics removed, articles stripped from artist names).
func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[string]model.MediaFile) []songQuery {
var queries []songQuery
for _, s := range songs {
@@ -423,42 +433,61 @@ func (e *provider) buildTitleQueries(songs []agents.Song, priorMatches ...map[st
return queries
}
+// selectBestMatchingSongs assembles the final result by mapping input songs to their best matching
+// library tracks. It iterates through the input songs in order and selects the first available match
+// using priority order: ID > MBID > ISRC > title+artist.
+//
+// The function also handles deduplication: when multiple different input songs would match the same
+// library track (e.g., "Song (Live)" and "Song (Remastered)" both matching "Song (Live)" in the library),
+// only the first match is kept. However, if the same input song appears multiple times (intentional
+// repetition), duplicates are preserved in the output.
+//
+// Returns up to 'count' MediaFiles, preserving the input order. Songs that cannot be matched are skipped.
func (e *provider) selectBestMatchingSongs(songs []agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile, count int) model.MediaFiles {
- var mfs model.MediaFiles
+ mfs := make(model.MediaFiles, 0, len(songs))
+ // Track MediaFile.ID -> input song that added it, for deduplication
+ addedBy := make(map[string]agents.Song, len(songs))
+
for _, t := range songs {
if len(mfs) == count {
break
}
- // Try ID match first
- if t.ID != "" {
- if mf, ok := byID[t.ID]; ok {
- mfs = append(mfs, mf)
- continue
+
+ mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitleArtist)
+ if !found {
+ continue
+ }
+
+ // Check for duplicate library track
+ if prevSong, alreadyAdded := addedBy[mf.ID]; alreadyAdded {
+ // Only add duplicate if input songs are identical
+ if t != prevSong {
+ continue // Different input songs → skip mismatch-induced duplicate
}
+ } else {
+ addedBy[mf.ID] = t
}
- // Try MBID match second
- if t.MBID != "" {
- if mf, ok := byMBID[t.MBID]; ok {
- mfs = append(mfs, mf)
- continue
- }
- }
- // Try ISRC match third
- if t.ISRC != "" {
- if mf, ok := byISRC[t.ISRC]; ok {
- mfs = append(mfs, mf)
- continue
- }
- }
- // Fall back to title+artist match (composite key preserves duplicate titles)
- key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
- if mf, ok := byTitleArtist[key]; ok {
- mfs = append(mfs, mf)
- }
+
+ mfs = append(mfs, mf)
}
return mfs
}
+// findMatchingTrack looks up a song in the match maps using priority order: ID > MBID > ISRC > title+artist.
+// Returns the matched MediaFile and true if found, or an empty MediaFile and false if no match exists.
+func findMatchingTrack(t agents.Song, byID, byMBID, byISRC, byTitleArtist map[string]model.MediaFile) (model.MediaFile, bool) {
+ // Try identifier-based matches first (ID, MBID, ISRC)
+ if mf, found := lookupByIdentifiers(t, byID, byMBID, byISRC); found {
+ return mf, true
+ }
+ // Fall back to title+artist fuzzy match
+ key := str.SanitizeFieldForSorting(t.Name) + "|" + str.SanitizeFieldForSortingNoArticle(t.Artist)
+ if mf, ok := byTitleArtist[key]; ok {
+ return mf, true
+ }
+ return model.MediaFile{}, false
+}
+
// similarityRatio calculates the similarity between two strings using Jaro-Winkler algorithm.
// Returns a value between 0.0 (completely different) and 1.0 (identical).
// Jaro-Winkler is well-suited for matching song titles because it gives higher scores
diff --git a/core/external/provider_matching_test.go b/core/external/provider_matching_test.go
index 3a902cba3..b3624ef3a 100644
--- a/core/external/provider_matching_test.go
+++ b/core/external/provider_matching_test.go
@@ -624,4 +624,139 @@ var _ = Describe("Provider - Song Matching", func() {
})
})
})
+
+ Describe("Deduplication of mismatched songs", func() {
+ var track model.MediaFile
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.SimilarSongsMatchThreshold = 85 // Allow fuzzy matching
+
+ track = model.MediaFile{ID: "track-1", Title: "Test Track", Artist: "Test Artist"}
+
+ // Setup for GetEntityByID to return the track
+ artistRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ albumRepo.On("Get", "track-1").Return(nil, model.ErrNotFound).Once()
+ mediaFileRepo.On("Get", "track-1").Return(&track, nil).Once()
+ })
+
+ It("removes duplicates when different input songs match the same library track", func() {
+ // Agent returns two different versions that will both fuzzy-match to the same library track
+ returnedSongs := []agents.Song{
+ {Name: "Bohemian Rhapsody (Live)", Artist: "Queen"},
+ {Name: "Bohemian Rhapsody (Original Mix)", Artist: "Queen"},
+ }
+ // Library only has one version
+ libraryTrack := model.MediaFile{
+ ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen",
+ }
+
+ setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
+
+ songs, err := provider.SimilarSongs(ctx, "track-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ // Should only return one track, not two duplicates
+ Expect(songs).To(HaveLen(1))
+ Expect(songs[0].ID).To(Equal("br-live"))
+ })
+
+ It("preserves duplicates when identical input songs match the same library track", func() {
+ // Agent returns the exact same song twice (intentional repetition)
+ returnedSongs := []agents.Song{
+ {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
+ {Name: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera"},
+ }
+ // Library has matching track
+ libraryTrack := model.MediaFile{
+ ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera",
+ }
+
+ setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
+
+ songs, err := provider.SimilarSongs(ctx, "track-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ // Should return two tracks since input songs were identical
+ Expect(songs).To(HaveLen(2))
+ Expect(songs[0].ID).To(Equal("br"))
+ Expect(songs[1].ID).To(Equal("br"))
+ })
+
+ It("handles mixed scenario with both identical and different input songs", func() {
+ // Agent returns: Song A, Song B (different from A), Song A again (same as first)
+ // All three match to the same library track
+ returnedSongs := []agents.Song{
+ {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"},
+ {Name: "Yesterday (Remastered)", Artist: "The Beatles", Album: "1"}, // Different version
+ {Name: "Yesterday", Artist: "The Beatles", Album: "Help!"}, // Same as first
+ {Name: "Yesterday (Anthology)", Artist: "The Beatles", Album: "Anthology"}, // Another different version
+ }
+ // Library only has one version
+ libraryTrack := model.MediaFile{
+ ID: "yesterday", Title: "Yesterday", Artist: "The Beatles", Album: "Help!",
+ }
+
+ setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{libraryTrack})
+
+ songs, err := provider.SimilarSongs(ctx, "track-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ // Should return 2 tracks:
+ // 1. First "Yesterday" (original)
+ // 2. Third "Yesterday" (same as first, so kept)
+ // Skip: Second "Yesterday (Remastered)" (different input, same library track)
+ // Skip: Fourth "Yesterday (Anthology)" (different input, same library track)
+ Expect(songs).To(HaveLen(2))
+ Expect(songs[0].ID).To(Equal("yesterday"))
+ Expect(songs[1].ID).To(Equal("yesterday"))
+ })
+
+ It("does not deduplicate songs that match different library tracks", func() {
+ // Agent returns different songs that match different library tracks
+ returnedSongs := []agents.Song{
+ {Name: "Song A", Artist: "Artist"},
+ {Name: "Song B", Artist: "Artist"},
+ {Name: "Song C", Artist: "Artist"},
+ }
+ // Library has all three songs
+ trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
+ trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
+ trackC := model.MediaFile{ID: "track-c", Title: "Song C", Artist: "Artist"}
+
+ setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB, trackC})
+
+ songs, err := provider.SimilarSongs(ctx, "track-1", 5)
+
+ Expect(err).ToNot(HaveOccurred())
+ // All three should be returned since they match different library tracks
+ Expect(songs).To(HaveLen(3))
+ Expect(songs[0].ID).To(Equal("track-a"))
+ Expect(songs[1].ID).To(Equal("track-b"))
+ Expect(songs[2].ID).To(Equal("track-c"))
+ })
+
+ It("respects count limit after deduplication", func() {
+ // Agent returns 4 songs: 2 unique + 2 that would create duplicates
+ returnedSongs := []agents.Song{
+ {Name: "Song A", Artist: "Artist"},
+ {Name: "Song A (Live)", Artist: "Artist"}, // Different, matches same track
+ {Name: "Song B", Artist: "Artist"},
+ {Name: "Song B (Remix)", Artist: "Artist"}, // Different, matches same track
+ }
+ trackA := model.MediaFile{ID: "track-a", Title: "Song A", Artist: "Artist"}
+ trackB := model.MediaFile{ID: "track-b", Title: "Song B", Artist: "Artist"}
+
+ setupSimilarSongsExpectations(returnedSongs, model.MediaFiles{trackA, trackB})
+
+ // Request only 2 songs
+ songs, err := provider.SimilarSongs(ctx, "track-1", 2)
+
+ Expect(err).ToNot(HaveOccurred())
+ // Should return exactly 2: Song A and Song B (skipping duplicates)
+ Expect(songs).To(HaveLen(2))
+ Expect(songs[0].ID).To(Equal("track-a"))
+ Expect(songs[1].ID).To(Equal("track-b"))
+ })
+ })
})
From f13ca58c98ed6f233f66982226d5649846db0c78 Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Fri, 30 Jan 2026 14:26:17 +0000
Subject: [PATCH 21/25] fix(plugins): allow using defaults in config form
manifest (#4954)
---
ui/src/plugin/SchemaConfigEditor.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/src/plugin/SchemaConfigEditor.jsx b/ui/src/plugin/SchemaConfigEditor.jsx
index 096bfeb9a..dc8f8a0f1 100644
--- a/ui/src/plugin/SchemaConfigEditor.jsx
+++ b/ui/src/plugin/SchemaConfigEditor.jsx
@@ -42,7 +42,7 @@ SchemaErrorBoundary.propTypes = {
// params.missingProperty. We transform them to point to the field directly
// (e.g., "/users/1/username") so JSONForms displays them under the correct input.
const ajv = new Ajv({
- useDefaults: false,
+ useDefaults: true,
allErrors: true,
verbose: true,
jsonPointers: true,
From 84ab652ca751b094e1ae0e5b32d064b7be463881 Mon Sep 17 00:00:00 2001
From: MichaIng
Date: Sat, 31 Jan 2026 07:24:19 +0100
Subject: [PATCH 22/25] feat: add riscv64 builds (#4949)
* ci: add riscv64 builds
This requires at least Debian Trixie base systems, and a cross-taglib version with riscv64 release assets.
Signed-off-by: MichaIng
* fix(makefile): add riscv64 to supported platforms and update cross-taglib version
Signed-off-by: Deluan
---------
Signed-off-by: MichaIng
Signed-off-by: Deluan
Co-authored-by: Deluan
---
.github/workflows/pipeline.yml | 4 ++--
Dockerfile | 4 ++--
Makefile | 4 ++--
release/goreleaser.yml | 1 +
4 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 5c57fdaa5..bb5b0e2ff 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -14,7 +14,7 @@ concurrency:
cancel-in-progress: true
env:
- CROSS_TAGLIB_VERSION: "2.1.1-1"
+ CROSS_TAGLIB_VERSION: "2.1.1-2"
CGO_CFLAGS_ALLOW: "--define-prefix"
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
@@ -193,7 +193,7 @@ jobs:
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
strategy:
matrix:
- platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
+ platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest
env:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
diff --git a/Dockerfile b/Dockerfile
index 64b1c768a..791854729 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -28,7 +28,7 @@ COPY --from=xx-build /out/ /usr/bin/
### Get TagLib
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
ARG TARGETPLATFORM
-ARG CROSS_TAGLIB_VERSION=2.1.1-1
+ARG CROSS_TAGLIB_VERSION=2.1.1-2
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
# wget in busybox can't follow redirects
@@ -63,7 +63,7 @@ COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary
-FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
+FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
diff --git a/Makefile b/Makefile
index 634d68c06..afeb55b7e 100644
--- a/Makefile
+++ b/Makefile
@@ -13,13 +13,13 @@ GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
endif
-SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
+SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
-CROSS_TAGLIB_VERSION ?= 2.1.1-1
+CROSS_TAGLIB_VERSION ?= 2.1.1-2
GOLANGCI_LINT_VERSION ?= v2.8.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
diff --git a/release/goreleaser.yml b/release/goreleaser.yml
index 30c0d6f3b..e5035adda 100644
--- a/release/goreleaser.yml
+++ b/release/goreleaser.yml
@@ -19,6 +19,7 @@ builds:
- linux_arm_v6
- linux_arm_v7
- linux_arm64
+ - linux_riscv64
- windows_386
- windows_amd64
From ebbc31f1ab1e063cee143e1fff8c8a727f9d4201 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sun, 1 Feb 2026 16:16:58 +0100
Subject: [PATCH 23/25] fix(scanner): store scan errors in the database and
update UI error handling
Signed-off-by: Deluan
---
scanner/controller.go | 4 +++
scanner/phase_1_folders.go | 2 +-
scanner/scanner_multilibrary_test.go | 8 +++++-
ui/src/layout/ActivityPanel.jsx | 36 ++++++++++++++++----------
ui/src/layout/ActivityPanel.test.jsx | 38 ++++++++++++++++++++++++----
5 files changed, 67 insertions(+), 21 deletions(-)
diff --git a/scanner/controller.go b/scanner/controller.go
index b42246a50..635011840 100644
--- a/scanner/controller.go
+++ b/scanner/controller.go
@@ -224,6 +224,10 @@ func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targ
for _, w := range scanWarnings {
log.Warn(ctx, fmt.Sprintf("Scan warning: %s", w))
}
+ // Store scan error in database so it can be displayed in the UI
+ if scanError != nil {
+ _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, scanError.Error())
+ }
// If changes were detected, send a refresh event to all clients
if s.changesDetected {
log.Debug(ctx, "Library changes imported. Sending refresh event")
diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go
index b493a94d4..38967832c 100644
--- a/scanner/phase_1_folders.go
+++ b/scanner/phase_1_folders.go
@@ -40,7 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders)
if err != nil {
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
- state.sendWarning(err.Error())
+ state.sendError(err)
continue
}
jobs = append(jobs, job)
diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go
index 66db62edf..107e66a99 100644
--- a/scanner/scanner_multilibrary_test.go
+++ b/scanner/scanner_multilibrary_test.go
@@ -51,8 +51,14 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
+ conf.Server.MusicFolder = "default:///music" // Use a distinct schema for the default library
conf.Server.DevExternalScanner = false
+ // Register an empty fake storage for the default library
+ emptyFS := storagetest.FakeFS{}
+ emptyFS.SetFiles(fstest.MapFS{})
+ storagetest.Register("default", &emptyFS)
+
db.Init(ctx)
DeferCleanup(func() {
Expect(tests.ClearDB()).To(Succeed())
@@ -770,7 +776,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
// Second scan should recover and import all rock content
warnings, err = s.ScanAll(ctx, true)
Expect(err).ToNot(HaveOccurred())
- Expect(warnings).ToNot(BeEmpty(), "Should have warnings for temporary disk error")
+ Expect(warnings).To(BeEmpty(), "Should have no warnings after error recovery")
// Verify both libraries now have content (at least jazz should work)
rockFiles, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx
index 6d5d32d31..085911ed7 100644
--- a/ui/src/layout/ActivityPanel.jsx
+++ b/ui/src/layout/ActivityPanel.jsx
@@ -15,7 +15,7 @@ import {
Typography,
} from '@material-ui/core'
import { FiActivity } from 'react-icons/fi'
-import { BiError } from 'react-icons/bi'
+import { BiError, BiMessageError } from 'react-icons/bi'
import { VscSync } from 'react-icons/vsc'
import { GiMagnifyingGlass } from 'react-icons/gi'
import subsonic from '../subsonic'
@@ -28,7 +28,12 @@ import config from '../config'
const useStyles = makeStyles((theme) => ({
wrapper: {
position: 'relative',
- color: (props) => (props.up ? null : 'orange'),
+ color: (props) =>
+ props.serverDown
+ ? theme.palette.error.main
+ : props.hasWarning
+ ? theme.palette.warning.main
+ : null,
},
progress: {
color: theme.palette.primary.light,
@@ -75,12 +80,10 @@ const ActivityPanel = () => {
scanStatus.scanning,
scanStatus.elapsedTime,
)
- const [acknowledgedError, setAcknowledgedError] = useState(null)
- const isErrorVisible =
- scanStatus.error && scanStatus.error !== acknowledgedError
- const classes = useStyles({
- up: up && (!scanStatus.error || !isErrorVisible),
- })
+ // Determine icon state: error (server down), warning (scan error), or normal
+ const serverDown = !up
+ const hasWarning = Boolean(scanStatus.error)
+ const classes = useStyles({ serverDown, hasWarning })
const translate = useTranslate()
const notify = useNotify()
const [anchorEl, setAnchorEl] = useState(null)
@@ -88,13 +91,12 @@ const ActivityPanel = () => {
useInitialScanStatus()
const handleMenuOpen = (event) => {
- if (scanStatus.error) {
- setAcknowledgedError(scanStatus.error)
- }
setAnchorEl(event.currentTarget)
}
- const handleMenuClose = () => setAnchorEl(null)
+ const handleMenuClose = () => {
+ setAnchorEl(null)
+ }
const triggerScan = (full) => () => subsonic.startScan({ fullScan: full })
useEffect(() => {
@@ -125,8 +127,10 @@ const ActivityPanel = () => {
- {!up || isErrorVisible ? (
+ {serverDown ? (
+ ) : hasWarning ? (
+
) : (
)}
@@ -155,7 +159,11 @@ const ActivityPanel = () => {
{translate('activity.serverUptime')}:
-
+
{up ? : translate('activity.serverDown')}
diff --git a/ui/src/layout/ActivityPanel.test.jsx b/ui/src/layout/ActivityPanel.test.jsx
index c506fd08b..3a951df5d 100644
--- a/ui/src/layout/ActivityPanel.test.jsx
+++ b/ui/src/layout/ActivityPanel.test.jsx
@@ -43,19 +43,47 @@ describe('', () => {
})
})
- it('clears the error icon after opening the panel', () => {
+ it('shows warning icon when server reports a scan error', () => {
render(
,
)
+ // Warning icon should be visible when there's a scan error
+ expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument()
+
+ // Open the panel - warning icon should still be visible
const button = screen.getByRole('button')
- expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument()
-
fireEvent.click(button)
-
- expect(screen.getByTestId('activity-ok-icon')).toBeInTheDocument()
+ expect(screen.getByTestId('activity-warning-icon')).toBeInTheDocument()
expect(screen.getByText('Scan failed')).toBeInTheDocument()
})
+
+ it('shows error icon when server is down', () => {
+ const downStore = createStore(
+ combineReducers({ activity: activityReducer }),
+ {
+ activity: {
+ scanStatus: {
+ scanning: false,
+ folderCount: 0,
+ count: 0,
+ error: '',
+ elapsedTime: 0,
+ },
+ serverStart: { version: config.version, startTime: null }, // null startTime = server down
+ },
+ },
+ )
+
+ render(
+
+
+ ,
+ )
+
+ // Error icon should be visible when server is down
+ expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument()
+ })
})
From 7b709899a16ac6a046c01810646a7959b0c7a633 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Mon, 2 Feb 2026 08:59:40 +0100
Subject: [PATCH 24/25] refactor(plugins): simplify websocket callback
invocation by creating a generic helper function
Signed-off-by: Deluan
---
plugins/host_websocket.go | 87 ++++++++-------------------------------
1 file changed, 17 insertions(+), 70 deletions(-)
diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go
index c4d18c127..7d1b93def 100644
--- a/plugins/host_websocket.go
+++ b/plugins/host_websocket.go
@@ -324,105 +324,52 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string
}
}
-func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connectionID, message string) {
+// invokeWebSocketCallback is a generic helper that handles the common callback invocation pattern.
+func invokeWebSocketCallback[I any](ctx context.Context, s *webSocketServiceImpl, funcName string, input I, callbackName string, connectionID string) {
instance := s.getPluginInstance()
if instance == nil {
return
}
- input := capabilities.OnTextMessageRequest{
- ConnectionID: connectionID,
- Message: message,
- }
-
- // Create a timeout context for this callback invocation
callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
defer cancel()
start := time.Now()
- err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnTextMessage, input)
+ err := callPluginFunctionNoOutput(callbackCtx, instance, funcName, input)
if err != nil {
- // Don't log error if function simply doesn't exist (optional callback)
if !errors.Is(errFunctionNotFound, err) {
- log.Error(ctx, "WebSocket text message callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
+ log.Error(ctx, "WebSocket "+callbackName+" callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
}
}
}
+func (s *webSocketServiceImpl) invokeOnTextMessage(ctx context.Context, connectionID, message string) {
+ invokeWebSocketCallback(ctx, s, FuncWebSocketOnTextMessage, capabilities.OnTextMessageRequest{
+ ConnectionID: connectionID,
+ Message: message,
+ }, "text message", connectionID)
+}
+
func (s *webSocketServiceImpl) invokeOnBinaryMessage(ctx context.Context, connectionID string, data []byte) {
- instance := s.getPluginInstance()
- if instance == nil {
- return
- }
-
- input := capabilities.OnBinaryMessageRequest{
+ invokeWebSocketCallback(ctx, s, FuncWebSocketOnBinaryMessage, capabilities.OnBinaryMessageRequest{
ConnectionID: connectionID,
Data: base64.StdEncoding.EncodeToString(data),
- }
-
- // Create a timeout context for this callback invocation
- callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
- defer cancel()
-
- start := time.Now()
- err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnBinaryMessage, input)
- if err != nil {
- // Don't log error if function simply doesn't exist (optional callback)
- if !errors.Is(errFunctionNotFound, err) {
- log.Error(ctx, "WebSocket binary message callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
- }
- }
+ }, "binary message", connectionID)
}
func (s *webSocketServiceImpl) invokeOnError(ctx context.Context, connectionID, errorMsg string) {
- instance := s.getPluginInstance()
- if instance == nil {
- return
- }
-
- input := capabilities.OnErrorRequest{
+ invokeWebSocketCallback(ctx, s, FuncWebSocketOnError, capabilities.OnErrorRequest{
ConnectionID: connectionID,
Error: errorMsg,
- }
-
- // Create a timeout context for this callback invocation
- callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
- defer cancel()
-
- start := time.Now()
- err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnError, input)
- if err != nil {
- // Don't log error if function simply doesn't exist (optional callback)
- if !errors.Is(errFunctionNotFound, err) {
- log.Error(ctx, "WebSocket error callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
- }
- }
+ }, "error", connectionID)
}
func (s *webSocketServiceImpl) invokeOnClose(ctx context.Context, connectionID string, code int32, reason string) {
- instance := s.getPluginInstance()
- if instance == nil {
- return
- }
-
- input := capabilities.OnCloseRequest{
+ invokeWebSocketCallback(ctx, s, FuncWebSocketOnClose, capabilities.OnCloseRequest{
ConnectionID: connectionID,
Code: code,
Reason: reason,
- }
-
- // Create a timeout context for this callback invocation
- callbackCtx, cancel := context.WithTimeout(ctx, webSocketCallbackTimeout)
- defer cancel()
-
- start := time.Now()
- err := callPluginFunctionNoOutput(callbackCtx, instance, FuncWebSocketOnClose, input)
- if err != nil {
- // Don't log error if function simply doesn't exist (optional callback)
- if !errors.Is(errFunctionNotFound, err) {
- log.Error(ctx, "WebSocket close callback failed", "plugin", s.pluginName, "connectionID", connectionID, "duration", time.Since(start), err)
- }
- }
+ }, "close", connectionID)
}
func (s *webSocketServiceImpl) getPluginInstance() *plugin {
From 9bce7677f5e0ff2590f2e940d703160876214224 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Mon, 2 Feb 2026 09:05:28 +0100
Subject: [PATCH 25/25] fix(ui): update Bulgarian, Catalan, German, Greek,
Spanish, Finnish, French, Galician, Dutch, Polish, Portuguese (BR), Russian,
Slovenian, Swedish, Thai translations from POEditor (#4852)
Co-authored-by: navidrome-bot
---
resources/i18n/bg.json | 85 ++-
resources/i18n/ca.json | 1191 +++++++++++++++++++++----------------
resources/i18n/de.json | 83 ++-
resources/i18n/el.json | 83 ++-
resources/i18n/es.json | 137 ++---
resources/i18n/fi.json | 93 ++-
resources/i18n/fr.json | 83 ++-
resources/i18n/gl.json | 83 ++-
resources/i18n/nl.json | 83 ++-
resources/i18n/pl.json | 83 ++-
resources/i18n/pt-br.json | 81 ++-
resources/i18n/ru.json | 87 ++-
resources/i18n/sl.json | 95 ++-
resources/i18n/sv.json | 86 ++-
resources/i18n/th.json | 83 ++-
15 files changed, 1780 insertions(+), 656 deletions(-)
diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json
index dfe3f27ed..bce5a3a6e 100644
--- a/resources/i18n/bg.json
+++ b/resources/i18n/bg.json
@@ -36,7 +36,8 @@
"bitDepth": "Битова дълбочина",
"sampleRate": "",
"missing": "Липсва",
- "libraryName": ""
+ "libraryName": "",
+ "composer": ""
},
"actions": {
"addToQueue": "Пусни по-късно",
@@ -46,7 +47,8 @@
"download": "Свали",
"playNext": "Следваща",
"info": "Информация",
- "showInPlaylist": ""
+ "showInPlaylist": "",
+ "instantMix": ""
}
},
"album": {
@@ -302,7 +304,7 @@
"scan": "",
"manageUsers": "",
"viewDetails": "",
- "quickScan": "",
+ "quickScan": "Quick Scan",
"fullScan": ""
},
"notifications": {
@@ -328,6 +330,80 @@
"scanInProgress": "",
"noLibrariesAssigned": ""
}
+ },
+ "plugin": {
+ "name": "",
+ "fields": {
+ "id": "",
+ "name": "",
+ "description": "",
+ "version": "",
+ "author": "",
+ "website": "",
+ "permissions": "",
+ "enabled": "",
+ "status": "",
+ "path": "",
+ "lastError": "",
+ "hasError": "",
+ "updatedAt": "",
+ "createdAt": "",
+ "configKey": "",
+ "configValue": "",
+ "allUsers": "",
+ "selectedUsers": "",
+ "allLibraries": "",
+ "selectedLibraries": ""
+ },
+ "sections": {
+ "status": "",
+ "info": "",
+ "configuration": "",
+ "manifest": "",
+ "usersPermission": "",
+ "libraryPermission": ""
+ },
+ "status": {
+ "enabled": "",
+ "disabled": ""
+ },
+ "actions": {
+ "enable": "",
+ "disable": "",
+ "disabledDueToError": "",
+ "disabledUsersRequired": "",
+ "disabledLibrariesRequired": "",
+ "addConfig": "",
+ "rescan": ""
+ },
+ "notifications": {
+ "enabled": "",
+ "disabled": "",
+ "updated": "",
+ "error": ""
+ },
+ "validation": {
+ "invalidJson": ""
+ },
+ "messages": {
+ "configHelp": "",
+ "clickPermissions": "",
+ "noConfig": "",
+ "allUsersHelp": "",
+ "noUsers": "",
+ "permissionReason": "",
+ "usersRequired": "",
+ "allLibrariesHelp": "",
+ "noLibraries": "",
+ "librariesRequired": "",
+ "requiredHosts": "",
+ "configValidationError": "",
+ "schemaRenderError": ""
+ },
+ "placeholders": {
+ "configKey": "",
+ "configValue": ""
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Премахни всички липсващи файлове",
"remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
"noSimilarSongsFound": "",
- "noTopSongsFound": ""
+ "noTopSongsFound": "",
+ "startingInstantMix": ""
},
"menu": {
"library": "Библиотека",
diff --git a/resources/i18n/ca.json b/resources/i18n/ca.json
index e3e7b544e..a7244b5bd 100644
--- a/resources/i18n/ca.json
+++ b/resources/i18n/ca.json
@@ -1,518 +1,711 @@
{
- "languageName": "Català",
- "resources": {
- "song": {
- "name": "Cançó |||| Cançons",
- "fields": {
- "albumArtist": "Artista de l'àlbum",
- "duration": "Durada",
- "trackNumber": "#",
- "playCount": "Reproduccions",
- "title": "Títol",
- "artist": "Artista",
- "album": "Àlbum",
- "path": "Ruta del fitxer",
- "genre": "Gènere",
- "compilation": "Compilació",
- "year": "Any",
- "size": "Mida del fitxer",
- "updatedAt": "Actualitzat",
- "bitRate": "Taxa de bits",
- "bitDepth": "Bits",
- "sampleRate": "Freqüencia de mostreig",
- "channels": "Canals",
- "discSubtitle": "Subtítol del disc",
- "starred": "Preferit",
- "comment": "Comentari",
- "rating": "Valoració",
- "quality": "Qualitat",
- "bpm": "tempo",
- "playDate": "Darrer resproduït",
- "createdAt": "Creat el",
- "grouping": "Agrupació",
- "mood": "Sentiment",
- "participants": "Participants",
- "tags": "Etiquetes",
- "mappedTags": "Etiquetes assignades",
- "rawTags": "Etiquetes sense processar"
- },
- "actions": {
- "addToQueue": "Reprodueix després",
- "playNow": "Reprodueix ara",
- "addToPlaylist": "Afegeix a la llista",
- "shuffleAll": "Aleatori",
- "download": "Descarrega",
- "playNext": "Reprodueix següent",
- "info": "Obtén informació"
- }
- },
- "album": {
- "name": "Àlbum |||| Àlbums",
- "fields": {
- "albumArtist": "Artista de l'àlbum",
- "artist": "Artista",
- "duration": "Durada",
- "songCount": "Cançons",
- "playCount": "Reproduccions",
- "size": "Mida",
- "name": "Nom",
- "genre": "Gènere",
- "compilation": "Compilació",
- "year": "Any",
- "updatedAt": "Actualitzat ",
- "comment": "Comentari",
- "rating": "Valoració",
- "createdAt": "Creat el",
- "size": "Mida",
- "originalDate": "Original",
- "releaseDate": "Publicat",
- "releases": "LLançament |||| Llançaments",
- "released": "Publicat",
- "recordLabel": "Discogràfica",
- "catalogNum": "Número de catàleg",
- "releaseType": "Tipus de publicació",
- "grouping": "Agrupació",
- "media": "Mitjà",
- "mood": "Sentiment"
- },
- "actions": {
- "playAll": "Reprodueix",
- "playNext": "Reprodueix la següent",
- "addToQueue": "Reprodueix després",
- "share": "Compartir",
- "shuffle": "Aleatori",
- "addToPlaylist": "Afegeix a la llista",
- "download": "Descarrega",
- "info": "Obtén informació"
- },
- "lists": {
- "all": "Tot",
- "random": "Aleatori",
- "recentlyAdded": "Afegit fa poc",
- "recentlyPlayed": "Reproduït fa poc",
- "mostPlayed": "Més reproduït",
- "starred": "Preferits",
- "topRated": "Més ben valorades"
- }
- },
- "artist": {
- "name": "Artista |||| Artistes",
- "fields": {
- "name": "Nom",
- "albumCount": "Nombre d'àlbums",
- "songCount": "Nombre de cançons",
- "size": "Mida",
- "playCount": "Reproduccions",
- "rating": "Valoració",
- "genre": "Gènere",
- "role": "Rol"
- },
- "roles": {
- "albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum",
- "artist": "Artista |||| Artistes",
- "composer": "Compositor |||| Compositors",
- "conductor": "Conductor |||| Conductors",
- "lyricist": "Lletrista |||| Lletristes",
- "arranger": "Arranjador |||| Arranjadors",
- "producer": "Productor |||| Productors",
- "director": "Director |||| Directors",
- "engineer": "Enginyer |||| Enginyers",
- "mixer": "Mesclador |||| Mescladors",
- "remixer": "Remesclador |||| Remescladors",
- "djmixer": "DJ Mesclador |||| DJ Mescladors",
- "performer": "Intèrpret |||| Intèrprets"
- }
- },
- "user": {
- "name": "Usuari |||| Usuaris",
- "fields": {
- "userName": "Nom d'usuari",
- "isAdmin": "És admin",
- "lastLoginAt": "Última connexió",
- "lastAccessAt": "Últim Accés",
- "updatedAt": "Actualitzat",
- "name": "Nom",
- "password": "Contrasenya",
- "createdAt": "Creat",
- "changePassword": "Canviar la contrasenya?",
- "currentPassword": "Contrasenya actual",
- "newPassword": "Contrasenya nova",
- "token": "Token"
- },
- "helperTexts": {
- "name": "Els canvis en el nom s'hi aplicaran en la següent connexió"
- },
- "notifications": {
- "created": "Usuari creat",
- "updated": "Usuari actualitzat",
- "deleted": "Usuari eliminat"
- },
- "message": {
- "listenBrainzToken": "Introduïu el vostre token d'usuari de ListenBrainz",
- "clickHereForToken": "Feu clic ací per a obtenir el vostre token"
- }
- },
- "player": {
- "name": "Reproductor |||| Reproductors",
- "fields": {
- "name": "Nom",
- "transcodingId": "Transcodificador",
- "maxBitRate": "Taxa de bits màx.",
- "client": "Client",
- "userName": "Nom d'usuari",
- "lastSeen": "Vist",
- "reportRealPath": "Informa de la ruta real",
- "scrobbleEnabled": "Activa el seguiment des de serveis externs"
- }
- },
- "transcoding": {
- "name": "Transcodificador |||| Transcodificadors",
- "fields": {
- "name": "Nom",
- "targetFormat": "Format desitjat",
- "defaultBitRate": "Taxa de bits per defecte",
- "command": "Ordre"
- }
- },
- "playlist": {
- "name": "Llista |||| Llistes",
- "fields": {
- "name": "Nom",
- "duration": "Durada",
- "ownerName": "Propietari",
- "public": "Públic",
- "updatedAt": "Actualitzat ",
- "createdAt": "Creat",
- "songCount": "Cançons",
- "comment": "Comentari",
- "sync": "Auto-importació",
- "path": "Importa de"
- },
- "actions": {
- "selectPlaylist": "Selecciona una llista:",
- "addNewPlaylist": "Crea \"%{nom}",
- "export": "Exporta",
- "makePublic": "Fes públic",
- "makePrivate": "Fes privat"
- },
- "message": {
- "duplicate_song": "Afegeix cançons duplicades",
- "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?"
- }
- },
- "radio": {
- "name": "Ràdio |||| Ràdios",
- "fields": {
- "name": "Nom",
- "streamUrl": "URL del flux",
- "homePageUrl": "URL principal",
- "updatedAt": "Actualitzat",
- "createdAt": "Creat"
- },
- "actions": {
- "playNow": "Reprodueix"
- }
- },
- "share": {
- "name": "Compartir |||| Compartits",
- "fields": {
- "username": "Compartit per",
- "url": "URL",
- "description": "Descripció",
- "downloadable": "Permet descarregar?",
- "contents": "Continguts",
- "expiresAt": "Caduca",
- "lastVisitedAt": "Última Visita",
- "visitCount": "Visites",
- "format": "Format",
- "maxBitRate": "Taxa de bits màx.",
- "updatedAt": "Actualitzat",
- "createdAt": "Creat"
- },
- "notifications": {},
- "actions": {}
- },
- "missing": {
+ "languageName": "Català",
+ "resources": {
+ "song": {
+ "name": "Cançó |||| Cançons",
+ "fields": {
+ "albumArtist": "Artista de l'àlbum",
+ "duration": "Durada",
+ "trackNumber": "#",
+ "playCount": "Reproduccions",
+ "title": "Títol",
+ "artist": "Artista",
+ "album": "Àlbum",
+ "path": "Ruta del fitxer",
+ "genre": "Gènere",
+ "compilation": "Compilació",
+ "year": "Any",
+ "size": "Mida del fitxer",
+ "updatedAt": "Actualitzat",
+ "bitRate": "Taxa de bits",
+ "discSubtitle": "Subtítol del disc",
+ "starred": "Preferit",
+ "comment": "Comentari",
+ "rating": "Valoració",
+ "quality": "Qualitat",
+ "bpm": "tempo",
+ "playDate": "Darrer resproduït",
+ "channels": "Canals",
+ "createdAt": "Data d'addició",
+ "grouping": "Agrupació",
+ "mood": "Sentiment",
+ "participants": "Participants",
+ "tags": "Etiquetes",
+ "mappedTags": "Etiquetes assignades",
+ "rawTags": "Etiquetes sense processar",
+ "bitDepth": "Bits",
+ "sampleRate": "Freqüencia de mostreig",
+ "missing": "Desaparegut",
+ "libraryName": "Biblioteca",
+ "composer": "Compositor"
+ },
+ "actions": {
+ "addToQueue": "Reprodueix després",
+ "playNow": "Reprodueix ara",
+ "addToPlaylist": "Afegeix a la llista",
+ "shuffleAll": "Aleatori",
+ "download": "Descarrega",
+ "playNext": "Reprodueix següent",
+ "info": "Obtén informació",
+ "showInPlaylist": "Mostra a la llista",
+ "instantMix": "Mescla immediata"
+ }
+ },
+ "album": {
+ "name": "Àlbum |||| Àlbums",
+ "fields": {
+ "albumArtist": "Artista de l'àlbum",
+ "artist": "Artista",
+ "duration": "Durada",
+ "songCount": "Cançons",
+ "playCount": "Reproduccions",
+ "name": "Nom",
+ "genre": "Gènere",
+ "compilation": "Compilació",
+ "year": "Any",
+ "updatedAt": "Actualitzat ",
+ "comment": "Comentari",
+ "rating": "Valoració",
+ "createdAt": "Data d'addició",
+ "size": "Mida",
+ "originalDate": "Original",
+ "releaseDate": "Publicat",
+ "releases": "LLançament |||| Llançaments",
+ "released": "Publicat",
+ "recordLabel": "Discogràfica",
+ "catalogNum": "Número de catàleg",
+ "releaseType": "Tipus de publicació",
+ "grouping": "Agrupació",
+ "media": "Mitjà",
+ "mood": "Sentiment",
+ "date": "Data d'enregistrament",
+ "missing": "Desaparegut",
+ "libraryName": "Biblioteca"
+ },
+ "actions": {
+ "playAll": "Reprodueix",
+ "playNext": "Reprodueix la següent",
+ "addToQueue": "Reprodueix després",
+ "shuffle": "Aleatori",
+ "addToPlaylist": "Afegeix a la llista",
+ "download": "Descarrega",
+ "info": "Obtén informació",
+ "share": "Compartir"
+ },
+ "lists": {
+ "all": "Tot",
+ "random": "Aleatori",
+ "recentlyAdded": "Afegit fa poc",
+ "recentlyPlayed": "Reproduït fa poc",
+ "mostPlayed": "Més reproduït",
+ "starred": "Preferits",
+ "topRated": "Més ben valorades"
+ }
+ },
+ "artist": {
+ "name": "Artista |||| Artistes",
+ "fields": {
+ "name": "Nom",
+ "albumCount": "Nombre d'àlbums",
+ "songCount": "Nombre de cançons",
+ "playCount": "Reproduccions",
+ "rating": "Valoració",
+ "genre": "Gènere",
+ "size": "Mida",
+ "role": "Rol",
+ "missing": "Desaparegut"
+ },
+ "roles": {
+ "albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum",
+ "artist": "Artista |||| Artistes",
+ "composer": "Compositor |||| Compositors",
+ "conductor": "Conductor |||| Conductors",
+ "lyricist": "Lletrista |||| Lletristes",
+ "arranger": "Arranjador |||| Arranjadors",
+ "producer": "Productor |||| Productors",
+ "director": "Director |||| Directors",
+ "engineer": "Enginyer |||| Enginyers",
+ "mixer": "Mesclador |||| Mescladors",
+ "remixer": "Remesclador |||| Remescladors",
+ "djmixer": "DJ Mesclador |||| DJ Mescladors",
+ "performer": "Intèrpret |||| Intèrprets",
+ "maincredit": "Artista de l'àlbum or Artista |||| Artistes de l'àlbum or Artistes"
+ },
+ "actions": {
+ "shuffle": "Barreja",
+ "radio": "Ràdio",
+ "topSongs": "Cançons populars"
+ }
+ },
+ "user": {
+ "name": "Usuari |||| Usuaris",
+ "fields": {
+ "userName": "Nom d'usuari",
+ "isAdmin": "És admin",
+ "lastLoginAt": "Última connexió",
+ "updatedAt": "Actualitzat",
+ "name": "Nom",
+ "password": "Contrasenya",
+ "createdAt": "Creat",
+ "changePassword": "Canviar la contrasenya?",
+ "currentPassword": "Contrasenya actual",
+ "newPassword": "Contrasenya nova",
+ "token": "Token",
+ "lastAccessAt": "Últim accés",
+ "libraries": "Biblioteques"
+ },
+ "helperTexts": {
+ "name": "Els canvis en el nom s'hi aplicaran en la següent connexió",
+ "libraries": "Seleccioneu biblioteques específiques per a aquest usuari o deixeu-ho buit per utilitzar les biblioteques predeterminades"
+ },
+ "notifications": {
+ "created": "Usuari creat",
+ "updated": "Usuari actualitzat",
+ "deleted": "Usuari eliminat"
+ },
+ "message": {
+ "listenBrainzToken": "Introduïu el vostre token d'usuari de ListenBrainz",
+ "clickHereForToken": "Feu clic ací per a obtenir el vostre token",
+ "selectAllLibraries": "Selecciona totes les biblioteques",
+ "adminAutoLibraries": "Els administradors tenen accés a totes les biblioteques automàticament"
+ },
+ "validation": {
+ "librariesRequired": "Cal que trieu almenys una biblioteca per als usuaris que no siguin administradors"
+ }
+ },
+ "player": {
+ "name": "Reproductor |||| Reproductors",
+ "fields": {
+ "name": "Nom",
+ "transcodingId": "Transcodificador",
+ "maxBitRate": "Taxa de bits màx.",
+ "client": "Client",
+ "userName": "Nom d'usuari",
+ "lastSeen": "Vist",
+ "reportRealPath": "Informa de la ruta real",
+ "scrobbleEnabled": "Activa el seguiment des de serveis externs"
+ }
+ },
+ "transcoding": {
+ "name": "Transcodificador |||| Transcodificadors",
+ "fields": {
+ "name": "Nom",
+ "targetFormat": "Format desitjat",
+ "defaultBitRate": "Taxa de bits per defecte",
+ "command": "Ordre"
+ }
+ },
+ "playlist": {
+ "name": "Llista |||| Llistes",
+ "fields": {
+ "name": "Nom",
+ "duration": "Durada",
+ "ownerName": "Propietari",
+ "public": "Públic",
+ "updatedAt": "Actualitzat ",
+ "createdAt": "Creat",
+ "songCount": "Cançons",
+ "comment": "Comentari",
+ "sync": "Auto-importació",
+ "path": "Importa de"
+ },
+ "actions": {
+ "selectPlaylist": "Selecciona una llista:",
+ "addNewPlaylist": "Crea \"%{nom}",
+ "export": "Exporta",
+ "makePublic": "Fes públic",
+ "makePrivate": "Fes privat",
+ "saveQueue": "Desar la cua a una llista",
+ "searchOrCreate": "Cerca llistes o escriu per crear-ne de noves...",
+ "pressEnterToCreate": "Prem Retorn per crear una nova llista",
+ "removeFromSelection": "Elimina de la selecció"
+ },
+ "message": {
+ "duplicate_song": "Afegeix cançons duplicades",
+ "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?",
+ "noPlaylistsFound": "No s'ha trobat cap llista",
+ "noPlaylists": "No hi ha cap llista disponible"
+ }
+ },
+ "radio": {
+ "name": "Ràdio |||| Ràdios",
+ "fields": {
+ "name": "Nom",
+ "streamUrl": "URL del flux",
+ "homePageUrl": "URL principal",
+ "updatedAt": "Actualitzat",
+ "createdAt": "Creat"
+ },
+ "actions": {
+ "playNow": "Reprodueix"
+ }
+ },
+ "share": {
+ "name": "Compartir |||| Compartits",
+ "fields": {
+ "username": "Compartit per",
+ "url": "URL",
+ "description": "Descripció",
+ "contents": "Continguts",
+ "expiresAt": "Caduca",
+ "lastVisitedAt": "Última Visita",
+ "visitCount": "Visites",
+ "format": "Format",
+ "maxBitRate": "Taxa de bits màx.",
+ "updatedAt": "Actualitzat",
+ "createdAt": "Creat",
+ "downloadable": "Permet descarregar?"
+ }
+ },
+ "missing": {
"name": "Fitxer faltant |||| Fitxers Faltants",
- "empty": "No falten fitxers",
"fields": {
"path": "Directori",
"size": "Mida",
- "updatedAt": "Desaparegut"
+ "updatedAt": "Desaparegut",
+ "libraryName": "Biblioteca"
},
"actions": {
- "remove": "Eliminar"
+ "remove": "Eliminar",
+ "remove_all": "Suprimeix-ho tot"
},
"notifications": {
"removed": "Fitxers faltants eliminats"
+ },
+ "empty": "No falten fitxers"
+ },
+ "library": {
+ "name": "Biblioteca |||| Biblioteques\n",
+ "fields": {
+ "name": "Nom",
+ "path": "Camí",
+ "remotePath": "Camí remot",
+ "lastScanAt": "Últim escaneig",
+ "songCount": "Cançons",
+ "albumCount": "Àlbums",
+ "artistCount": "Artistes",
+ "totalSongs": "Cançons",
+ "totalAlbums": "Àlbums",
+ "totalArtists": "Artistes",
+ "totalFolders": "Carpetes",
+ "totalFiles": "Fitxers",
+ "totalMissingFiles": "Fitxers desapareguts",
+ "totalSize": "Mida total",
+ "totalDuration": "Durada",
+ "defaultNewUsers": "Predeterminat per a usuaris nous",
+ "createdAt": "Creat",
+ "updatedAt": "Actualitzat"
+ },
+ "sections": {
+ "basic": "Informació bàsica",
+ "statistics": "Estadístiques"
+ },
+ "actions": {
+ "scan": "Escaneja la biblioteca",
+ "manageUsers": "Gestiona l'accés d'usuari",
+ "viewDetails": "Mostra els detalls",
+ "quickScan": "Escaneig ràpid",
+ "fullScan": "Escaneig complet"
+ },
+ "notifications": {
+ "created": "La biblioteca s'ha creat correctament",
+ "updated": "La biblioteca s'ha actualitzat correctament",
+ "deleted": "La biblioteca s'ha suprimit correctament",
+ "scanStarted": "Començant l'escaneig de la biblioteca",
+ "scanCompleted": "S'ha completat l'escaneig de la biblioteca",
+ "quickScanStarted": "Començant escaneig ràpid",
+ "fullScanStarted": "Començant escaneig complet",
+ "scanError": "S'ha produït un error en començar l'escaneig. Comproveu els registres"
+ },
+ "validation": {
+ "nameRequired": "Es requereix un nom per la biblioteca",
+ "pathRequired": "Es requereix un camí a la biblioteca",
+ "pathNotDirectory": "El camí a la biblioteca ha de ser un directori",
+ "pathNotFound": "No s'ha trobat el camí a la biblioteca",
+ "pathNotAccessible": "No es pot accedir al camí de la biblioteca ",
+ "pathInvalid": "Camí a la llibreria no vàlid"
+ },
+ "messages": {
+ "deleteConfirm": "Esteu segur que voleu suprimir aquesta biblioteca? Se'n suprimiran totes les dades associades i els accessos d'usuari.",
+ "scanInProgress": "Escaneig en curs...",
+ "noLibrariesAssigned": "Aquest usuari no té cap biblioteca assignada"
+ }
+ },
+ "plugin": {
+ "name": "\nConnector |||| Connectors",
+ "fields": {
+ "id": "ID",
+ "name": "Nom",
+ "description": "Descripció",
+ "version": "Versió",
+ "author": "Autor",
+ "website": "Lloc web",
+ "permissions": "Permissos",
+ "enabled": "Activat",
+ "status": "Estat",
+ "path": "Camí",
+ "lastError": "Error",
+ "hasError": "Error",
+ "updatedAt": "Actualitzat",
+ "createdAt": "Instal·lat",
+ "configKey": "Clau",
+ "configValue": "Valor",
+ "allUsers": "Permet tots els usuaris",
+ "selectedUsers": "Usuaris seleccionats",
+ "allLibraries": "Permet totes les llibreries",
+ "selectedLibraries": "Biblioteques seleccionades"
+ },
+ "sections": {
+ "status": "Estat",
+ "info": "Informació del controlador",
+ "configuration": "Configuració",
+ "manifest": "Manifest",
+ "usersPermission": "Permís dels usuaris",
+ "libraryPermission": "Permís de la llibreria"
+ },
+ "status": {
+ "enabled": "Activat",
+ "disabled": "Desactivat"
+ },
+ "actions": {
+ "enable": "Activa",
+ "disable": "Desactiva",
+ "disabledDueToError": "Arregleu l'error abans de l'activació",
+ "disabledUsersRequired": "Seleccioneu els usuaris abans de l'activació",
+ "disabledLibrariesRequired": "Seleccioneu les biblioteques abans de l'activació",
+ "addConfig": "Afegeix una configuració",
+ "rescan": "Torna a escanejar"
+ },
+ "notifications": {
+ "enabled": "Controlador activat",
+ "disabled": "Controlador desactivat",
+ "updated": "Controlador activat",
+ "error": "S'ha produït un error en actualitzar el controlador"
+ },
+ "validation": {
+ "invalidJson": "El fitxer Configuració ha de ser un JSON vàlid"
+ },
+ "messages": {
+ "configHelp": "Configureu el controlador utilitzant parelles clau-valor. Deixeu-ho buit si el controlador no requereix cap configuració.",
+ "clickPermissions": "Feu clic en un permís per veure’n els detalls",
+ "noConfig": "No s'ha establert cap configuració",
+ "allUsersHelp": "Quan està activat, el controlador té accés a tots els usuaris, inclosos els creats a posteriori.",
+ "noUsers": "No s'ha seleccionat cap usuari",
+ "permissionReason": "Motiu",
+ "usersRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet tots els usuaris».",
+ "allLibrariesHelp": "Quan està activat, el controlador té accés a totes les llibreries, incloses les creades a posteriori.",
+ "noLibraries": "No s'ha seleccionat cap biblioteca",
+ "librariesRequired": "Aquest controlador necessita accedir a la informació de la biblioteca. Selecciona a quines biblioteques pot accedir o activa «Permet totes les biblioteques».",
+ "requiredHosts": "Hosts requerits",
+ "configValidationError": "Ha fallat la validació de la configuració:",
+ "schemaRenderError": "No s'ha pogut renderitzar el formulari de configuració. És possible que l'esquema del controlador sigui invàlid."
+ },
+ "placeholders": {
+ "configKey": "clau",
+ "configValue": "valor"
}
}
},
- "ra": {
- "auth": {
- "welcome1": "Gràcies d'haver instal·lat Navidrome!",
- "welcome2": "Per a començar, creeu un usuari administrador",
- "confirmPassword": "Confirmeu la contrasenya",
- "buttonCreateAdmin": "Crea un administrador",
- "auth_check_error": "Si us plau, inicieu sessió per a continuar",
- "user_menu": "Perfil",
- "username": "Nom d'usuari",
- "password": "Contrasenya",
- "sign_in": "Inicia sessió",
- "sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar",
- "logout": "Sortida",
- "insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols"
- },
- "validation": {
- "invalidChars": "Si us plau, useu només lletres i nombres",
- "passwordDoesNotMatch": "Les contrasenyes no coincideixen",
- "required": "Obligatori",
- "minLength": "Ha de tenir, si més no, %{min} caràcters",
- "maxLength": "Ha de tenir %{max} caràcters o menys",
- "minValue": "Ha de ser com a mínim %{min}",
- "maxValue": "Ha de ser %{max} o menys",
- "number": "Ha de ser un nombre",
- "email": "Ha de ser un correu vàlid",
- "oneOf": "Ha de ser un de: %{options}",
- "regex": "Ha de tenir el format (regexp): %{pattern}",
- "unique": "Ha de ser únic",
- "url": "Ha de ser una URL vàlida"
- },
- "action": {
- "add_filter": "Afegeix un filtre",
- "add": "Afegeix",
- "back": "Enrere",
- "bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
- "cancel": "Cancel·la",
- "clear_input_value": "Neteja el valor",
- "clone": "Clona",
- "confirm": "Confirma",
- "create": "Crea",
- "delete": "Suprimeix",
- "edit": "Edita",
- "export": "Exporta",
- "list": "Llista",
- "refresh": "Refresca",
- "remove_filter": "Suprimeix aquest filtre",
- "remove": "Elimina",
- "save": "Desa",
- "search": "Cerca",
- "show": "Mostra",
- "sort": "Ordena",
- "undo": "Desfés",
- "expand": "Expandeix",
- "close": "Tanca",
- "open_menu": "Obre el menú",
- "close_menu": "Tanca el menú",
- "unselect": "Anul·la la selecció",
- "skip": "Omet",
- "share": "Compartir",
- "download": "Descarregar"
- },
- "boolean": {
- "true": "Sí",
- "false": "No"
- },
- "page": {
- "create": "Crea %{nom}",
- "dashboard": "Tauler",
- "edit": "%{name} #%{id}",
- "error": "Alguna cosa ha fallat",
- "list": "%{name}",
- "loading": "Ara es carrega",
- "not_found": "No s'ha trobat",
- "show": "%{name} #%{id}",
- "empty": "No hi ha %{name} encara.",
- "invite": "Voleu afegir-ne una?"
- },
- "input": {
- "file": {
- "upload_several": "Deixeu caure-hi fitxers per a carregar-los o feu clic per a seleccionar-ne un.",
- "upload_single": "Deixeu caure-hi un fitxer per a carregar o feu clic per a seleccionar-lo."
- },
- "image": {
- "upload_several": "Deixeu caure-hi imatges per a carregar-les o feu clic per a seleccionar-ne una.",
- "upload_single": "Deixeu caure-hi una imatge per a carregar-la o feu clic per a seleccionar-la."
- },
- "references": {
- "all_missing": "No ha estat possible trobar les dades de referència.",
- "many_missing": "Sembla que almenys una de les referències associades ja no està disponible.",
- "single_missing": "Sembla que la referència associada ja no està disponible."
- },
- "password": {
- "toggle_visible": "Amaga la contrasenya",
- "toggle_hidden": "Mostra la contrasenya"
- }
- },
- "message": {
- "about": "Quant a...",
- "are_you_sure": "N'esteu segur?",
- "bulk_delete_content": "Voleu eliminar aquest %{name}? |||| Voleu eliminar aquests %{smart_count} element?\n",
- "bulk_delete_title": "Esborra %{name} |||| Esborra %{smart_count} %{name}",
- "delete_content": "Segur que voleu eliminar aquest element?",
- "delete_title": "Elimina %{name} #%{id}",
- "details": "Detalls",
- "error": "S'ha produït un error en un client i la vostra sol·licitud no ha pogut ser completada.",
- "invalid_form": "El formulari no és vàlid.",
- "loading": "La pàgina es carrega, un moment si us plau.",
- "no": "No",
- "not_found": "La URL és incorrecta o heu seguit un enllaç erroni.",
- "yes": "Sí",
- "unsaved_changes": "Alguns canvis no s'hi han desat. Segur que voleu ignorar-los?"
- },
- "navigation": {
- "no_results": "No s'ha trobat",
- "no_more_results": "La pàgina número %{page} no existeix. Proveu l'anterior.",
- "page_out_of_boundaries": "La pàgina número %{page} no existeix",
- "page_out_from_end": "No podeu anar més enllà de la darrera pàgina",
- "page_out_from_begin": "No podeu anar més enllà de la primera pàgina",
- "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
- "page_rows_per_page": "Elements per pàgina:",
- "next": "Següent",
- "prev": "Anterior",
- "skip_nav": "Salta al contingut"
- },
- "notification": {
- "updated": "Element actualitzat |||| %{smart_count} elements actualitzats",
- "created": "Element creat",
- "deleted": "Element actualitzat |||| %{smart_count} elements actualitzats",
- "bad_item": "Element incorrecte",
- "item_doesnt_exist": "L'element no existeix",
- "http_error": "Error de comunicació del servidor",
- "data_provider_error": "dataProvider error. Vegeu la consola si en voleu més detalls.",
- "i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat",
- "canceled": "Acció cancel·lada",
- "logged_out": "La sessió ha acabat, si us plau reconnecteu",
- "new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra."
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "Columnes a mostrar",
- "layout": "Disposició",
- "grid": "Quadrícula",
- "table": "Taula"
- }
+ "ra": {
+ "auth": {
+ "welcome1": "Gràcies d'haver instal·lat Navidrome!",
+ "welcome2": "Per a començar, creeu un usuari administrador",
+ "confirmPassword": "Confirmeu la contrasenya",
+ "buttonCreateAdmin": "Crea un administrador",
+ "auth_check_error": "Si us plau, inicieu sessió per a continuar",
+ "user_menu": "Perfil",
+ "username": "Nom d'usuari",
+ "password": "Contrasenya",
+ "sign_in": "Inicia sessió",
+ "sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar",
+ "logout": "Sortida",
+ "insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols"
+ },
+ "validation": {
+ "invalidChars": "Si us plau, utilitzeu només lletres i nombres",
+ "passwordDoesNotMatch": "Les contrasenyes no coincideixen",
+ "required": "Obligatori",
+ "minLength": "Ha de tenir, si més no, %{min} caràcters",
+ "maxLength": "Ha de tenir %{max} caràcters o menys",
+ "minValue": "Ha de ser com a mínim %{min}",
+ "maxValue": "Ha de ser %{max} o menys",
+ "number": "Ha de ser un nombre",
+ "email": "Ha de ser un correu vàlid",
+ "oneOf": "Ha de ser un de: %{options}",
+ "regex": "Ha de tenir el format (regexp): %{pattern}",
+ "unique": "Ha de ser únic",
+ "url": "Ha de ser una URL vàlida"
+ },
+ "action": {
+ "add_filter": "Afegeix un filtre",
+ "add": "Afegeix",
+ "back": "Enrere",
+ "bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats",
+ "cancel": "Cancel·la",
+ "clear_input_value": "Neteja el valor",
+ "clone": "Clona",
+ "confirm": "Confirma",
+ "create": "Crea",
+ "delete": "Suprimeix",
+ "edit": "Edita",
+ "export": "Exporta",
+ "list": "Llista",
+ "refresh": "Refresca",
+ "remove_filter": "Suprimeix aquest filtre",
+ "remove": "Elimina",
+ "save": "Desa",
+ "search": "Cerca",
+ "show": "Mostra",
+ "sort": "Ordena",
+ "undo": "Desfés",
+ "expand": "Expandeix",
+ "close": "Tanca",
+ "open_menu": "Obre el menú",
+ "close_menu": "Tanca el menú",
+ "unselect": "Anul·la la selecció",
+ "skip": "Omet",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Compartir",
+ "download": "Descarregar"
+ },
+ "boolean": {
+ "true": "Sí",
+ "false": "No"
+ },
+ "page": {
+ "create": "Crea %{nom}",
+ "dashboard": "Tauler",
+ "edit": "%{name} #%{id}",
+ "error": "Alguna cosa ha fallat",
+ "list": "%{name}",
+ "loading": "Ara es carrega",
+ "not_found": "No s'ha trobat",
+ "show": "%{name} #%{id}",
+ "empty": "No hi ha %{name} encara.",
+ "invite": "Voleu afegir-ne una?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Deixeu caure-hi fitxers per a carregar-los o feu clic per a seleccionar-ne un.",
+ "upload_single": "Deixeu caure-hi un fitxer per a carregar o feu clic per a seleccionar-lo."
+ },
+ "image": {
+ "upload_several": "Deixeu caure-hi imatges per a carregar-les o feu clic per a seleccionar-ne una.",
+ "upload_single": "Deixeu caure-hi una imatge per a carregar-la o feu clic per a seleccionar-la."
+ },
+ "references": {
+ "all_missing": "No ha estat possible trobar les dades de referència.",
+ "many_missing": "Sembla que almenys una de les referències associades ja no està disponible.",
+ "single_missing": "Sembla que la referència associada ja no està disponible."
+ },
+ "password": {
+ "toggle_visible": "Amaga la contrasenya",
+ "toggle_hidden": "Mostra la contrasenya"
+ }
},
"message": {
- "note": "NOTA",
- "transcodingDisabled": "Per motius de seguretat, el canvi de configuració del trasnscodificador amb la interfície web no està habilitat. Si voleu canviar les opcions de transcodificació (sia editar-les sia afegir-ne), reinicieu el servidor amb l'opció %{config}.",
- "transcodingEnabled": "Ara Navidrome s'executa amb %{config}, cosa que fa possible executar ordres del sistema des de les opcions de transcodificació usant la interfície web. Per motius de seguretat us recomanem que només l'activeu quan necessiteu configurar les opcions de transcodificació.",
- "songsAddedToPlaylist": "S'ha afegit 1 cançó a la llista |||| S'han afegit %{smart_count} a la llista",
- "noPlaylistsAvailable": "No n'hi ha cap disponible",
- "delete_user_title": "Esborra usuari '%{nom}'",
- "delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)",
- "remove_missing_title": "Eliminar fitxers faltants",
- "remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.",
- "notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador",
- "notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https",
- "lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment",
- "lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm",
- "lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment",
- "lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm",
- "listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}",
- "listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}",
- "listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat",
- "listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz",
- "openIn": {
- "lastfm": "Obri en Last.fm",
- "musicbrainz": "Obri en MusicBrainz"
- },
- "lastfmLink": "Llegeix més...",
- "shareOriginalFormat": "Compartir en format original",
- "shareDialogTitle": "Compartir %{resource} '%{name}'",
- "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
- "shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter",
- "shareSuccess": "URL copiada al porta-retalls: %{url}",
- "shareFailure": "Error copiant URL %{url} al porta-retalls",
- "downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})",
- "downloadOriginalFormat": "Descarregar en format original"
+ "about": "Quant a...",
+ "are_you_sure": "N'esteu segur?",
+ "bulk_delete_content": "Voleu eliminar aquest %{name}? |||| Voleu eliminar aquests %{smart_count} element?\n",
+ "bulk_delete_title": "Esborra %{name} |||| Esborra %{smart_count} %{name}",
+ "delete_content": "Segur que voleu eliminar aquest element?",
+ "delete_title": "Elimina %{name} #%{id}",
+ "details": "Detalls",
+ "error": "S'ha produït un error en un client i la vostra sol·licitud no ha pogut ser completada.",
+ "invalid_form": "El formulari no és vàlid.",
+ "loading": "La pàgina es carrega, un moment si us plau.",
+ "no": "No",
+ "not_found": "La URL és incorrecta o heu seguit un enllaç erroni.",
+ "yes": "Sí",
+ "unsaved_changes": "Alguns canvis no s'hi han desat. Segur que voleu ignorar-los?"
},
- "menu": {
- "library": "Discoteca",
- "settings": "Configuració",
- "version": "Versió",
- "theme": "Tema",
- "personal": {
- "name": "Personal",
- "options": {
- "theme": "Tema",
- "language": "Llengua",
- "defaultView": "Vista per defecte",
- "desktop_notifications": "Notificacions d'escriptori",
- "lastfmNotConfigured": "No s'ha configurat l'API de Last.fm",
- "lastfmScrobbling": "Activa el seguiment de Last.fm",
- "listenBrainzScrobbling": "Activa el seguiment de ListenBrainz",
- "replaygain": "Mode ReplayGain",
- "preAmp": "PreAmp de ReplayGain (dB)",
- "gain": {
- "none": "Cap",
- "album": "Guany de l'àlbum",
- "track": "Guany de la pista"
- }
- }
- },
- "albumList": "Àlbums",
- "about": "Quant a...",
- "playlists": "Llistes",
- "sharedPlaylists": "Llistes compartides"
+ "navigation": {
+ "no_results": "No s'ha trobat",
+ "no_more_results": "La pàgina número %{page} no existeix. Proveu l'anterior.",
+ "page_out_of_boundaries": "La pàgina número %{page} no existeix",
+ "page_out_from_end": "No podeu anar més enllà de la darrera pàgina",
+ "page_out_from_begin": "No podeu anar més enllà de la primera pàgina",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}",
+ "page_rows_per_page": "Elements per pàgina:",
+ "next": "Següent",
+ "prev": "Anterior",
+ "skip_nav": "Salta al contingut"
},
- "player": {
- "playListsText": "Reprodueix la cua",
- "openText": "Obre",
- "closeText": "Tanca",
- "notContentText": "No hi ha música",
- "clickToPlayText": "Feu clic per a reproduir",
- "clickToPauseText": "Feu clic per a posar en pausa",
- "nextTrackText": "Pista següent",
- "previousTrackText": "Pista anterior",
- "reloadText": "Recarrega",
- "volumeText": "Volum",
- "toggleLyricText": "Activa / desactiva lletra",
- "toggleMiniModeText": "Minimitza",
- "destroyText": "Destrueix",
- "downloadText": "Descarrega",
- "removeAudioListsText": "Elimina llistes d'àudio",
- "clickToDeleteText": "Feu clic per a eliminar %{name}",
- "emptyLyricText": "Sense lletra",
- "playModeText": {
- "order": "En ordre",
- "orderLoop": "Repeteix",
- "singleLoop": "Repeteix una vegada",
- "shufflePlay": "Aleatori"
- }
+ "notification": {
+ "updated": "Element actualitzat |||| %{smart_count} elements actualitzats",
+ "created": "Element creat",
+ "deleted": "Element actualitzat |||| %{smart_count} elements actualitzats",
+ "bad_item": "Element incorrecte",
+ "item_doesnt_exist": "L'element no existeix",
+ "http_error": "Error de comunicació del servidor",
+ "data_provider_error": "dataProvider error. Vegeu la consola si en voleu més detalls.",
+ "i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat",
+ "canceled": "Acció cancel·lada",
+ "logged_out": "La sessió ha acabat, si us plau reconnecteu",
+ "new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra."
},
- "about": {
- "links": {
- "homepage": "Inici",
- "source": "Codi font",
- "featureRequests": "Sol·licitud de funcionalitats",
- "lastInsightsCollection": "Última recolecció d'informació",
- "insights": {
- "disabled": "Desactivada",
- "waiting": "Esperant"
- }
- }
- },
- "activity": {
- "title": "Activitat",
- "totalScanned": "Carpetes escanejades en total",
- "quickScan": "Escaneig ràpid",
- "fullScan": "Escaneig complet",
- "serverUptime": "Temps de funcionament del servidor",
- "serverDown": "Sense connexió"
- },
- "help": {
- "title": "Dreceres de teclat de Navidrome",
- "hotkeys": {
- "show_help": "Mostra aquesta ajuda",
- "toggle_menu": "Commuta la barra lateral",
- "toggle_play": "Reprodueix / Pausa",
- "prev_song": "Cançó anterior",
- "next_song": "Cançó següent",
- "vol_up": "Apuja el volum",
- "vol_down": "Abaixa el volum",
- "toggle_love": "Afegeix la pista a favorits",
- "current_song": "Anar a la cançó actual"
- }
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Columnes a mostrar",
+ "layout": "Disposició",
+ "grid": "Quadrícula",
+ "table": "Taula"
}
-}
+ },
+ "message": {
+ "note": "NOTA",
+ "transcodingDisabled": "Per motius de seguretat, el canvi de configuració del transcodificador amb la interfície web està desactivat. Si voleu canviar les opcions de transcodificació (sia editar-les sia afegir-ne), reinicieu el servidor amb l'opció %{config}.",
+ "transcodingEnabled": "Navidrome s'executa amb %{config}, cosa que fa possible executar ordres del sistema des de les opcions de transcodificació usant la interfície web. Per motius de seguretat us recomanem que només l'activeu quan necessiteu configurar les opcions de transcodificació.",
+ "songsAddedToPlaylist": "S'ha afegit 1 cançó a la llista |||| S'han afegit %{smart_count} a la llista",
+ "noPlaylistsAvailable": "No n'hi ha cap disponible",
+ "delete_user_title": "Esborra usuari '%{nom}'",
+ "delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)",
+ "notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador",
+ "notifications_not_available": "El navegador no és compatible amb les notificacions o no us heu connectat a Navidrome per https",
+ "lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment",
+ "lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm",
+ "lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment",
+ "lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm",
+ "openIn": {
+ "lastfm": "Obri en Last.fm",
+ "musicbrainz": "Obri en MusicBrainz"
+ },
+ "lastfmLink": "Llegeix més...",
+ "listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}",
+ "listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat",
+ "listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz",
+ "downloadOriginalFormat": "Descarregar en el format original",
+ "shareOriginalFormat": "Compartir en format original",
+ "shareDialogTitle": "Compartir %{resource} '%{name}'",
+ "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
+ "shareSuccess": "URL copiada al porta-retalls: %{url}",
+ "shareFailure": "Error copiant URL %{url} al porta-retalls",
+ "downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter",
+ "remove_missing_title": "Eliminar fitxers faltants",
+ "remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.",
+ "remove_all_missing_title": "Suprimir tots els fitxers perduts",
+ "remove_all_missing_content": "Esteu segur que voleu eliminar tots els fitxers desapareguts de la base de dades? Se n'eliminarà permanentment qualsevol referència, inclosos el nombre de reproduccions i les puntuacions.",
+ "noSimilarSongsFound": "No s'ha trobat cap cançó similar",
+ "noTopSongsFound": "No s'ha trobat cap cançó popular",
+ "startingInstantMix": "S'està carregant la mescla immediata..."
+ },
+ "menu": {
+ "library": "Biblioteca",
+ "settings": "Configuració",
+ "version": "Versió",
+ "theme": "Tema",
+ "personal": {
+ "name": "Personal",
+ "options": {
+ "theme": "Tema",
+ "language": "Llengua",
+ "defaultView": "Vista per defecte",
+ "desktop_notifications": "Notificacions d'escriptori",
+ "lastfmScrobbling": "Activa el seguiment de Last.fm",
+ "listenBrainzScrobbling": "Activa el seguiment de ListenBrainz",
+ "replaygain": "Mode ReplayGain",
+ "preAmp": "PreAmp de ReplayGain (dB)",
+ "gain": {
+ "none": "Cap",
+ "album": "Guany de l'àlbum",
+ "track": "Guany de la pista"
+ },
+ "lastfmNotConfigured": "No s'ha configurat l'API de Last.fm"
+ }
+ },
+ "albumList": "Àlbums",
+ "about": "Quant a...",
+ "playlists": "Llistes",
+ "sharedPlaylists": "Llistes compartides",
+ "librarySelector": {
+ "allLibraries": "Totes les llibreries (%{count})",
+ "multipleLibraries": "%{selected} de %{total} Biblioteques",
+ "selectLibraries": "Selecciona les biblioteques",
+ "none": "Cap"
+ }
+ },
+ "player": {
+ "playListsText": "Reprodueix la cua",
+ "openText": "Obre",
+ "closeText": "Tanca",
+ "notContentText": "No hi ha música",
+ "clickToPlayText": "Feu clic per a reproduir",
+ "clickToPauseText": "Feu clic per a posar en pausa",
+ "nextTrackText": "Pista següent",
+ "previousTrackText": "Pista anterior",
+ "reloadText": "Recarrega",
+ "volumeText": "Volum",
+ "toggleLyricText": "Activa / desactiva lletra",
+ "toggleMiniModeText": "Minimitza",
+ "destroyText": "Destrueix",
+ "downloadText": "Descarrega",
+ "removeAudioListsText": "Elimina llistes d'àudio",
+ "clickToDeleteText": "Feu clic per a eliminar %{name}",
+ "emptyLyricText": "Sense lletra",
+ "playModeText": {
+ "order": "En ordre",
+ "orderLoop": "Repeteix",
+ "singleLoop": "Repeteix una vegada",
+ "shufflePlay": "Aleatori"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Inici",
+ "source": "Codi font",
+ "featureRequests": "Sol·licitud de funcionalitats",
+ "lastInsightsCollection": "Última recolecció d'informació",
+ "insights": {
+ "disabled": "Desactivada",
+ "waiting": "Esperant"
+ }
+ },
+ "tabs": {
+ "about": "Quant a",
+ "config": "Configuració"
+ },
+ "config": {
+ "configName": "Nom de Config",
+ "environmentVariable": "Variable d'entorn",
+ "currentValue": "Valor actual",
+ "configurationFile": "Fitxer de configuració",
+ "exportToml": "Exporta la configuració (TOML)",
+ "exportSuccess": "Configuració exportada al porta-retalls en format TOML",
+ "exportFailed": "La còpia de la configuració ha fallat",
+ "devFlagsHeader": "Indicadors de desenvolupament (subjecte a canvis o eliminació)",
+ "devFlagsComment": "Aquests paràmetres són experimentals i és possible que s'eliminin en versions futures"
+ }
+ },
+ "activity": {
+ "title": "Activitat",
+ "totalScanned": "Carpetes escanejades en total",
+ "quickScan": "Escaneig ràpid",
+ "fullScan": "Escaneig complet",
+ "serverUptime": "Temps de funcionament del servidor",
+ "serverDown": "Sense connexió",
+ "scanType": "Últim escaneig",
+ "status": "Error d'escaneig",
+ "elapsedTime": "Temps transcorregut",
+ "selectiveScan": "Selectiu"
+ },
+ "help": {
+ "title": "Dreceres de teclat de Navidrome",
+ "hotkeys": {
+ "show_help": "Mostra aquesta ajuda",
+ "toggle_menu": "Commuta la barra lateral",
+ "toggle_play": "Reprodueix / Pausa",
+ "prev_song": "Cançó anterior",
+ "next_song": "Cançó següent",
+ "vol_up": "Apuja el volum",
+ "vol_down": "Abaixa el volum",
+ "toggle_love": "Afegeix la pista a favorits",
+ "current_song": "Anar a la cançó actual"
+ }
+ },
+ "nowPlaying": {
+ "title": "Està sonant",
+ "empty": "No s'està reproduint res",
+ "minutesAgo": "Fa %{smart_count} minut |||| Fa %{smart_count} minuts"
+ }
+}
\ No newline at end of file
diff --git a/resources/i18n/de.json b/resources/i18n/de.json
index 22e2fab44..972debb37 100644
--- a/resources/i18n/de.json
+++ b/resources/i18n/de.json
@@ -36,7 +36,8 @@
"bitDepth": "Bittiefe",
"sampleRate": "Samplerate",
"missing": "Fehlend",
- "libraryName": "Bibliothek"
+ "libraryName": "Bibliothek",
+ "composer": "Komponist"
},
"actions": {
"addToQueue": "Später abspielen",
@@ -46,7 +47,8 @@
"download": "Herunterladen",
"playNext": "Als nächstes abspielen",
"info": "Mehr Informationen",
- "showInPlaylist": "In Wiedergabeliste anzeigen"
+ "showInPlaylist": "In Wiedergabeliste anzeigen",
+ "instantMix": ""
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Bibliothek Scan läuft...",
"noLibrariesAssigned": "Keine Bibliotheken zugeordnet"
}
+ },
+ "plugin": {
+ "name": "Plugin |||| Plugins",
+ "fields": {
+ "id": "ID",
+ "name": "Name",
+ "description": "Beschreibung",
+ "version": "Version",
+ "author": "Autor",
+ "website": "Website",
+ "permissions": "Berechtigungen",
+ "enabled": "Aktiv",
+ "status": "Status",
+ "path": "Pfad",
+ "lastError": "Fehler",
+ "hasError": "Fehler",
+ "updatedAt": "Aktualisiert am",
+ "createdAt": "Installiert",
+ "configKey": "Schlüssel",
+ "configValue": "Wert",
+ "allUsers": "Alle Benutzer",
+ "selectedUsers": "Ausgewählte Benutzer",
+ "allLibraries": "Alle Bibliotheken",
+ "selectedLibraries": "Ausgewählte Bibliotheken"
+ },
+ "sections": {
+ "status": "Status",
+ "info": "Plugin Information",
+ "configuration": "Konfiguration",
+ "manifest": "Manifest",
+ "usersPermission": "Benutzer Zugriff",
+ "libraryPermission": "Bibliotheken Zugriff"
+ },
+ "status": {
+ "enabled": "Aktiv",
+ "disabled": "Inaktiv"
+ },
+ "actions": {
+ "enable": "Aktivieren",
+ "disable": "Deaktivieren",
+ "disabledDueToError": "Fehler beheben um Plugin zu aktivieren",
+ "disabledUsersRequired": "Wähle Benutzer Zugriff um Plugin zu aktivieren",
+ "disabledLibrariesRequired": "Wähle Bibliotheken Zugriff um Plugin zu aktivieren",
+ "addConfig": "Konfiguration hinzufügen",
+ "rescan": "Scan"
+ },
+ "notifications": {
+ "enabled": "Plugin aktiv",
+ "disabled": "Plugin inaktiv",
+ "updated": "Plugin aktualisiert",
+ "error": "Fehler beim aktualisieren des Plugins"
+ },
+ "validation": {
+ "invalidJson": "Konfiguration muss valides JSON sein"
+ },
+ "messages": {
+ "configHelp": "Plugin mit Schlüssel-Werte Paaren konfigurieren. Leer lassen wenn das Plugin keine Konfiguration benötigt.",
+ "clickPermissions": "Berechtigung anklicken für mehr Details",
+ "noConfig": "Keine Konfiguration gesetzt",
+ "allUsersHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Benutzer, inklusive solcher, die in Zukunft erstellt werden.",
+ "noUsers": "Keine Benutzer ausgewählt",
+ "permissionReason": "Begründung",
+ "usersRequired": "Dieses Plugin benötigt Zugriff auf Benutzerinformationen. Wähle aus, auf welche Nutzer das Plugin zugreifen darf oder wähle 'Alle Benutzer'.",
+ "allLibrariesHelp": "Wenn aktiviert, erhält das Plugin Zugriff auf alle Bibliotheken, inklusive solcher, die in Zukunft erstellt werden.",
+ "noLibraries": "Keine Bibliotheken ausgewählt",
+ "librariesRequired": "Dieses Plugin benötigt Zugriff auf Bibliotheken. Wähle aus, auf welche Bibliotheken das Plugin zugreifen darf oder wähle 'Alle Bibliotheken'.",
+ "requiredHosts": "Benötigte Hosts",
+ "configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
+ "schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt."
+ },
+ "placeholders": {
+ "configKey": "Schlüssel",
+ "configValue": "Wert"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Alle fehlenden Dateien entfernen",
"remove_all_missing_content": "Möchtest du wirklich alle Fehlenden Dateien aus der Datenbank entfernen? Alle Referenzen zu den Dateien wie Anzahl Wiedergaben und Bewertungen werden permanent gelöscht.",
"noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
- "noTopSongsFound": "Keine beliebten Titel gefunden"
+ "noTopSongsFound": "Keine beliebten Titel gefunden",
+ "startingInstantMix": ""
},
"menu": {
"library": "Bibliothek",
diff --git a/resources/i18n/el.json b/resources/i18n/el.json
index 4dd58e9cc..02d0b06c4 100644
--- a/resources/i18n/el.json
+++ b/resources/i18n/el.json
@@ -36,7 +36,8 @@
"bitDepth": "Λίγο βάθος",
"sampleRate": "Ποσοστό δειγματοληψίας",
"missing": "Απών",
- "libraryName": "Βιβλιοθήκη"
+ "libraryName": "Βιβλιοθήκη",
+ "composer": "Συνθέτης"
},
"actions": {
"addToQueue": "Αναπαραγωγη Μετα",
@@ -46,7 +47,8 @@
"download": "Ληψη",
"playNext": "Επόμενη Αναπαραγωγή",
"info": "Εμφάνιση Πληροφοριών",
- "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής"
+ "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής",
+ "instantMix": "Άμεση Μίξη"
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Σάρωση σε εξέλιξη...",
"noLibrariesAssigned": "Δεν έχουν αντιστοιχιστεί βιβλιοθήκες σε αυτόν τον χρήστη"
}
+ },
+ "plugin": {
+ "name": "Πρόσθετο |||| Πρόσθετα",
+ "fields": {
+ "id": "ID",
+ "name": "Όνομα",
+ "description": "Περιγραφή",
+ "version": "Έκδοση",
+ "author": "Καλλιτέχνης",
+ "website": "Ιστοσελίδα",
+ "permissions": "Άδειες",
+ "enabled": "Ενεργό",
+ "status": "Κατάσταση",
+ "path": "Διαδρομή",
+ "lastError": "Σφάλμα",
+ "hasError": "Σφάλμα",
+ "updatedAt": "Ενημερώθηκε",
+ "createdAt": "Εγκατασταθηκε",
+ "configKey": "Κλειδί",
+ "configValue": "Τιμή",
+ "allUsers": "Επιτρέψτε όλους τους χρήστες",
+ "selectedUsers": "Επιλογή χρηστών",
+ "allLibraries": "Επιτρέψτε όλες τις βιβλιοθήκες",
+ "selectedLibraries": "Επιλεγμένες βιβλιοθήκες"
+ },
+ "sections": {
+ "status": "Κατάσταση",
+ "info": "Πληροφορίες Πρόσθετου",
+ "configuration": "Παραμετροποίηση",
+ "manifest": "Manifest",
+ "usersPermission": "Άδειες Χρηστών",
+ "libraryPermission": "Άδειες Βιβλιοθηκών"
+ },
+ "status": {
+ "enabled": "Ενεργό",
+ "disabled": "Ανενεργό"
+ },
+ "actions": {
+ "enable": "Ενεργοποίηση",
+ "disable": "Απενεργοποίηση",
+ "disabledDueToError": "Διορθώστε το σφάλμα πριν την ενεργοποίηση",
+ "disabledUsersRequired": "Επιλέξτε χρήστες πριν την ενεργοποίηση",
+ "disabledLibrariesRequired": "Επιλέξτε βιβλιοθήκες πριν την ενεργοποίηση",
+ "addConfig": "Προσθήκη παραμετροποίησης",
+ "rescan": "Σάρωση ξανά"
+ },
+ "notifications": {
+ "enabled": "Πρόσθετο ενεργοποιημένο",
+ "disabled": "Πρόσθετο απενεργοποιημένο",
+ "updated": "Πρόσθετο ενημερωμένο",
+ "error": "Σφάλμα κατά την ενημέρωση του πρόσθετου"
+ },
+ "validation": {
+ "invalidJson": "Η παραμετροποίηση πρέπει να είναι συμβατό JSON"
+ },
+ "messages": {
+ "configHelp": "Παραμετροποιήστε το πρόσθετο με χρήση ζεύγων κλειδιών-τιμών. Αφήστε κενό αν το πρόσθετο δεν απαιτεί παραμετροποίηση",
+ "clickPermissions": "Κάνετε κλικ για λεπτομέρειες αδειών",
+ "noConfig": "Δεν ορίστηκε παραμετροποίηση",
+ "allUsersHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλους τους χρήστες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.",
+ "noUsers": "Δεν επιλέχθηκαν χρήστες",
+ "permissionReason": "Αιτία",
+ "usersRequired": "Το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες χρηστών. Ορίστε τους χρήστες που θα έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλους τους χρήστες'",
+ "allLibrariesHelp": "Όταν είναι ενεργό, το πρόσθετο θα έχει πρόσβαση σε όλες τις βιβλιοθήκες, συμπεριλαμβανομένων και όσων δημιουργηθούν στο μέλλον.",
+ "noLibraries": "Δεν επιλέχθηκαν βιβλιοθήκες",
+ "librariesRequired": "Αυτό το πρόσθετο απαιτεί πρόσβαση στις πληροφορίες βιβλιοθήκης. Επιλέξτε σε ποιές βιβλιοθήκες μπορεί να έχει πρόσβαση το πρόσθετο, ή ενεργοποιήστε το 'Επιτρέψτε όλες τις βιβλιοθήκες'",
+ "requiredHosts": "Απαιτούμενοι hosts",
+ "configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
+ "schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο."
+ },
+ "placeholders": {
+ "configKey": "κλειδί",
+ "configValue": "τιμή"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Αφαίρεση όλων των αρχείων που λείπουν",
"remove_all_missing_content": "Είστε βέβαιοι ότι θέλετε να καταργήσετε όλα τα αρχεία που λείπουν από τη βάση δεδομένων? Αυτό θα καταργήσει οριστικά τυχόν αναφορές σε αυτά, συμπεριλαμβανομένου του αριθμού αναπαραγωγών και των αξιολογήσεών τους.",
"noSimilarSongsFound": "Δεν βρέθηκαν παρόμοια τραγούδια",
- "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια"
+ "noTopSongsFound": "Δεν βρέθηκαν κορυφαία τραγούδια",
+ "startingInstantMix": "Φόρτωση Άμεσης Μίξης..."
},
"menu": {
"library": "Βιβλιοθήκη",
diff --git a/resources/i18n/es.json b/resources/i18n/es.json
index 8d7219883..4e9e13e57 100644
--- a/resources/i18n/es.json
+++ b/resources/i18n/es.json
@@ -12,16 +12,12 @@
"artist": "Artista",
"album": "Álbum",
"path": "Ruta del archivo",
- "libraryName": "Biblioteca",
"genre": "Género",
"compilation": "Compilación",
"year": "Año",
"size": "Tamaño del archivo",
"updatedAt": "Actualizado el",
"bitRate": "Tasa de bits",
- "bitDepth": "Profundidad de bits",
- "sampleRate": "Frecuencia de muestreo",
- "channels": "Canales",
"discSubtitle": "Subtítulo del disco",
"starred": "Favorito",
"comment": "Comentario",
@@ -29,6 +25,7 @@
"quality": "Calidad",
"bpm": "BPM",
"playDate": "Últimas reproducciones",
+ "channels": "Canales",
"createdAt": "Creado el",
"grouping": "Agrupación",
"mood": "Estado de ánimo",
@@ -36,17 +33,22 @@
"tags": "Etiquetas",
"mappedTags": "Etiquetas asignadas",
"rawTags": "Etiquetas sin procesar",
- "missing": "Faltante"
+ "bitDepth": "Profundidad de bits",
+ "sampleRate": "Frecuencia de muestreo",
+ "missing": "Faltante",
+ "libraryName": "Biblioteca",
+ "composer": "Compositor"
},
"actions": {
"addToQueue": "Reproducir después",
"playNow": "Reproducir ahora",
"addToPlaylist": "Agregar a la playlist",
- "showInPlaylist": "Mostrar en la lista de reproducción",
"shuffleAll": "Todas aleatorias",
"download": "Descarga",
"playNext": "Siguiente",
- "info": "Obtener información"
+ "info": "Obtener información",
+ "showInPlaylist": "Mostrar en la lista de reproducción",
+ "instantMix": ""
}
},
"album": {
@@ -57,38 +59,38 @@
"duration": "Duración",
"songCount": "Canciones",
"playCount": "Reproducciones",
- "size": "Tamaño del archivo",
"name": "Nombre",
- "libraryName": "Biblioteca",
"genre": "Género",
"compilation": "Compilación",
"year": "Año",
- "date": "Fecha de grabación",
- "originalDate": "Original",
- "releaseDate": "Publicado",
- "releases": "Lanzamiento |||| Lanzamientos",
- "released": "Publicado",
"updatedAt": "Actualizado el",
"comment": "Comentario",
"rating": "Calificación",
"createdAt": "Creado el",
+ "size": "Tamaño del archivo",
+ "originalDate": "Original",
+ "releaseDate": "Publicado",
+ "releases": "Lanzamiento |||| Lanzamientos",
+ "released": "Publicado",
"recordLabel": "Discográfica",
"catalogNum": "Número de catálogo",
"releaseType": "Tipo de lanzamiento",
"grouping": "Agrupación",
"media": "Medios",
"mood": "Estado de ánimo",
- "missing": "Faltante"
+ "date": "Fecha de grabación",
+ "missing": "Faltante",
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
"playNext": "Reproducir siguiente",
"addToQueue": "Reproducir después",
- "share": "Compartir",
"shuffle": "Aleatorio",
"addToPlaylist": "Agregar a la lista",
"download": "Descargar",
- "info": "Obtener información"
+ "info": "Obtener información",
+ "share": "Compartir"
},
"lists": {
"all": "Todos",
@@ -106,10 +108,10 @@
"name": "Nombre",
"albumCount": "Número de álbumes",
"songCount": "Número de canciones",
- "size": "Tamaño",
"playCount": "Reproducciones",
"rating": "Calificación",
"genre": "Género",
+ "size": "Tamaño",
"role": "Rol",
"missing": "Faltante"
},
@@ -130,9 +132,9 @@
"maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
},
"actions": {
- "topSongs": "Más destacadas",
"shuffle": "Aleatorio",
- "radio": "Radio"
+ "radio": "Radio",
+ "topSongs": "Más destacadas"
}
},
"user": {
@@ -141,7 +143,6 @@
"userName": "Nombre de usuario",
"isAdmin": "Es administrador",
"lastLoginAt": "Último inicio de sesión",
- "lastAccessAt": "Último acceso",
"updatedAt": "Actualizado el",
"name": "Nombre",
"password": "Contraseña",
@@ -150,6 +151,7 @@
"currentPassword": "Contraseña actual",
"newPassword": "Nueva contraseña",
"token": "Token",
+ "lastAccessAt": "Último acceso",
"libraries": "Bibliotecas"
},
"helperTexts": {
@@ -211,9 +213,9 @@
"selectPlaylist": "Seleccione una lista:",
"addNewPlaylist": "Creada \"%{name}\"",
"export": "Exportar",
- "saveQueue": "Guardar la fila de reproducción en una playlist",
"makePublic": "Hazla pública",
"makePrivate": "Hazla privada",
+ "saveQueue": "Guardar la fila de reproducción en una playlist",
"searchOrCreate": "Buscar listas de reproducción o escribe para crear una nueva…",
"pressEnterToCreate": "Pulsa Enter para crear una nueva lista de reproducción",
"removeFromSelection": "Quitar de la selección"
@@ -244,7 +246,6 @@
"username": "Compartido por",
"url": "URL",
"description": "Descripción",
- "downloadable": "¿Permitir descargas?",
"contents": "Contenido",
"expiresAt": "Caduca el",
"lastVisitedAt": "Visitado por última vez el",
@@ -252,14 +253,12 @@
"format": "Formato",
"maxBitRate": "Tasa de bits Máx.",
"updatedAt": "Actualizado el",
- "createdAt": "Creado el"
- },
- "notifications": {},
- "actions": {}
+ "createdAt": "Creado el",
+ "downloadable": "¿Permitir descargas?"
+ }
},
"missing": {
"name": "Fichero faltante |||| Ficheros faltantes",
- "empty": "No faltan archivos",
"fields": {
"path": "Ruta",
"size": "Tamaño",
@@ -272,7 +271,8 @@
},
"notifications": {
"removed": "Eliminado"
- }
+ },
+ "empty": "No faltan archivos"
},
"library": {
"name": "Biblioteca |||| Bibliotecas",
@@ -302,20 +302,20 @@
},
"actions": {
"scan": "Escanear biblioteca",
- "quickScan": "Escaneo rápido",
- "fullScan": "Escaneo completo",
"manageUsers": "Gestionar el acceso de usarios",
- "viewDetails": "Ver detalles"
+ "viewDetails": "Ver detalles",
+ "quickScan": "Escaneo rápido",
+ "fullScan": "Escaneo completo"
},
"notifications": {
"created": "La biblioteca se creó correctamente",
"updated": "La biblioteca se actualizó correctamente",
"deleted": "La biblioteca se eliminó correctamente",
"scanStarted": "El escaneo de la biblioteca ha comenzado",
+ "scanCompleted": "El escaneo de la biblioteca se completó",
"quickScanStarted": "Escaneo rápido ha comenzado",
"fullScanStarted": "Escaneo completo ha comenzado",
- "scanError": "Error al iniciar el escaneo. Revisa los registros",
- "scanCompleted": "El escaneo de la biblioteca se completó"
+ "scanError": "Error al iniciar el escaneo. Revisa los registros"
},
"validation": {
"nameRequired": "El nombre de la biblioteca es obligatorio",
@@ -396,7 +396,9 @@
"allLibrariesHelp": "Cuando se active, el plugin tendrá acceso a todas las bibliotecas, incluidas las que se creen en el futuro.",
"noLibraries": "Ninguna biblioteca seleccionada",
"librariesRequired": "Este plugin requiere acceso a la información de las bibliotecas. Selecciona a qué bibliotecas puede acceder el plugin, o activa 'Permitir todas las bibliotecas'.",
- "requiredHosts": "Hosts requeridos"
+ "requiredHosts": "Hosts requeridos",
+ "configValidationError": "La validación de la configuración falló:",
+ "schemaRenderError": "No se pudo renderizar el formulario de configuración. Es posible que el esquema del complemento no sea válido."
},
"placeholders": {
"configKey": "clave",
@@ -439,7 +441,6 @@
"add": "Añadir",
"back": "Ir atrás",
"bulk_actions": "1 elemento seleccionado |||| %{smart_count} elementos seleccionados",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Cancelar",
"clear_input_value": "Limpiar valor",
"clone": "Duplicar",
@@ -463,6 +464,7 @@
"close_menu": "Cerrar menú",
"unselect": "Deseleccionado",
"skip": "Omitir",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Compartir",
"download": "Descargar"
},
@@ -554,47 +556,42 @@
"transcodingDisabled": "Cambiar la configuración de la transcodificación a través de la interfaz web esta deshabilitado por motivos de seguridad. Si quieres cambiar (editar o agregar) opciones de transcodificación, reinicia el servidor con la %{config} opción de configuración.",
"transcodingEnabled": "Navidrom se esta ejecutando con %{config}, lo que hace posible ejecutar comandos de sistema desde el apartado de transcodificación en la interfaz web. Recomendamos deshabilitarlo por motivos de seguridad y solo habilitarlo cuando se este configurando opciones de transcodificación.",
"songsAddedToPlaylist": "1 canción agregada a la lista |||| %{smart_count} canciones agregadas a la lista",
- "noSimilarSongsFound": "No se encontraron canciones similares",
- "noTopSongsFound": "No se encontraron canciones destacadas",
"noPlaylistsAvailable": "Ninguna lista disponible",
"delete_user_title": "Eliminar usuario '%{name}'",
"delete_user_content": "¿Esta seguro de eliminar a este usuario y todos sus datos (incluyendo listas y preferencias)?",
- "remove_missing_title": "Eliminar archivos faltantes",
- "remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
- "remove_all_missing_title": "Eliminar todos los archivos faltantes",
- "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"notifications_blocked": "Las notificaciones de este sitio están bloqueadas en tu navegador",
"notifications_not_available": "Este navegador no soporta notificaciones o no ingresaste a Navidrome usando https",
"lastfmLinkSuccess": "Last.fm esta conectado y el scrobbling esta activado",
"lastfmLinkFailure": "No se pudo conectar con Last.fm",
"lastfmUnlinkSuccess": "Last.fm se ha desconectado y el scrobbling se desactivo",
"lastfmUnlinkFailure": "No se pudo desconectar Last.fm",
- "listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}",
- "listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}",
- "listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling",
- "listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz",
"openIn": {
"lastfm": "Ver en Last.fm",
"musicbrainz": "Ver en MusicBrainz"
},
"lastfmLink": "Leer más...",
+ "listenBrainzLinkSuccess": "Se ha conectado correctamente a ListenBrainz y se activó el scrobbling como el usuario: %{user}",
+ "listenBrainzLinkFailure": "No se pudo conectar con ListenBrainz: %{error}",
+ "listenBrainzUnlinkSuccess": "Se desconectó ListenBrainz y se desactivó el scrobbling",
+ "listenBrainzUnlinkFailure": "No se pudo desconectar ListenBrainz",
+ "downloadOriginalFormat": "Descargar formato original",
"shareOriginalFormat": "Compartir formato original",
"shareDialogTitle": "Compartir %{resource} '%{name}'",
"shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}",
- "shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
"shareSuccess": "URL copiada al portapapeles: %{url}",
"shareFailure": "Error al copiar la URL %{url} al portapapeles",
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
- "downloadOriginalFormat": "Descargar formato original"
+ "shareCopyToClipboard": "Copiar al portapapeles: Ctrl+C, Intro",
+ "remove_missing_title": "Eliminar archivos faltantes",
+ "remove_missing_content": "¿Realmente desea eliminar los archivos faltantes seleccionados de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
+ "remove_all_missing_title": "Eliminar todos los archivos faltantes",
+ "remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
+ "noSimilarSongsFound": "No se encontraron canciones similares",
+ "noTopSongsFound": "No se encontraron canciones destacadas",
+ "startingInstantMix": ""
},
"menu": {
"library": "Biblioteca",
- "librarySelector": {
- "allLibraries": "Todas las bibliotecas (%{count})",
- "multipleLibraries": "%{selected} de %{total} bibliotecas",
- "selectLibraries": "Seleccionar bibliotecas",
- "none": "Ninguno"
- },
"settings": "Ajustes",
"version": "Versión",
"theme": "Tema",
@@ -605,7 +602,6 @@
"language": "Idioma",
"defaultView": "Vista por defecto",
"desktop_notifications": "Notificaciones de escritorio",
- "lastfmNotConfigured": "La clave API de Last.fm no está configurada",
"lastfmScrobbling": "Scrobble a Last.fm",
"listenBrainzScrobbling": "Scrobble a ListenBrainz",
"replaygain": "Modo de ReplayGain",
@@ -614,13 +610,20 @@
"none": "Desactivado",
"album": "Ganancia del álbum",
"track": "Ganancia de pista"
- }
+ },
+ "lastfmNotConfigured": "La clave API de Last.fm no está configurada"
}
},
"albumList": "Álbumes",
+ "about": "Acerca de",
"playlists": "Playlists",
"sharedPlaylists": "Playlists Compartidas",
- "about": "Acerca de"
+ "librarySelector": {
+ "allLibraries": "Todas las bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} bibliotecas",
+ "selectLibraries": "Seleccionar bibliotecas",
+ "none": "Ninguno"
+ }
},
"player": {
"playListsText": "Fila de reproducción",
@@ -679,17 +682,12 @@
"totalScanned": "Total de carpetas escaneadas",
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
- "selectiveScan": "Selectivo",
"serverUptime": "Uptime del servidor",
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
- "elapsedTime": "Tiempo transcurrido"
- },
- "nowPlaying": {
- "title": "En reproducción",
- "empty": "Nada en reproducción",
- "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
+ "elapsedTime": "Tiempo transcurrido",
+ "selectiveScan": "Selectivo"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -699,10 +697,15 @@
"toggle_play": "Reproducir / Pausar",
"prev_song": "Canción anterior",
"next_song": "Siguiente canción",
- "current_song": "Canción actual",
"vol_up": "Subir volumen",
"vol_down": "Bajar volumen",
- "toggle_love": "Marca esta canción como favorita"
+ "toggle_love": "Marca esta canción como favorita",
+ "current_song": "Canción actual"
}
+ },
+ "nowPlaying": {
+ "title": "En reproducción",
+ "empty": "Nada en reproducción",
+ "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
-}
+}
\ No newline at end of file
diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json
index fc2793389..0d260fb44 100644
--- a/resources/i18n/fi.json
+++ b/resources/i18n/fi.json
@@ -36,7 +36,8 @@
"bitDepth": "Bittisyvyys",
"sampleRate": "Näytteenottotaajuus",
"missing": "Puuttuva",
- "libraryName": "Kirjasto"
+ "libraryName": "Kirjasto",
+ "composer": "Säveltäjä"
},
"actions": {
"addToQueue": "Lisää jonoon",
@@ -46,7 +47,8 @@
"download": "Lataa",
"playNext": "Soita seuraavaksi",
"info": "Info",
- "showInPlaylist": "Näytä soittolistassa"
+ "showInPlaylist": "Näytä soittolistassa",
+ "instantMix": "Pikasekoitus"
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Skannaus käynnissä...",
"noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
}
+ },
+ "plugin": {
+ "name": "Liitännäinen |||| Liitännäiset",
+ "fields": {
+ "id": "ID",
+ "name": "Nimi",
+ "description": "Kuvaus",
+ "version": "Versio",
+ "author": "Tekijä",
+ "website": "Verkkosivusto",
+ "permissions": "Oikeudet",
+ "enabled": "Käytössä",
+ "status": "Tila",
+ "path": "Polku",
+ "lastError": "Virhe",
+ "hasError": "Virhe",
+ "updatedAt": "Päivitetty",
+ "createdAt": "Asennettu",
+ "configKey": "Avain",
+ "configValue": "Arvo",
+ "allUsers": "Salli kaikki käyttäjät",
+ "selectedUsers": "Valitut käyttäjät",
+ "allLibraries": "Salli kaikki kirjastot",
+ "selectedLibraries": "Valitut kirjastot"
+ },
+ "sections": {
+ "status": "Tila",
+ "info": "Lisäosan tiedot",
+ "configuration": "Määritykset",
+ "manifest": "Luettelo",
+ "usersPermission": "Käyttäjäoikeudet",
+ "libraryPermission": "Kirjaston oikeudet"
+ },
+ "status": {
+ "enabled": "Käytössä",
+ "disabled": "Ei käytössä"
+ },
+ "actions": {
+ "enable": "Ota käyttöön",
+ "disable": "Poista käytöstä",
+ "disabledDueToError": "Korjaa virhe ennen käyttöönottoa",
+ "disabledUsersRequired": "Valitse käyttäjät ennen käyttöönottoa",
+ "disabledLibrariesRequired": "Valitse kirjastot ennen käyttöönottoa",
+ "addConfig": "Lisää määritykset",
+ "rescan": "Skannaa uudelleen"
+ },
+ "notifications": {
+ "enabled": "Lisäosa käytössä",
+ "disabled": "Lisäosa ei käytössä",
+ "updated": "Lisäosa päivitetty",
+ "error": "Virhe lisäosaa päivitettäessä"
+ },
+ "validation": {
+ "invalidJson": "Määrityksen on oltava kelvollinen JSON"
+ },
+ "messages": {
+ "configHelp": "Määritä lisäosa avain-arvo-parien avulla. Jätä tyhjäksi, jos lisäosa ei vaadi määrityksiä.",
+ "clickPermissions": "Napsauta käyttöoikeutta saadaksesi lisätietoja",
+ "noConfig": "Ei määritettyjä asetuksia",
+ "allUsersHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin käyttäjiin, myös tulevaisuudessa luotaviin.",
+ "noUsers": "Ei valittuja käyttäjiä",
+ "permissionReason": "Syy",
+ "usersRequired": "Tämä laajennus vaatii pääsyn käyttäjätietoihin. Valitse käyttäjät, joihin laajennus voi päästä, tai ota käyttöön 'Salli kaikki käyttäjät'.",
+ "allLibrariesHelp": "Kun tämä on käytössä, laajennuksella on pääsy kaikkiin kirjastoihin, myös tulevaisuudessa luotaviin.",
+ "noLibraries": "Ei valittuja kirjastoja",
+ "librariesRequired": "Tämä laajennus vaatii pääsyn kirjastotietoihin. Valitse, mihin kirjastoihin laajennus voi käyttää, tai ota käyttöön 'Salli kaikki kirjastot'.",
+ "requiredHosts": "Vaaditut palvelimet",
+ "configValidationError": "Määrityksen validointi epäonnistui:",
+ "schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen."
+ },
+ "placeholders": {
+ "configKey": "avain",
+ "configValue": "arvo"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Poista kaikki puuttuvat tiedostot",
"remove_all_missing_content": "Haluatko varmasti poistaa kaikki puuttuvat tiedostot tietokannasta? Tämä poistaa pysyvästi kaikki viittaukset niihin, mukaan lukien toistomäärät ja arvostelut.",
"noSimilarSongsFound": "Samankaltaisia kappaleita ei löytynyt",
- "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt"
+ "noTopSongsFound": "Suosituimpia kappaleita ei löytynyt",
+ "startingInstantMix": "Ladataan Pikasekoitus..."
},
"menu": {
"library": "Kirjasto",
@@ -586,16 +663,16 @@
},
"tabs": {
"about": "Tietoja",
- "config": "Kokoonpano"
+ "config": "Määritykset"
},
"config": {
"configName": "Konfiguraation nimi",
"environmentVariable": "Ympäristömuuttuja",
"currentValue": "Nykyinen arvo",
- "configurationFile": "Konfiguraatiotiedosto",
- "exportToml": "Vie konfiguraatio (TOML)",
- "exportSuccess": "Konfiguraatio viety leikepöydälle TOML-muodossa",
- "exportFailed": "Konfiguraation kopiointi epäonnistui",
+ "configurationFile": "Määritystiedosto",
+ "exportToml": "Vie määritys (TOML)",
+ "exportSuccess": "Määritykset viety leikepöydälle TOML-muodossa",
+ "exportFailed": "Määritysten kopiointi epäonnistui",
"devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)",
"devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa"
}
diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json
index 070e63977..d3d0d5d57 100644
--- a/resources/i18n/fr.json
+++ b/resources/i18n/fr.json
@@ -36,7 +36,8 @@
"bitDepth": "Profondeur de bits",
"sampleRate": "Fréquence d'échantillonnage",
"missing": "Manquant",
- "libraryName": "Bibliothèque"
+ "libraryName": "Bibliothèque",
+ "composer": "Compositeur·e"
},
"actions": {
"addToQueue": "Ajouter à la file",
@@ -46,7 +47,8 @@
"download": "Télécharger",
"playNext": "Jouer ensuite",
"info": "Plus d'informations",
- "showInPlaylist": "Montrer dans la playlist"
+ "showInPlaylist": "Montrer dans la playlist",
+ "instantMix": ""
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Scan en cours...",
"noLibrariesAssigned": "Aucune bibliothèque pour cet utilisateur"
}
+ },
+ "plugin": {
+ "name": "Extension |||| Extensions",
+ "fields": {
+ "id": "ID",
+ "name": "Nom",
+ "description": "Description",
+ "version": "Version",
+ "author": "Auteur.e",
+ "website": "Site web",
+ "permissions": "Permissions",
+ "enabled": "Activée",
+ "status": "Statut",
+ "path": "Chemin",
+ "lastError": "Erreur",
+ "hasError": "Erreur",
+ "updatedAt": "Mise à jour",
+ "createdAt": "Installée",
+ "configKey": "Clef",
+ "configValue": "Valeur",
+ "allUsers": "Autoriser tous les utilisateur·rices",
+ "selectedUsers": "Utilisateur·rices sélectionné.e.s",
+ "allLibraries": "Autoriser toutes les bibliothèques",
+ "selectedLibraries": "Bibliothèques sélectionnées"
+ },
+ "sections": {
+ "status": "Statut",
+ "info": "Informations de l'extension",
+ "configuration": "Configuration",
+ "manifest": "Manifeste",
+ "usersPermission": "Permissions utilisateur·ices",
+ "libraryPermission": "Permissions des bibliothèques"
+ },
+ "status": {
+ "enabled": "Activées",
+ "disabled": "Désactivées"
+ },
+ "actions": {
+ "enable": "Activer",
+ "disable": "Désactiver",
+ "disabledDueToError": "L'erreur doit être réglée avant de pouvoir activer la bibliothèque",
+ "disabledUsersRequired": "Sélectionner des utilisateur·ices avant d'activer la bibliothèque",
+ "disabledLibrariesRequired": "Sélectionner au moins une bibliothèque",
+ "addConfig": "Ajouter une configuration",
+ "rescan": "Rescanner"
+ },
+ "notifications": {
+ "enabled": "Extension activée",
+ "disabled": "Extension désactivée",
+ "updated": "Extension mise à jour",
+ "error": "Erreur pendant la mise à jour de l'extension"
+ },
+ "validation": {
+ "invalidJson": "La configuration doit être un JSON valide"
+ },
+ "messages": {
+ "configHelp": "Configurer l'extension en utilisant des paires clef/valeurs. Laisser vide si l'extension ne requiert aucune configuration.",
+ "clickPermissions": "Cliquer sur une permission pour plus de détails",
+ "noConfig": "Aucune configuration",
+ "allUsersHelp": "Quand sélectionnée, l'extension aura accès à l'ensemble des utilisateur·rices, y compris ceux créé.e.s dans le future.",
+ "noUsers": "Aucun.e utilisateur·rice sélectionné.e",
+ "permissionReason": "Raison",
+ "usersRequired": "Cette extension nécessite un accès aux informations utilisateurs. Sélectionnez les utilisateur·rices autorisé.e.s ou sélectionnez 'Tout autoriser'.",
+ "allLibrariesHelp": "Quand sélectionnée, cette extension aura accès à l'ensemble des bibliothèques, y compris celles créées dans le futur.",
+ "noLibraries": "Aucune bibliothèque sélectionnée",
+ "librariesRequired": "Cette extension nécessite l'accès aux information de la bibliothèque. Sélectionnez à quelles bibliothèque cette extension a accès, ou sélectionnez 'Autoriser toutes les bibliothèques'.",
+ "requiredHosts": "Hôtes requis",
+ "configValidationError": "Erreur lors de la validation de la configuration",
+ "schemaRenderError": "Impossible de processer la configuration. Le schéma de l'extension n'est peut-être pas valide."
+ },
+ "placeholders": {
+ "configKey": "clef",
+ "configValue": "valeur"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Supprimer tous les fichiers manquants",
"remove_all_missing_content": "Êtes-vous sûr(e) de vouloir supprimer tous les fichiers manquants de la base de données ? Cette action est permanente et supprimera leurs nombres d'écoutes, leur notations et tout ce qui y fait référence.",
"noSimilarSongsFound": "Aucun titre similaire n'a été trouvé",
- "noTopSongsFound": "Aucun meilleur titre n'a été trouvé"
+ "noTopSongsFound": "Aucun meilleur titre n'a été trouvé",
+ "startingInstantMix": ""
},
"menu": {
"library": "Bibliothèque",
diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json
index a5f7ce0ce..32d0d919f 100644
--- a/resources/i18n/gl.json
+++ b/resources/i18n/gl.json
@@ -36,7 +36,8 @@
"bitDepth": "Calidade de Bit",
"sampleRate": "Taxa de mostra",
"missing": "Falta",
- "libraryName": "Biblioteca"
+ "libraryName": "Biblioteca",
+ "composer": "Composición"
},
"actions": {
"addToQueue": "Ao final da cola",
@@ -46,7 +47,8 @@
"download": "Descargar",
"playNext": "A continuación",
"info": "Obter info",
- "showInPlaylist": "Mostrar en Lista de reprodución"
+ "showInPlaylist": "Mostrar en Lista de reprodución",
+ "instantMix": "Mestura Súbita"
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Escaneo en progreso…",
"noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria"
}
+ },
+ "plugin": {
+ "name": "Complemento |||| Complementos",
+ "fields": {
+ "id": "ID",
+ "name": "Nome",
+ "description": "Descrición",
+ "version": "Versión",
+ "author": "Autoría",
+ "website": "Sitio web",
+ "permissions": "Permisos",
+ "enabled": "Activado",
+ "status": "Estado",
+ "path": "Ruta",
+ "lastError": "Erro",
+ "hasError": "Erro",
+ "updatedAt": "Actualizado",
+ "createdAt": "Instalado",
+ "configKey": "Clave",
+ "configValue": "Valor",
+ "allUsers": "Para todas as usuarias",
+ "selectedUsers": "Usuarias seleccionadas",
+ "allLibraries": "Permitir todas as bibliotecas",
+ "selectedLibraries": "Selecciona bibliotecas"
+ },
+ "sections": {
+ "status": "Estado",
+ "info": "Info do complemento",
+ "configuration": "Configuración",
+ "manifest": "Manifesto",
+ "usersPermission": "Permiso sobre usuarias",
+ "libraryPermission": "Permiso sobre bibliotecas"
+ },
+ "status": {
+ "enabled": "Activado",
+ "disabled": "Desactivado"
+ },
+ "actions": {
+ "enable": "Activar",
+ "disable": "Desactivar",
+ "disabledDueToError": "Arranxar erro antes de activar",
+ "disabledUsersRequired": "Selección de usuarias antes de activar",
+ "disabledLibrariesRequired": "Selección de bibliotecas antes de activar",
+ "addConfig": "Engadir configuración",
+ "rescan": "Volver a escanear"
+ },
+ "notifications": {
+ "enabled": "Complemento activado",
+ "disabled": "Complemento desactivado",
+ "updated": "Complemento actualizado",
+ "error": "Erro ao actualizar o complemento"
+ },
+ "validation": {
+ "invalidJson": "A configuración debe ser un JSON válido"
+ },
+ "messages": {
+ "configHelp": "Configura o complemento usando pares clave-valor. Deixa baleiro se o complemento non require configuración.",
+ "clickPermissions": "Preme nun permiso para ver detalles",
+ "noConfig": "Sen configuración establecida",
+ "allUsersHelp": "Ao activalo, o complemento terá acceso a todas as usuarias, incluíndo aquelas que se creen no futuro.",
+ "noUsers": "Sen usuarias seleccionadas",
+ "permissionReason": "Motivo",
+ "usersRequired": "O complemento precisa acceso á información sobre a usuaria. Selecciona as usuarias ás que pode acceder, ou activa 'Todas as usuarias'.",
+ "allLibrariesHelp": "Ao activalo, o complemento terá acceso a todas as bibliotecas, incluíndo aquelas que se creen no futuro.",
+ "noLibraries": "Sen bibliotecas seleccionadas",
+ "librariesRequired": "O complemento precisa acceso á información sobre a biblioteca. Selecciona as bibliotecas ás que pode acceder, ou activa 'Todas as bibliotecas'.",
+ "requiredHosts": "Servidores requeridos",
+ "configValidationError": "Fallou a comprobación da configuración:",
+ "schemaRenderError": "Non se puido aplicar a configuración. O esquema do complemento podería non ser válido."
+ },
+ "placeholders": {
+ "configKey": "clave",
+ "configValue": "valor"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Retirar todos os ficheiros que faltan",
"remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
"noSimilarSongsFound": "Sen cancións parecidas",
- "noTopSongsFound": "Sen cancións destacadas"
+ "noTopSongsFound": "Sen cancións destacadas",
+ "startingInstantMix": "Cargando Mestura Súbita…"
},
"menu": {
"library": "Biblioteca",
diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json
index 059d243cb..86793ee19 100644
--- a/resources/i18n/nl.json
+++ b/resources/i18n/nl.json
@@ -36,7 +36,8 @@
"bitDepth": "Bit diepte",
"sampleRate": "Sample waarde",
"missing": "Ontbrekend",
- "libraryName": "Bibliotheek"
+ "libraryName": "Bibliotheek",
+ "composer": ""
},
"actions": {
"addToQueue": "Voeg toe aan wachtrij",
@@ -46,7 +47,8 @@
"download": "Downloaden",
"playNext": "Volgende",
"info": "Meer info",
- "showInPlaylist": "Toon in afspeellijst"
+ "showInPlaylist": "Toon in afspeellijst",
+ "instantMix": ""
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Scan is bezig...",
"noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
}
+ },
+ "plugin": {
+ "name": "Plugin |||| Plugins",
+ "fields": {
+ "id": "ID",
+ "name": "Naam",
+ "description": "Omschrijving",
+ "version": "Versie",
+ "author": "Auteur",
+ "website": "Website",
+ "permissions": "Permissies",
+ "enabled": "Aangezet",
+ "status": "Status",
+ "path": "Pad",
+ "lastError": "Fout",
+ "hasError": "Fout",
+ "updatedAt": "Geupdate",
+ "createdAt": "Geinstalleerd",
+ "configKey": "Sleutel",
+ "configValue": "Waarde",
+ "allUsers": "Alle gebruikers toelaten",
+ "selectedUsers": "Geselecteerde gebruikers",
+ "allLibraries": "Alle bibliotheken toestaan",
+ "selectedLibraries": "Geselecteerde bibliotheken"
+ },
+ "sections": {
+ "status": "Status",
+ "info": "Plugin informatie",
+ "configuration": "Configuratie",
+ "manifest": "Manifest",
+ "usersPermission": "Gebruikers permissie",
+ "libraryPermission": "Bibliotheekpermissie"
+ },
+ "status": {
+ "enabled": "Aangezet",
+ "disabled": "Uitgezet"
+ },
+ "actions": {
+ "enable": "Aanzetten",
+ "disable": "Uitzetten",
+ "disabledDueToError": "Herstel de fout voor aanzetten",
+ "disabledUsersRequired": "Selecteer gebruikers voor aanzetten",
+ "disabledLibrariesRequired": "Selecteer bibliotheek voor aanzetten",
+ "addConfig": "Configuratie toevoegen",
+ "rescan": "Opnieuw scannen"
+ },
+ "notifications": {
+ "enabled": "Plugin actief",
+ "disabled": "Plugin niet actief",
+ "updated": "Plugin geupdate",
+ "error": "Fout bij updaten plugin"
+ },
+ "validation": {
+ "invalidJson": "Configuratie moet geldige JSON zijn"
+ },
+ "messages": {
+ "configHelp": "",
+ "clickPermissions": "Klik op permissie voor details",
+ "noConfig": "Geen configuratie ingesteld",
+ "allUsersHelp": "",
+ "noUsers": "Geen gebruikers geselecteerd",
+ "permissionReason": "Reden",
+ "usersRequired": "",
+ "allLibrariesHelp": "",
+ "noLibraries": "Geen bibliotheken geselecteerd",
+ "librariesRequired": "",
+ "requiredHosts": "Benodigde hosts",
+ "configValidationError": "",
+ "schemaRenderError": ""
+ },
+ "placeholders": {
+ "configKey": "Sleutel",
+ "configValue": "Waarde"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
"remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
"noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
- "noTopSongsFound": "Geen beste nummers gevonden"
+ "noTopSongsFound": "Geen beste nummers gevonden",
+ "startingInstantMix": ""
},
"menu": {
"library": "Bibliotheek",
diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json
index a9d6db88f..f5aac031c 100644
--- a/resources/i18n/pl.json
+++ b/resources/i18n/pl.json
@@ -36,7 +36,8 @@
"bitDepth": "Głębokość próbkowania",
"sampleRate": "Częstotliwość próbkowania",
"missing": "Brak",
- "libraryName": "Biblioteka"
+ "libraryName": "Biblioteka",
+ "composer": "Kompozytor"
},
"actions": {
"addToQueue": "Odtwarzaj Później",
@@ -46,7 +47,8 @@
"download": "Pobierz",
"playNext": "Odtwarzaj Następny",
"info": "Zdobądź Informacje",
- "showInPlaylist": "Pokaż w Liście Odtwarzania"
+ "showInPlaylist": "Pokaż w Liście Odtwarzania",
+ "instantMix": ""
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "Skanowanie w trakcie...",
"noLibrariesAssigned": "Brak bibliotek przypisanych do tego użytkownika"
}
+ },
+ "plugin": {
+ "name": "\nWtyczka |||| Wtyczki",
+ "fields": {
+ "id": "ID",
+ "name": "Nazwa",
+ "description": "Opis",
+ "version": "Wersja",
+ "author": "Autor",
+ "website": "Witryna",
+ "permissions": "Uprawnienia",
+ "enabled": "Aktywny",
+ "status": "Status",
+ "path": "Ścieżka",
+ "lastError": "Błąd",
+ "hasError": "Błąd",
+ "updatedAt": "Zaktualizowana",
+ "createdAt": "Zainstalowana",
+ "configKey": "Klucz",
+ "configValue": "Wartość",
+ "allUsers": "Zezwalaj wszystkim użytkownikom",
+ "selectedUsers": "Wybrani użytkownicy",
+ "allLibraries": "Zezwalaj dla wszystkich bibliotek",
+ "selectedLibraries": "Wybrane biblioteki"
+ },
+ "sections": {
+ "status": "Status",
+ "info": "Informacje O Wtyczce",
+ "configuration": "Konfiguracja",
+ "manifest": "Manifest",
+ "usersPermission": "Uprawnienia Użytkowników",
+ "libraryPermission": "Uprawnienia Biblioteki"
+ },
+ "status": {
+ "enabled": "Włączona",
+ "disabled": "Wyłączona"
+ },
+ "actions": {
+ "enable": "Włącz",
+ "disable": "Wyłącz",
+ "disabledDueToError": "Napraw błąd przed włączeniem",
+ "disabledUsersRequired": "Wybierz użytkowników przed włączeniem",
+ "disabledLibrariesRequired": "Wybierz biblioteki przed włączaniem",
+ "addConfig": "Dodaj Konfigurację",
+ "rescan": "Przeskanuj Ponownie"
+ },
+ "notifications": {
+ "enabled": "Wtyczka włączona",
+ "disabled": "Wtyczka wyłączona",
+ "updated": "Wtyczka zaktualizowana",
+ "error": "Błąd aktualizacji wtyczki"
+ },
+ "validation": {
+ "invalidJson": "Konfiguracja musić być w poprawnym formacie JSON"
+ },
+ "messages": {
+ "configHelp": "Użyj par klucz-wartość, aby skonfigurować wtyczkę. Pozostaw puste, jeśli wtyczka nie wymaga konfiguracji.",
+ "clickPermissions": "Kliknij uprawnienie, aby uzyskać szczegółowe informacje",
+ "noConfig": "Nie wybrano konfiguracji",
+ "allUsersHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich użytkowników, także tych utworzonych w przyszłości.",
+ "noUsers": "Nie wybrano użytkowników",
+ "permissionReason": "Powód",
+ "usersRequired": "Ta wtyczka wymaga dostępu do informacji o użytkowniku. Wybierz użytkowników, do których wtyczka ma mieć dostęp, lub włącz opcję „Zezwól wszystkim użytkownikom”.",
+ "allLibrariesHelp": "Po włączeniu wtyczka będzie miała dostęp do wszystkich bibliotek, także tych utworzonych w przyszłości.",
+ "noLibraries": "Nie wybrano biblioteki",
+ "librariesRequired": "Wtyczka wymaga dostępu do informacji o bibliotece. Wybierz, dla której biblioteki zezwolić dostęp, lub włącz 'Zezwalaj dla wszystkich bibliotek'.",
+ "requiredHosts": "Wymagane hosty",
+ "configValidationError": "Weryfikacja konfiguracji nie powiodła się:",
+ "schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy."
+ },
+ "placeholders": {
+ "configKey": "klucz",
+ "configValue": "wartość"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Usuń wszystkie brakujące pliki",
"remove_all_missing_content": "Czy chcesz usunąć wszystkie brakujące pliki z bazy danych? Spowoduje to trwałe usunięcie wszelkich odniesień do tych plików, takich jak liczba odtworzeń, czy oceny.",
"noSimilarSongsFound": "Brak podobnych utworów",
- "noTopSongsFound": "Brak najlepszych utworów"
+ "noTopSongsFound": "Brak najlepszych utworów",
+ "startingInstantMix": ""
},
"menu": {
"library": "Biblioteka",
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 2697de622..8975254fe 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -12,7 +12,6 @@
"artist": "Artista",
"album": "Álbum",
"path": "Arquivo",
- "libraryName": "Biblioteca",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
@@ -36,7 +35,9 @@
"rawTags": "Tags originais",
"bitDepth": "Profundidade de bits",
"sampleRate": "Taxa de amostragem",
- "missing": "Ausente"
+ "missing": "Ausente",
+ "libraryName": "Biblioteca",
+ "composer": "Compositor"
},
"actions": {
"addToQueue": "Adicionar à fila",
@@ -59,7 +60,6 @@
"songCount": "Músicas",
"playCount": "Execuções",
"name": "Nome",
- "libraryName": "Biblioteca",
"genre": "Gênero",
"compilation": "Coletânea",
"year": "Ano",
@@ -79,7 +79,8 @@
"media": "Mídia",
"mood": "Mood",
"date": "Data de Lançamento",
- "missing": "Ausente"
+ "missing": "Ausente",
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Tocar",
@@ -131,9 +132,9 @@
"maincredit": "Artista do Álbum ou Artista |||| Artistas do Álbum ou Artistas"
},
"actions": {
- "topSongs": "Mais tocadas",
"shuffle": "Aleatório",
- "radio": "Rádio"
+ "radio": "Rádio",
+ "topSongs": "Mais tocadas"
}
},
"user": {
@@ -162,14 +163,14 @@
"updated": "Usuário atualizado com sucesso",
"deleted": "Usuário deletado com sucesso"
},
- "validation": {
- "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores"
- },
"message": {
"listenBrainzToken": "Entre seu token do ListenBrainz",
"clickHereForToken": "Clique aqui para obter seu token",
"selectAllLibraries": "Selecionar todas as bibliotecas",
"adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas"
+ },
+ "validation": {
+ "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores"
}
},
"player": {
@@ -254,17 +255,15 @@
"updatedAt": "Últ. Atualização",
"createdAt": "Data de Criação",
"downloadable": "Permitir Baixar?"
- },
- "notifications": {},
- "actions": {}
+ }
},
"missing": {
"name": "Arquivo ausente |||| Arquivos ausentes",
"fields": {
"path": "Caminho",
"size": "Tamanho",
- "libraryName": "Biblioteca",
- "updatedAt": "Desaparecido em"
+ "updatedAt": "Desaparecido em",
+ "libraryName": "Biblioteca"
},
"actions": {
"remove": "Remover",
@@ -303,20 +302,20 @@
},
"actions": {
"scan": "Scanear Biblioteca",
- "quickScan": "Scan Rápido",
- "fullScan": "Scan Completo",
"manageUsers": "Gerenciar Acesso do Usuário",
- "viewDetails": "Ver Detalhes"
+ "viewDetails": "Ver Detalhes",
+ "quickScan": "Scan Rápido",
+ "fullScan": "Scan Completo"
},
"notifications": {
"created": "Biblioteca criada com sucesso",
"updated": "Biblioteca atualizada com sucesso",
"deleted": "Biblioteca excluída com sucesso",
"scanStarted": "Scan da biblioteca iniciada",
+ "scanCompleted": "Scan da biblioteca concluída",
"quickScanStarted": "Scan rápido iniciado",
"fullScanStarted": "Scan completo iniciado",
- "scanError": "Erro ao iniciar o scan. Verifique os logs",
- "scanCompleted": "Scan da biblioteca concluída"
+ "scanError": "Erro ao iniciar o scan. Verifique os logs"
},
"validation": {
"nameRequired": "Nome da biblioteca é obrigatório",
@@ -388,8 +387,6 @@
},
"messages": {
"configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.",
- "configValidationError": "Falha na validação da configuração:",
- "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido.",
"clickPermissions": "Clique em uma permissão para ver detalhes",
"noConfig": "Nenhuma configuração definida",
"allUsersHelp": "Quando habilitado, o plugin terá acesso a todos os usuários, incluindo os criados no futuro.",
@@ -399,7 +396,9 @@
"allLibrariesHelp": "Quando habilitado, o plugin terá acesso a todas as bibliotecas, incluindo as criadas no futuro.",
"noLibraries": "Nenhuma biblioteca selecionada",
"librariesRequired": "Este plugin requer acesso a informações de bibliotecas. Selecione quais bibliotecas o plugin pode acessar, ou habilite 'Permitir todas as bibliotecas'.",
- "requiredHosts": "Hosts necessários"
+ "requiredHosts": "Hosts necessários",
+ "configValidationError": "Falha na validação da configuração:",
+ "schemaRenderError": "Não foi possível renderizar o formulário de configuração. O schema do plugin pode estar inválido."
},
"placeholders": {
"configKey": "chave",
@@ -557,9 +556,6 @@
"transcodingDisabled": "Por questão de segurança, esta tela de configuração está desabilitada. Se você quiser alterar estas configurações, reinicie o servidor com a opção %{config}",
"transcodingEnabled": "Navidrome está sendo executado com a opção %{config}. Isto permite que potencialmente se execute comandos do sistema pela interface Web. É recomendado que vc mantenha esta opção desabilitada, e só a habilite quando precisar configurar opções de Conversão",
"songsAddedToPlaylist": "Música adicionada à playlist |||| %{smart_count} músicas adicionadas à playlist",
- "noSimilarSongsFound": "Nenhuma música semelhante encontrada",
- "startingInstantMix": "Carregando Mix Instantâneo...",
- "noTopSongsFound": "Nenhuma música mais tocada encontrada",
"noPlaylistsAvailable": "Nenhuma playlist",
"delete_user_title": "Excluir usuário '%{name}'",
"delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?",
@@ -589,16 +585,13 @@
"remove_missing_title": "Remover arquivos ausentes",
"remove_missing_content": "Você tem certeza que deseja remover os arquivos selecionados do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
"remove_all_missing_title": "Remover todos os arquivos ausentes",
- "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações."
+ "remove_all_missing_content": "Você tem certeza que deseja remover todos os arquivos ausentes do banco de dados? Isso removerá permanentemente qualquer referência a eles, incluindo suas contagens de reprodução e classificações.",
+ "noSimilarSongsFound": "Nenhuma música semelhante encontrada",
+ "noTopSongsFound": "Nenhuma música mais tocada encontrada",
+ "startingInstantMix": "Carregando Mix Instantâneo..."
},
"menu": {
"library": "Biblioteca",
- "librarySelector": {
- "allLibraries": "Todas as Bibliotecas (%{count})",
- "multipleLibraries": "%{selected} de %{total} Bibliotecas",
- "selectLibraries": "Selecionar Bibliotecas",
- "none": "Nenhuma"
- },
"settings": "Configurações",
"version": "Versão",
"theme": "Tema",
@@ -624,7 +617,13 @@
"albumList": "Álbuns",
"about": "Info",
"playlists": "Playlists",
- "sharedPlaylists": "Compartilhadas"
+ "sharedPlaylists": "Compartilhadas",
+ "librarySelector": {
+ "allLibraries": "Todas as Bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} Bibliotecas",
+ "selectLibraries": "Selecionar Bibliotecas",
+ "none": "Nenhuma"
+ }
},
"player": {
"playListsText": "Fila de Execução",
@@ -683,17 +682,12 @@
"totalScanned": "Total de pastas scaneadas",
"quickScan": "Rápido",
"fullScan": "Completo",
- "selectiveScan": "Seletivo",
"serverUptime": "Uptime do servidor",
"serverDown": "DESCONECTADO",
"scanType": "Último Scan",
"status": "Erro",
- "elapsedTime": "Duração"
- },
- "nowPlaying": {
- "title": "Tocando agora",
- "empty": "Nada tocando",
- "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás"
+ "elapsedTime": "Duração",
+ "selectiveScan": "Seletivo"
},
"help": {
"title": "Teclas de atalho",
@@ -708,5 +702,10 @@
"toggle_love": "Marcar/desmarcar favorita",
"current_song": "Vai para música atual"
}
+ },
+ "nowPlaying": {
+ "title": "Tocando agora",
+ "empty": "Nada tocando",
+ "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás"
}
-}
+}
\ No newline at end of file
diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json
index 2d7ffd249..5b20c3e19 100644
--- a/resources/i18n/ru.json
+++ b/resources/i18n/ru.json
@@ -36,7 +36,8 @@
"bitDepth": "Битовая глубина (Bit)",
"sampleRate": "Частота дискретизации (Hz)",
"missing": "Поле отсутствует",
- "libraryName": "Библиотека"
+ "libraryName": "Библиотека",
+ "composer": "Композитор"
},
"actions": {
"addToQueue": "В очередь",
@@ -46,7 +47,8 @@
"download": "Скачать",
"playNext": "Следующий",
"info": "Информация",
- "showInPlaylist": "Показать в плейлисте"
+ "showInPlaylist": "Показать в плейлисте",
+ "instantMix": "Быстрый микс"
}
},
"album": {
@@ -93,7 +95,7 @@
"lists": {
"all": "Все",
"random": "Случайные",
- "recentlyAdded": "Свежие",
+ "recentlyAdded": "Новые",
"recentlyPlayed": "Проигранные",
"mostPlayed": "Популярные",
"starred": "Избранные",
@@ -328,6 +330,80 @@
"scanInProgress": "Сканирование продолжается...",
"noLibrariesAssigned": "Нет библиотек, назначенных этому пользователю"
}
+ },
+ "plugin": {
+ "name": "Плагин |||| Плагины",
+ "fields": {
+ "id": "ID",
+ "name": "Имя",
+ "description": "Описание",
+ "version": "Версия",
+ "author": "Автор",
+ "website": "Вебсайт",
+ "permissions": "Разрешения",
+ "enabled": "Включено",
+ "status": "Статус",
+ "path": "Путь",
+ "lastError": "Ошибка",
+ "hasError": "Ошибка",
+ "updatedAt": "Обновлено",
+ "createdAt": "Установленный",
+ "configKey": "Ключ",
+ "configValue": "Значение",
+ "allUsers": "Разрешить всем пользователям",
+ "selectedUsers": "Выбранные пользователи",
+ "allLibraries": "Разрешить доступ ко всем библиотекам",
+ "selectedLibraries": "Избранные библиотеки"
+ },
+ "sections": {
+ "status": "Статус",
+ "info": "Информация о плагине",
+ "configuration": "Конфигурация",
+ "manifest": "Манифест",
+ "usersPermission": "Разрешение пользователей",
+ "libraryPermission": "Разрешение на использование библиотеки"
+ },
+ "status": {
+ "enabled": "Включено",
+ "disabled": "Отключить"
+ },
+ "actions": {
+ "enable": "Включить",
+ "disable": "Отключить",
+ "disabledDueToError": "Исправьте ошибку перед включением",
+ "disabledUsersRequired": "Выберите пользователей перед включением",
+ "disabledLibrariesRequired": "Выберите библиотеки перед включением",
+ "addConfig": "Добавить конфигурацию",
+ "rescan": "Повторное сканирование"
+ },
+ "notifications": {
+ "enabled": "Плагин включен",
+ "disabled": "Плагин отключен",
+ "updated": "Плагин обновлен",
+ "error": "Ошибка обновления плагина"
+ },
+ "validation": {
+ "invalidJson": "Конфигурация должна быть в формате JSON, допустимом для всех пользователей"
+ },
+ "messages": {
+ "configHelp": "Настройте плагин, используя пары ключ-значение. Оставьте поле пустым, если плагин не требует настройки.",
+ "clickPermissions": "Нажмите на разрешение для получения подробной информации",
+ "noConfig": "Конфигурация не задана",
+ "allUsersHelp": "При включении плагин получит доступ ко всем пользователям, включая тех, кто будет создан в будущем.",
+ "noUsers": "Не выбрано ни одного пользователя",
+ "permissionReason": "Причина",
+ "usersRequired": "Этому плагину требуется доступ к пользовательской информации. Выберите, к каким пользователям плагин может получить доступ, или включите \"Разрешить всем пользователям\".",
+ "allLibrariesHelp": "После включения плагин будет иметь доступ ко всем библиотекам, включая те, которые будут созданы в будущем.",
+ "noLibraries": "Библиотеки не выбраны",
+ "librariesRequired": "Этому плагину требуется доступ к библиотечной информации. Выберите, к каким библиотекам плагин может получить доступ, или включите \"Разрешить все библиотеки\".",
+ "requiredHosts": "Необходимые хосты",
+ "configValidationError": "Проверка конфигурации завершилась неудачей:",
+ "schemaRenderError": "Не удалось отобразить форму конфигурации. Возможно, схема плагина недействительна."
+ },
+ "placeholders": {
+ "configKey": "ключ",
+ "configValue": "значение"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "Удалите все отсутствующие файлы",
"remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.",
"noSimilarSongsFound": "Похожих треков не найдено",
- "noTopSongsFound": "Лучших треков не найдено"
+ "noTopSongsFound": "Лучших треков не найдено",
+ "startingInstantMix": "Загрузка быстрого микса"
},
"menu": {
"library": "Библиотека",
@@ -538,7 +615,7 @@
}
},
"albumList": "Альбомы",
- "about": "О нас",
+ "about": "О программе",
"playlists": "Плейлисты",
"sharedPlaylists": "Поделиться плейлистом",
"librarySelector": {
diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json
index 80bd8e4a3..f499d6ad5 100644
--- a/resources/i18n/sl.json
+++ b/resources/i18n/sl.json
@@ -36,7 +36,8 @@
"bitDepth": "Bitna globina",
"sampleRate": "Frekvenca vzorčenja",
"missing": "Manjka",
- "libraryName": "Knjižnica"
+ "libraryName": "Knjižnica",
+ "composer": "Skladatelj"
},
"actions": {
"addToQueue": "Predvajaj kasneje",
@@ -46,7 +47,8 @@
"download": "Naloži",
"playNext": "Naslednji",
"info": "Več informacij",
- "showInPlaylist": "Prikaži na seznamu predvajanja"
+ "showInPlaylist": "Prikaži na seznamu predvajanja",
+ "instantMix": ""
}
},
"album": {
@@ -301,14 +303,19 @@
"actions": {
"scan": "Skeniraj knjižnico",
"manageUsers": "Upravljanje dostopa uporabnikov",
- "viewDetails": "Ogled podrobnosti"
+ "viewDetails": "Ogled podrobnosti",
+ "quickScan": "Hitro skeniranje",
+ "fullScan": "Popolno skeniranje"
},
"notifications": {
"created": "Knjižnica je uspešno ustvarjena",
"updated": "Knjižnica je bila uspešno posodobljena",
"deleted": "Knjižnica je uspešno izbrisana",
"scanStarted": "Skeniranje knjižnice se je začelo",
- "scanCompleted": "Skeniranje knjižnice končano"
+ "scanCompleted": "Skeniranje knjižnice končano",
+ "quickScanStarted": "Hitro skeniranje se je začelo",
+ "fullScanStarted": "Popolno skeniranje se je začelo",
+ "scanError": "Napaka pri začetku skeniranja. Preverite dnevnike"
},
"validation": {
"nameRequired": "Ime knjižnice je obvezno",
@@ -323,6 +330,80 @@
"scanInProgress": "Skeniranje v teku...",
"noLibrariesAssigned": "Uporabnik nima dodeljenih knjižnic"
}
+ },
+ "plugin": {
+ "name": "Vtičnik |||| Vtičniki",
+ "fields": {
+ "id": "ID",
+ "name": "Ime",
+ "description": "Opis",
+ "version": "Verzija",
+ "author": "Avtor",
+ "website": "Spletna stran",
+ "permissions": "Dovoljenja",
+ "enabled": "Vključeno",
+ "status": "Status",
+ "path": "Pot",
+ "lastError": "Napaka",
+ "hasError": "Napaka",
+ "updatedAt": "Posodobljeno",
+ "createdAt": "Inštalirano",
+ "configKey": "Ključ",
+ "configValue": "Vrednost",
+ "allUsers": "Dovoli vsem uporabnikom",
+ "selectedUsers": "Izbrani uporabniki",
+ "allLibraries": "Dovoli vse knjižnice",
+ "selectedLibraries": "Izbrane knjižnice"
+ },
+ "sections": {
+ "status": "Status",
+ "info": "Informacije o vtičniku",
+ "configuration": "Konfiguracija",
+ "manifest": "Manifest",
+ "usersPermission": "Uporabniška dovoljenja",
+ "libraryPermission": "Knjižnična dovoljenja"
+ },
+ "status": {
+ "enabled": "Vključeno",
+ "disabled": "Izključeno"
+ },
+ "actions": {
+ "enable": "Vključi",
+ "disable": "Izključi",
+ "disabledDueToError": "Popravi napako pred vključitvijo",
+ "disabledUsersRequired": "Izberi uporabnike pred vključitvijo",
+ "disabledLibrariesRequired": "Izberi knjižnice pred vključitvijo",
+ "addConfig": "Dodaj konfiguracijo",
+ "rescan": "Ponovi skeniranje"
+ },
+ "notifications": {
+ "enabled": "Vtičnik vključen",
+ "disabled": "Vtičnik izključen",
+ "updated": "Vtičnik posodobljen",
+ "error": "Napaka pri posodobitvi vtičnika"
+ },
+ "validation": {
+ "invalidJson": "Konfiguracija mora biti pravilen JSON"
+ },
+ "messages": {
+ "configHelp": "Konfiguriraj vtičnik z uporabo key-value parov. Pusti prazno, če vtičnik ne potrebuje konfiguracije.",
+ "clickPermissions": "Klikni za dovoljenje o podrobnostih",
+ "noConfig": "Konfiguracija ni nastavljena",
+ "allUsersHelp": "Ko vključeno, bo vtičnik imel dostop do vseh uporabnikov, tudi prihodnjih.",
+ "noUsers": "Uporabniki niso izbrani",
+ "permissionReason": "Razlog",
+ "usersRequired": "Vtičnik potrebuje dostop do uporabnikovih informacij. Izberi uporabnike ali vključi dostop vsem uporabnikom.",
+ "allLibrariesHelp": "Ko vključeno, bo vtičnik imel dostop do vseh knjižnic, tudi prihodnjih.",
+ "noLibraries": "Ni izbranih knjižnic",
+ "librariesRequired": "Vtičnik zahteva dostop do knjižnih informacij. Izberi do katerih knjižnic lahko dostopa, ali vključi dostop do vseh knjižnic.",
+ "requiredHosts": "Zahtevani gostitelji",
+ "configValidationError": "",
+ "schemaRenderError": ""
+ },
+ "placeholders": {
+ "configKey": "ključ",
+ "configValue": "vrednost"
+ }
}
},
"ra": {
@@ -506,7 +587,8 @@
"remove_all_missing_title": "Odstrani vse manjkajoče datoteke",
"remove_all_missing_content": "Ste prepričani, da želite odstraniti vse manjkajoče datoteke iz baze? Trajno boste odstranili vse reference nanje, vključno s številom predvajanj in ocenami.",
"noSimilarSongsFound": "Ni najdenih podobnih pesmi",
- "noTopSongsFound": "Ni najdenih najboljših pesmi"
+ "noTopSongsFound": "Ni najdenih najboljših pesmi",
+ "startingInstantMix": ""
},
"menu": {
"library": "Knjižnica",
@@ -604,7 +686,8 @@
"serverDown": "NEPOVEZAN",
"scanType": "Tip",
"status": "Napaka pri skeniranju",
- "elapsedTime": "Pretečeni čas"
+ "elapsedTime": "Pretečeni čas",
+ "selectiveScan": "Selektivno"
},
"help": {
"title": "Hitre tipke",
diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json
index a93831079..5896b4ed9 100644
--- a/resources/i18n/sv.json
+++ b/resources/i18n/sv.json
@@ -10,7 +10,6 @@
"playCount": "Spelningar",
"title": "Titel",
"artist": "Artist",
- "composer": "Kompositör",
"album": "Album",
"path": "Sökväg",
"genre": "Genre",
@@ -37,7 +36,8 @@
"bitDepth": "Bitdjup",
"sampleRate": "Samplingsfrekvens",
"missing": "Saknade",
- "libraryName": "Bibliotek"
+ "libraryName": "Bibliotek",
+ "composer": "Kompositör"
},
"actions": {
"addToQueue": "Lägg till i kön",
@@ -47,7 +47,8 @@
"download": "Ladda ner",
"playNext": "Spela nästa",
"info": "Mer information",
- "showInPlaylist": "Visa i spellista"
+ "showInPlaylist": "Visa i spellista",
+ "instantMix": "Direktmix"
}
},
"album": {
@@ -329,6 +330,80 @@
"scanInProgress": "Scanning pågår...",
"noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren"
}
+ },
+ "plugin": {
+ "name": "Tillägg |||| Tillägg",
+ "fields": {
+ "id": "ID",
+ "name": "Namn",
+ "description": "Beskrivning",
+ "version": "Version",
+ "author": "Författare",
+ "website": "Website",
+ "permissions": "Behörigheter",
+ "enabled": "Aktiverad",
+ "status": "Status",
+ "path": "Sökväg",
+ "lastError": "Fel",
+ "hasError": "Fel",
+ "updatedAt": "Uppdaterad",
+ "createdAt": "Installerad",
+ "configKey": "Nyckel",
+ "configValue": "Värde",
+ "allUsers": "Tillåt alla användare",
+ "selectedUsers": "Valda användare",
+ "allLibraries": "Tillåt alla bibliotek",
+ "selectedLibraries": "Valda bibliotek"
+ },
+ "sections": {
+ "status": "Status",
+ "info": "Tilläggsinformation",
+ "configuration": "Konfiguration",
+ "manifest": "Manifest",
+ "usersPermission": "Användarbehörigheter",
+ "libraryPermission": "Biblioteksbehörigheter"
+ },
+ "status": {
+ "enabled": "Aktiverad",
+ "disabled": "Inaktiverad"
+ },
+ "actions": {
+ "enable": "Aktivera",
+ "disable": "Inaktivera",
+ "disabledDueToError": "Åtgärda felet innan aktivering",
+ "disabledUsersRequired": "Välj användare före aktivering",
+ "disabledLibrariesRequired": "Välj bibliotek före aktivering",
+ "addConfig": "Lägg till konfiguration",
+ "rescan": "Scanna om"
+ },
+ "notifications": {
+ "enabled": "Tillägg aktiverat",
+ "disabled": "Tillägg inaktiverat",
+ "updated": "Tillägg uppdaterat",
+ "error": "Fel vid uppdatering av tillägg"
+ },
+ "validation": {
+ "invalidJson": "Konfigurationen måste vara giltig JSON"
+ },
+ "messages": {
+ "configHelp": "Konfigurera tillägget med nyckel–värde-par. Lämna tomt om tillägget inte kräver någon konfiguration.",
+ "clickPermissions": "Klicka på en behörighet för mer information",
+ "noConfig": "Ingen konfiguration angiven",
+ "allUsersHelp": "När den är aktiverad får tillägget tillgång till alla användare, inklusive de som skapas i framtiden.",
+ "noUsers": "Inga användare valda",
+ "permissionReason": "Orsak",
+ "usersRequired": "Detta tillägg kräver åtkomst till användarinformation. Välj vilka användare insticksprogrammet ska ha åtkomst till, eller aktivera 'Tillåt alla användare'.",
+ "allLibrariesHelp": "När den är aktiverad får tillägget tillgång till alla bibliotek, inklusive de som skapas i framtiden.",
+ "noLibraries": "Inga bibliotek valda",
+ "librariesRequired": "Detta tillägg kräver tillgång till biblioteksinformation. Välj vilka bibliotek tillägget kan komma åt eller aktivera 'Tillåt alla bibliotek'.",
+ "requiredHosts": "Krävda värdar",
+ "configValidationError": "Validering av konfigurationen misslyckades:",
+ "schemaRenderError": "Kunde inte rendera konfigurationsformuläret. Tilläggets schema kan vara ogiltigt."
+ },
+ "placeholders": {
+ "configKey": "nyckel",
+ "configValue": "värde"
+ }
}
},
"ra": {
@@ -512,7 +587,8 @@
"remove_all_missing_title": "Ta bort alla saknade filer",
"remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.",
"noSimilarSongsFound": "Hittade inga liknande låtar",
- "noTopSongsFound": "Hittade inga topplåtar"
+ "noTopSongsFound": "Hittade inga topplåtar",
+ "startingInstantMix": "Laddar direktmix..."
},
"menu": {
"library": "Bibliotek",
@@ -545,7 +621,7 @@
"librarySelector": {
"allLibraries": "Alla bibliotek (%{count})",
"multipleLibraries": "%{selected} av %{total} bibliotek",
- "selectLibraries": "Valda bibliotek",
+ "selectLibraries": "Välj bibliotek",
"none": "Inga"
}
},
diff --git a/resources/i18n/th.json b/resources/i18n/th.json
index 833a68ab9..45a5e5f34 100644
--- a/resources/i18n/th.json
+++ b/resources/i18n/th.json
@@ -36,7 +36,8 @@
"bitDepth": "Bit depth",
"sampleRate": "แซมเปิ้ลเรต",
"missing": "หายไป",
- "libraryName": "ห้องสมุด"
+ "libraryName": "ห้องสมุด",
+ "composer": "ผู้แต่ง"
},
"actions": {
"addToQueue": "เพิ่มในคิว",
@@ -46,7 +47,8 @@
"download": "ดาวน์โหลด",
"playNext": "เล่นถัดไป",
"info": "ดูรายละเอียด",
- "showInPlaylist": "แสดงในเพลย์ลิสต์"
+ "showInPlaylist": "แสดงในเพลย์ลิสต์",
+ "instantMix": ""
}
},
"album": {
@@ -328,6 +330,80 @@
"scanInProgress": "กำลังสแกน...",
"noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
}
+ },
+ "plugin": {
+ "name": "ปลั๊กอิน |||| ปลั๊กอิน",
+ "fields": {
+ "id": "ID",
+ "name": "ชื่อ",
+ "description": "รายละเอียด",
+ "version": "เวอร์ชั่น",
+ "author": "ผู้สร้าง",
+ "website": "เว็บไซต์",
+ "permissions": "การอนุญาติ",
+ "enabled": "เปิดใช้",
+ "status": "สถานะ",
+ "path": "เส้นทาง",
+ "lastError": "ผิดพลาด",
+ "hasError": "ผิดพลาด",
+ "updatedAt": "อัพเดทแล้ว",
+ "createdAt": "ติดตั้งแล้ว",
+ "configKey": "คีย์",
+ "configValue": "ค่า",
+ "allUsers": "อนุญาติผู้ใช้ทั้งหมด",
+ "selectedUsers": "ผู้ใช้ถูกเลือก",
+ "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด",
+ "selectedLibraries": "ห้องสมุดเพลงถูกเลือก"
+ },
+ "sections": {
+ "status": "สถานะ",
+ "info": "ข้อมูลปลั๊กอิน",
+ "configuration": "การตั้งค่า",
+ "manifest": "แสดง",
+ "usersPermission": "สิทธิของผู้ใช้",
+ "libraryPermission": "สิทธิของห้องสมุดเพลง"
+ },
+ "status": {
+ "enabled": "เปิดใช้งานแล้ว",
+ "disabled": "ปิดใช้งานแล้ว"
+ },
+ "actions": {
+ "enable": "เปิดใช้งาน",
+ "disable": "ปิดใช้งาน",
+ "disabledDueToError": "แก้ไขข้อผิดพลาดก่อนเปิดใช้งาน",
+ "disabledUsersRequired": "เลือกผู้ใช้ที่จะเปิดใช้งาน",
+ "disabledLibrariesRequired": "เลือกห้องสมุดเพลงที่จะเปิดใช้งาน",
+ "addConfig": "เพิ่มการตั้งค่า",
+ "rescan": "สแกนซ้ำ"
+ },
+ "notifications": {
+ "enabled": "เปิดใช้ปลั๊กอินแล้ว",
+ "disabled": "ปิดใช้ปลั๊กอินแล้ว",
+ "updated": "ปลั๊กอินอัพเดท",
+ "error": "อัพเดทผิดพลาด"
+ },
+ "validation": {
+ "invalidJson": "ต้องตั้งค่าตามไวยากรณ์ JSON"
+ },
+ "messages": {
+ "configHelp": "ใส่ค่าให้เข้าคู่กับคีย์ของปลั๊กอิน ปล่อยว่างถ้าปลั๊กอินไม่ต้องการใช้",
+ "clickPermissions": "กดดูรายละเอียดของการอนุญาติ",
+ "noConfig": "ไม่ได้ตั้งค่า",
+ "allUsersHelp": "เมื่อเปิดใช้ ปลั๊กอินจะใช้กับผู้ใช้ทุกคน รวมถึงผู้ใช้ใหม่ในอนาคต",
+ "noUsers": "ไม่ได้เลือกผู้ใช้",
+ "permissionReason": "เหตุผล",
+ "usersRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลผู้ใช้ เลือกผู้ใช้ที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับผู้ใช้ทั้งหมด",
+ "allLibrariesHelp": "เมื่อเปิดใช้งาน ปลั๊กอินจะเข้าถึงทุกห้องสมุดเพลง รวมถึงของผู้ใช้ใหม่ในอนาคต",
+ "noLibraries": "ไม่มีห้องสมุดเพลงถูกเลือก",
+ "librariesRequired": "ปลั๊กอินนี้ต้องการเข้าถึงข้อมูลห้องสมุดเพลง เลือกห้องสมุดเพลงที่ต้องการให้ปลั๊กอินเข้าถึงหรือเปิดใช้งานกับห้องสมุดเพลงทั้งหมด",
+ "requiredHosts": "ต้องการ Host",
+ "configValidationError": "การตั้งค่าเกิดความผิดพลาด",
+ "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน"
+ },
+ "placeholders": {
+ "configKey": "คีย์",
+ "configValue": "ค่า"
+ }
}
},
"ra": {
@@ -511,7 +587,8 @@
"remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด",
"remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
"noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
- "noTopSongsFound": "ไม่พบเพลงยอดนิยม"
+ "noTopSongsFound": "ไม่พบเพลงยอดนิยม",
+ "startingInstantMix": ""
},
"menu": {
"library": "ห้องสมุดเพลง",