Merge branch 'master' into msi-property-fix

This commit is contained in:
Deluan Quintão 2026-09-12 09:27:27 -04:00 committed by GitHub
commit 119d5784db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1546 additions and 223 deletions

View File

@ -132,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
sessionKey, err := s.client.getSession(ctx, token)
if err != nil {
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
"requestId", middleware.GetReqID(ctx), err)
return err
}

View File

@ -2,9 +2,7 @@ package cmd
import (
"context"
"fmt"
"os"
"strings"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
@ -31,7 +29,7 @@ func init() {
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
backupRoot.AddCommand(pruneCmd)
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
@ -78,24 +76,12 @@ func runBackup(ctx context.Context) {
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
requireExistingDB()
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
@ -111,36 +97,17 @@ func runPrune(ctx context.Context) {
conf.Server.Backup.Count = backupCount
}
if conf.Server.Backup.Count == 0 && !force {
fmt.Println("Warning: pruning ALL backups")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Prune cancelled")
return
}
}
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
log.Warn("Prune cancelled")
return
}
requireExistingDB()
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
@ -149,36 +116,29 @@ func runPrune(ctx context.Context) {
}
func runRestore(ctx context.Context) {
idx := strings.LastIndex(conf.Server.DbPath, "?")
var path string
requireExistingDB()
if idx == -1 {
path = conf.Server.DbPath
} else {
path = conf.Server.DbPath[:idx]
}
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
return
}
if !force {
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
if input != "YES" || err != nil {
log.Warn("Restore cancelled")
// A relative --backup-file is resolved against Backup.Path, the same folder
// `backup create` writes to. Without this, the value was treated as relative
// to the working directory, where the file does not exist.
if !filepath.IsAbs(restorePath) {
backupPath, err := conf.Server.Backup.Path.Path()
if err != nil {
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
return
}
restorePath = filepath.Join(backupPath, restorePath)
}
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
log.Warn("Restore cancelled")
return
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
log.Fatal("Error restoring database", "backupFile", restorePath, err)
}
elapsed := time.Since(start)

99
cmd/doctor.go Normal file
View File

@ -0,0 +1,99 @@
package cmd
import (
"context"
"database/sql"
"fmt"
"io"
"os"
"github.com/navidrome/navidrome/db"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(doctorCmd)
}
var doctorCmd = &cobra.Command{
Use: "doctor",
Short: "Check your Navidrome installation for problems",
Long: "Run read-only health checks and report what was found. Checks the database for " +
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
"can fix what it finds. This command never alters your data",
Run: func(cmd *cobra.Command, _ []string) {
runDoctor(cmd.Context())
},
}
func runDoctor(ctx context.Context) {
requireExistingDB()
healthy := doctor(ctx, db.Db(), os.Stdout)
db.Close(ctx)
if !healthy {
os.Exit(1)
}
}
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
func printFindings(out io.Writer, check, noun string, items []string) {
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
for _, item := range items {
fmt.Fprintln(out, " "+item)
}
}
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
healthy := true
fmt.Fprintln(out, "Checking database integrity...")
issues, truncated, err := db.IntegrityCheck(ctx, database)
switch {
case err != nil:
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
fmt.Fprintln(out, recoveryAdvice)
return false
case len(issues) == 0:
fmt.Fprintln(out, "Integrity check passed.")
default:
healthy = false
printFindings(out, "Integrity check", "issue(s)", issues)
switch {
case truncated:
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
fmt.Fprintln(out, recoveryAdvice)
case db.IsFTSCorruptionOnly(issues):
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
default:
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
fmt.Fprintln(out, recoveryAdvice)
}
}
fmt.Fprintln(out, "Checking foreign keys...")
violations, err := db.ForeignKeyCheck(ctx, database)
switch {
case err != nil:
healthy = false
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
case len(violations) == 0:
fmt.Fprintln(out, "Foreign key check passed.")
default:
healthy = false
lines := make([]string, 0, len(violations))
for _, v := range violations {
lines = append(lines,
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
}
printFindings(out, "Foreign key check", "violation(s)", lines)
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
"in library data; the rest have to be removed by hand.")
}
if healthy {
fmt.Fprintln(out, "Database is healthy.")
}
return healthy
}

124
cmd/doctor_test.go Normal file
View File

@ -0,0 +1,124 @@
package cmd
import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"github.com/navidrome/navidrome/db"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("doctor", func() {
var (
ctx context.Context
dbPath string
database *sql.DB
out *strings.Builder
reopen func()
)
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
BeforeEach(func() {
ctx = context.Background()
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
reopen = func() {
var err error
database, err = sql.Open(db.Dialect, dbPath)
Expect(err).ToNot(HaveOccurred())
database.SetMaxOpenConns(1)
}
reopen()
DeferCleanup(func() { _ = database.Close() })
for _, stmt := range []string{
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
`create table library(id integer primary key)`,
`create table media_file(id integer primary key, library_id integer references library(id))`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
out = &strings.Builder{}
})
It("reports a healthy database", func() {
Expect(doctor(ctx, database, out)).To(BeTrue())
Expect(out.String()).To(ContainSubstring("Database is healthy."))
})
It("points to 'search rebuild' when corruption is limited to the search index", func() {
_, err := database.ExecContext(ctx,
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
Expect(err).ToNot(HaveOccurred())
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
})
It("points to a backup restore when corruption is not limited to the search index", func() {
_, err := database.ExecContext(ctx,
`insert into library(id)
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
select x from s`)
Expect(err).ToNot(HaveOccurred())
var rootPage, pageSize int64
Expect(database.QueryRowContext(ctx,
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
Expect(database.Close()).To(Succeed())
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
Expect(err).ToNot(HaveOccurred())
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
Expect(err).ToNot(HaveOccurred())
Expect(f.Close()).To(Succeed())
reopen()
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("backup restore"))
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
})
It("reports foreign key violations", func() {
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
Expect(err).ToNot(HaveOccurred())
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
Expect(err).ToNot(HaveOccurred())
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
Expect(out.String()).To(ContainSubstring("media_file"))
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
// GC never touches player, share or playqueue, so don't promise a full cleanup.
Expect(out.String()).To(ContainSubstring("removed by hand"))
})
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
It("does not blame the search index when the issue list is truncated", func() {
for _, stmt := range []string{
`create table t(a, b)`,
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
insert into t select x, x + 10000 from s`,
`create index media_file_fts_probe on t(a)`,
`pragma writable_schema=on`,
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
where name = 'media_file_fts_probe'`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
Expect(database.Close()).To(Succeed())
reopen()
Expect(doctor(ctx, database, out)).To(BeFalse())
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
Expect(out.String()).To(ContainSubstring("backup restore"))
})
})

55
cmd/search.go Normal file
View File

@ -0,0 +1,55 @@
package cmd
import (
"context"
"fmt"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/spf13/cobra"
)
var searchRebuildForce bool
func init() {
rootCmd.AddCommand(searchRoot)
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
searchRoot.AddCommand(searchRebuildCmd)
}
var (
searchRoot = &cobra.Command{
Use: "search",
Short: "Search index maintenance",
}
searchRebuildCmd = &cobra.Command{
Use: "rebuild",
Short: "Rebuild the full-text search index",
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
"library. This must be done offline",
Run: func(cmd *cobra.Command, _ []string) {
runSearchRebuild(cmd.Context())
},
}
)
func runSearchRebuild(ctx context.Context) {
requireExistingDB()
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
log.Warn("Rebuild cancelled")
return
}
fmt.Println("Rebuilding the search index...")
err := db.RebuildFTS(ctx, db.Db())
db.Close(ctx)
if err != nil {
log.Fatal("Error rebuilding the search index", err)
}
fmt.Println("Search index rebuilt successfully.")
}

View File

@ -5,8 +5,11 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
@ -15,6 +18,23 @@ import (
"github.com/navidrome/navidrome/persistence"
)
// requireExistingDB aborts the command when the database file (DbPath minus DSN
// params) does not exist.
func requireExistingDB() {
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path)
}
}
func confirmYES(warning string) bool {
fmt.Println(warning)
fmt.Printf("Please enter YES (all caps) to continue: ")
var input string
_, err := fmt.Scanln(&input)
return input == "YES" && err == nil
}
// newTabWriter keeps every CLI table on the same column settings.
func newTabWriter(out io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)

View File

@ -70,7 +70,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
insights := metrics.GetInstance(dataStore)
broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
@ -103,7 +103,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
@ -189,7 +189,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
return modelScanner
}
@ -200,7 +200,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
uploader := artwork.NewUploader(dataStore)
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@ -249,7 +249,7 @@ func getPluginManager() *plugins.Manager {
// wire_injectors.go:
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
func GetPluginManager(ctx context.Context) *plugins.Manager {
manager := getPluginManager()

View File

@ -42,7 +42,7 @@ var allProviders = wire.NewSet(
lastfm.NewRouter,
listenbrainz.NewRouter,
events.GetBroker,
scanner.New,
scanner.GetInstance,
scanner.GetWatcher,
metrics.GetPrometheusInstance,
db.Db,

View File

@ -407,7 +407,7 @@ func Load(noConfigDump bool) {
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
}
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
@ -329,6 +330,21 @@ var _ = Describe("Configuration", func() {
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
})
It("creates the log file readable only by the owner", func() {
if runtime.GOOS == "windows" {
Skip("file modes are not enforced on Windows")
}
logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log")
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("logfile", logFile)
DeferCleanup(log.SetOutput, os.Stderr)
conf.Load(true)
info, err := os.Stat(logFile)
Expect(err).ToNot(HaveOccurred())
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
})
It("is called when BaseURL is invalid", func() {
viper.SetDefault("datafolder", GinkgoT().TempDir())
viper.SetDefault("baseurl", "://invalid")

View File

@ -76,7 +76,7 @@ func toFastScaleType(img image.Image) image.Image {
}
func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, error) {
original, format, err := image.Decode(bytes.NewReader(data))
original, format, err := decodeCapped(data)
if err != nil {
return nil, 0, err
}

View File

@ -0,0 +1,13 @@
package artwork
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("resizeStaticImage", func() {
It("rejects images whose declared dimensions exceed the pixel cap before decoding", func() {
_, _, err := resizeStaticImage(pngHeaderWithDims(9000, 9000), 300, false)
Expect(err).To(MatchError(ContainSubstring("exceed pixel cap")))
})
})

View File

@ -2,6 +2,8 @@ package core
import (
"context"
"fmt"
"slices"
"strings"
"time"
@ -98,27 +100,19 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration))
}
firstId, _, _ := strings.Cut(s.ResourceIDs, ",")
v, err := model.GetEntityByID(r.ctx, r.ds, firstId)
s.ResourceType, err = r.resourceType(s.ResourceIDs)
if err != nil {
return "", err
}
switch v.(type) {
case *model.Artist:
s.ResourceType = "artist"
switch s.ResourceType {
case "artist":
s.Contents = r.contentsLabelFromArtist(s.ID, s.ResourceIDs)
case *model.Album:
s.ResourceType = "album"
case "album":
s.Contents = r.contentsLabelFromAlbums(s.ID, s.ResourceIDs)
case *model.Playlist:
s.ResourceType = "playlist"
case "playlist":
s.Contents = r.contentsLabelFromPlaylist(s.ID, s.ResourceIDs)
case *model.MediaFile:
s.ResourceType = "media_file"
case "media_file":
s.Contents = r.contentsLabelFromMediaFiles(s.ID, s.ResourceIDs)
default:
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
return "", model.ErrNotFound
}
s.Contents = str.TruncateRunes(s.Contents, 30, "...")
@ -126,6 +120,29 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) {
return r.Persistable.Save(s)
}
var shareableKinds = []model.Kind{model.KindArtistArtwork, model.KindAlbumArtwork, model.KindPlaylistArtwork, model.KindMediaFileArtwork}
// resourceType resolves every ID as the current user, so an entity they cannot see cannot
// ride along behind a valid first one, and requires all IDs to be of the same kind.
func (r *shareRepositoryWrapper) resourceType(resourceIDs string) (string, error) {
resourceType := ""
for _, id := range strings.Split(resourceIDs, ",") {
kind, err := model.GetEntityKindByID(r.ctx, r.ds, id)
if err != nil {
return "", err
}
if !slices.Contains(shareableKinds, kind) {
log.Error(r.ctx, "Invalid Resource ID", "id", id)
return "", model.ErrNotFound
}
if resourceType != "" && kind.String() != resourceType {
return "", fmt.Errorf("%w: share mixes %s and %s resources", model.ErrValidation, resourceType, kind)
}
resourceType = kind.String()
}
return resourceType, nil
}
func (r *shareRepositoryWrapper) Update(id string, entity any, _ ...string) error {
cols := []string{"description", "downloadable"}

View File

@ -70,6 +70,19 @@ var _ = Describe("Share", func() {
Expect(err).ToNot(HaveOccurred())
Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
})
It("fails when any of the resource IDs does not exist", func() {
entity := &model.Share{Description: "test", ResourceIDs: "123,missing"}
_, err := repo.Save(entity)
Expect(err).To(MatchError(model.ErrNotFound))
})
It("fails when the resource IDs are of mixed types", func() {
_ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
entity := &model.Share{Description: "test", ResourceIDs: "123,456"}
_, err := repo.Save(entity)
Expect(err).To(HaveOccurred())
})
})
Describe("Update", func() {

View File

@ -3,6 +3,7 @@ package local
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@ -18,22 +19,18 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
return nil, errors.New("watcher already started")
}
input := make(chan notify.EventInfo, 500)
output := make(chan string, 500)
libPath := filepath.Join(s.u.Path, "...")
log.Debug(ctx, "Starting watcher", "lib", libPath)
if err := notify.Watch(libPath, input, WatchEvents); err != nil {
s.watching.Store(false)
return nil, fmt.Errorf("starting watcher on %s: %w", libPath, err)
}
started := make(chan struct{})
output := make(chan string, 500)
go func() {
defer close(input)
defer close(output)
libPath := filepath.Join(s.u.Path, "...")
log.Debug(ctx, "Starting watcher", "lib", libPath)
err := notify.Watch(libPath, input, WatchEvents)
if err != nil {
log.Error("Error starting watcher", "lib", libPath, err)
return
}
defer notify.Stop(input)
close(started) // signals the main goroutine we have started
for {
select {
@ -49,9 +46,5 @@ func (s *localStorage) Start(ctx context.Context) (<-chan string, error) {
}
}
}()
select {
case <-started:
case <-ctx.Done():
}
return output, nil
}

View File

@ -137,3 +137,18 @@ type noopExtractor struct{}
func (s noopExtractor) Parse(files ...string) (map[string]metadata.Info, error) { return nil, nil }
func (s noopExtractor) Version() string { return "0" }
var _ = Describe("Watcher.Start", func() {
It("returns an error instead of hanging when the path cannot be watched", func() {
local.RegisterExtractor("noop", func(fs fs.FS, path string) local.Extractor { return noopExtractor{} })
conf.Server.Scanner.Extractor = "noop"
ls, err := storage.For(filepath.Join(GinkgoT().TempDir(), "does-not-exist"))
Expect(err).ToNot(HaveOccurred())
lsw := ls.(storage.Watcher)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = lsw.Start(ctx)
Expect(err).To(HaveOccurred())
})
})

View File

@ -9,6 +9,7 @@ import (
"path/filepath"
"regexp"
"slices"
"strings"
"time"
"github.com/mattn/go-sqlite3"
@ -18,7 +19,7 @@ import (
const (
backupPrefix = "navidrome_backup"
backupRegexString = backupPrefix + "_(.+)\\.db"
backupRegexString = "^" + backupPrefix + "_(.+)\\.db$"
)
var backupRegex = regexp.MustCompile(backupRegexString)
@ -40,6 +41,18 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
}
defer existingConn.Close()
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
// path would create an empty database and "restore" it over the live one.
if !isBackup {
// The driver splits the DSN at '?', so such a path would open a different file.
if strings.ContainsRune(path, '?') {
return fmt.Errorf("backup path cannot contain '?': %s", path)
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("backup file not available: %w", err)
}
}
backupDb, err := sql.Open(Driver, path)
if err != nil {
return fmt.Errorf("opening backup database in '%s': %w", path, err)

View File

@ -5,12 +5,14 @@ import (
"database/sql"
"math/rand"
"os"
"path/filepath"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
. "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils/singleton"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@ -103,6 +105,19 @@ var _ = Describe("database backups", func() {
Entry("delete all files", 0, 0),
Entry("preserve all files when at length", len(timesDecreasingChronologically), len(timesDecreasingChronologically)),
Entry("preserve all files when less than count", 10000, len(timesDecreasingChronologically)))
It("ignores SQLite sidecar files when counting backups", func() {
for _, suffix := range []string{"-shm", "-wal"} {
file, err := os.Create(BackupPath(timesDecreasingChronologically[0]) + suffix)
Expect(err).ToNot(HaveOccurred())
_ = file.Close()
}
conf.Server.Backup.Count = len(timesDecreasingChronologically)
pruneCount, err := Prune(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(pruneCount).To(BeZero())
})
})
Describe("backup and restore", Ordered, func() {
@ -148,4 +163,78 @@ var _ = Describe("database backups", func() {
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
})
})
Describe("backup and restore with a file-based database", Ordered, func() {
var ctx context.Context
var tempFolder string
var dbFilePath string
BeforeAll(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
var err error
tempFolder, err = os.MkdirTemp("", "navidrome_restore")
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Close(ctx)
_ = os.RemoveAll(tempFolder)
})
// Mimic the production DSN (consts.DefaultDbPath): a file database in WAL mode.
dbFilePath = filepath.Join(tempFolder, "navidrome.db")
conf.Server.DbPath = dbFilePath + "?_busy_timeout=15000&_journal_mode=WAL&_foreign_keys=on&synchronous=normal"
// The previous container's cleanup closed the shared *sql.DB without
// dropping the singleton; force a fresh connection for this container.
singleton.DeleteInstance[*sql.DB]()
DeferCleanup(Init(ctx))
})
It("restores data into a database whose stale WAL sidecar files were left behind", func() {
By("seeding user data in the current database")
_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
VALUES ('u-restore-1', 'drilladmin', 'drilladmin', 'drilladmin@example.com', 'x', 1, datetime('now'), datetime('now'))`)
Expect(err).ToNot(HaveOccurred())
By("creating a backup containing the user row")
path, err := Backup(ctx)
Expect(err).ToNot(HaveOccurred())
By("simulating the CLI exiting without closing the pool: sidecar files stay behind")
_, err = Db().ExecContext(ctx, "CREATE TABLE IF NOT EXISTS _restore_probe(x)")
Expect(err).ToNot(HaveOccurred())
singleton.DeleteInstance[*sql.DB]()
err = tests.ClearDB()
Expect(err).ToNot(HaveOccurred())
By("restoring the backup")
Expect(Restore(ctx, path)).To(Succeed())
By("verifying the restored data is readable through a fresh connection")
singleton.DeleteInstance[*sql.DB]()
var userName string
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-1'").Scan(&userName)).To(Succeed())
Expect(userName).To(Equal("drilladmin"))
})
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
By("seeding user data in the current database")
_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
Expect(err).ToNot(HaveOccurred())
By("attempting a restore from a nonexistent file")
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
err = Restore(ctx, missingPath)
Expect(err).To(HaveOccurred())
By("verifying the database was not wiped")
var userName string
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
Expect(userName).To(Equal("keepme"))
_, statErr := os.Stat(missingPath)
Expect(statErr).To(MatchError(os.ErrNotExist))
})
})
})

View File

@ -157,13 +157,24 @@ func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool {
return l.numPending > 0
}
// hasGooseTable reports whether goose's bookkeeping table exists, i.e. whether the
// database has ever been migrated.
func hasGooseTable(ctx context.Context, db *sql.DB) (bool, error) {
var name string
err := db.QueryRowContext(ctx,
"SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version'").Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func isSchemaEmpty(ctx context.Context, db *sql.DB) bool {
rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck
found, err := hasGooseTable(ctx, db)
if err != nil {
log.Fatal(ctx, "Database could not be opened!", err)
}
defer rows.Close()
return !rows.Next()
return !found
}
type logAdapter struct {

View File

@ -2,6 +2,10 @@ package db
// Definitions for testing private methods
var (
EmbedMigrations = embedMigrations
FTSTables = ftsTables
FTSTriggerSuffixes = ftsTriggerSuffixes
FTSSearchMigration = ftsSearchMigration
IsSchemaEmpty = isSchemaEmpty
BackupPath = backupPath
OptimizeDBAt = optimizeAt

View File

@ -3,7 +3,6 @@ package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/navidrome/navidrome/conf"
"github.com/pressly/goose/v3"
@ -28,10 +27,10 @@ func upAddLibraryTable(ctx context.Context, tx *sql.Tx) error {
return err
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
insert into library(id, name, path) values(1, 'Music Library', '%s');
delete from property where id like 'LastScan-%%';
`, conf.Server.MusicFolder))
_, err = tx.ExecContext(ctx, `
insert into library(id, name, path) values(1, 'Music Library', ?);
delete from property where id like 'LastScan-%';
`, conf.Server.MusicFolder)
if err != nil {
return err
}

367
db/repair.go Normal file
View File

@ -0,0 +1,367 @@
package db
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"strings"
)
var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"}
var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"}
// integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the
// pragma for one extra row, because it truncates without emitting any marker.
const integrityCheckMaxIssues = 100
// IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or
// an empty slice when healthy. The second value marks a list that was cut short.
func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) {
rows, err := database.QueryContext(ctx,
fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1))
if err != nil {
return nil, false, fmt.Errorf("running integrity_check: %w", err)
}
defer rows.Close()
var issues []string
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return nil, false, fmt.Errorf("reading integrity_check results: %w", err)
}
issues = append(issues, line)
}
if err := rows.Err(); err != nil {
return nil, false, fmt.Errorf("reading integrity_check results: %w", err)
}
if len(issues) == 1 && issues[0] == "ok" {
return nil, false, nil
}
if len(issues) > integrityCheckMaxIssues {
return issues[:integrityCheckMaxIssues], true, nil
}
return issues, false, nil
}
// FKViolation counts the rows in Table that reference missing rows in Parent.
type FKViolation struct {
Table string
Parent string
Count int64
}
// ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair
// because the raw pragma emits one row per orphan, unbounded on a large library.
func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) {
rows, err := database.QueryContext(ctx,
`SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`)
if err != nil {
return nil, fmt.Errorf("running foreign_key_check: %w", err)
}
defer rows.Close()
var violations []FKViolation
for rows.Next() {
var v FKViolation
if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil {
return nil, fmt.Errorf("reading foreign_key_check results: %w", err)
}
violations = append(violations, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("reading foreign_key_check results: %w", err)
}
return violations, nil
}
// IsFTSCorruptionOnly reports whether every integrity issue refers to one of the
// FTS5 search tables, meaning RebuildFTS can fully repair the database.
func IsFTSCorruptionOnly(issues []string) bool {
if len(issues) == 0 {
return false
}
for _, line := range issues {
if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) {
return false
}
}
return true
}
// execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs.
type execer interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}
// VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a
// full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database.
func VerifyFTS(ctx context.Context, database *sql.DB) error {
return verifyFTS(ctx, database)
}
func verifyFTS(ctx context.Context, database execer) error {
for _, table := range ftsTables {
stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list
if _, err := database.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("verifying %s: %w", table, err)
}
}
return nil
}
const ftsSearchMigration int64 = 20260220173400
var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first")
// requireFTSMigration fails unless the FTS search migration has run. The goose table
// is probed separately because a query against a missing table fails at prepare time.
func requireFTSMigration(ctx context.Context, database *sql.DB) error {
migrated, err := hasGooseTable(ctx, database)
if err != nil {
return fmt.Errorf("checking FTS migration status: %w", err)
}
if !migrated {
return errNotMigrated
}
var applied int
if err := database.QueryRowContext(ctx,
"SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil {
return fmt.Errorf("checking FTS migration status: %w", err)
}
if applied == 0 {
return errNotMigrated
}
return nil
}
// RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the
// base tables, and verifies the result before committing. The tables are contentless,
// so no user data is lost. It needs only the FTS migration, not a fully migrated
// schema, because a corrupted DB often cannot run pending migrations.
func RebuildFTS(ctx context.Context, database *sql.DB) error {
if err := requireFTSMigration(ctx, database); err != nil {
return err
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("starting FTS rebuild transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
var stmts []string
for _, table := range ftsTables {
for _, suffix := range ftsTriggerSuffixes {
stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix)
}
stmts = append(stmts, "DROP TABLE IF EXISTS "+table)
}
stmts = append(stmts, ftsSchemaDDL...)
for _, stmt := range stmts {
if _, err := tx.ExecContext(ctx, stmt); err != nil {
return fmt.Errorf("rebuilding FTS schema: %w", err)
}
}
if err := verifyFTS(ctx, tx); err != nil {
return fmt.Errorf("the rebuilt search index did not verify: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("committing FTS rebuild: %w", err)
}
return nil
}
// ftsSchemaDDL must reproduce what the full migration chain produces, not what any
// single migration does; the schema comparison in repair_test.go guards the drift.
var ftsSchemaDDL = []string{
`
CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5(
title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5(
name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5(
name, sort_artist_name, search_normalized,
content='', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2'
)
`,
`
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
SELECT rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''),
COALESCE(search_normalized, '')
FROM media_file
`,
`
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''),
COALESCE(search_participants, ''), COALESCE(discs, ''),
COALESCE(catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(album.tags, '$.albumversion') AS je), ''),
COALESCE(search_normalized, '')
FROM album
`,
`
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '')
FROM artist
`,
`
CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file
WHEN
OLD.title IS NOT NEW.title OR
OLD.album IS NOT NEW.album OR
OLD.artist IS NOT NEW.artist OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.sort_title IS NOT NEW.sort_title OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR
OLD.disc_subtitle IS NOT NEW.disc_subtitle OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist,
OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name,
COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO media_file_fts(rowid, title, album, artist, album_artist,
sort_title, sort_album_name, sort_artist_name, sort_album_artist_name,
disc_subtitle, search_participants, search_normalized)
VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist,
NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name,
COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER album_fts_au AFTER UPDATE ON album
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_album_name IS NOT NEW.sort_album_name OR
OLD.album_artist IS NOT NEW.album_artist OR
OLD.search_participants IS NOT NEW.search_participants OR
OLD.discs IS NOT NEW.discs OR
OLD.catalog_num IS NOT NEW.catalog_num OR
OLD.tags IS NOT NEW.tags OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''),
COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''),
COALESCE(OLD.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(OLD.tags, '$.albumversion') AS je), ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO album_fts(rowid, name, sort_album_name, album_artist,
search_participants, discs, catalog_num, album_version, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''),
COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''),
COALESCE(NEW.catalog_num, ''),
COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ')
FROM json_each(NEW.tags, '$.albumversion') AS je), ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
END
`,
`
CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist
WHEN
OLD.name IS NOT NEW.name OR
OLD.sort_artist_name IS NOT NEW.sort_artist_name OR
OLD.search_normalized IS NOT NEW.search_normalized
BEGIN
INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized)
VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''),
COALESCE(OLD.search_normalized, ''));
INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized)
VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''),
COALESCE(NEW.search_normalized, ''));
END
`,
}

309
db/repair_test.go Normal file
View File

@ -0,0 +1,309 @@
package db_test
import (
"context"
"database/sql"
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/navidrome/navidrome/db"
"github.com/pressly/goose/v3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// newDB returns an in-memory database migrated up to the given goose version
// (0 = fully migrated).
func newDB(ctx context.Context, upTo int64) *sql.DB {
GinkgoHelper()
d, err := sql.Open(db.Dialect, "file::memory:")
Expect(err).ToNot(HaveOccurred())
d.SetMaxOpenConns(1) // non-shared :memory:, a second conn would be an empty DB
DeferCleanup(func() { _ = d.Close() })
_, err = d.ExecContext(ctx, "PRAGMA foreign_keys=off")
Expect(err).ToNot(HaveOccurred())
goose.SetBaseFS(db.EmbedMigrations)
goose.SetLogger(goose.NopLogger())
DeferCleanup(func() { goose.SetBaseFS(nil) })
Expect(goose.SetDialect(db.Dialect)).To(Succeed())
if upTo == 0 {
Expect(goose.UpContext(ctx, d, "migrations")).To(Succeed())
} else {
Expect(goose.UpToContext(ctx, d, "migrations", upTo)).To(Succeed())
}
return d
}
// openMismatchedIndexDB builds a database whose index is declared over a different
// column than the one it was populated from, so integrity_check reports one issue per row.
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
GinkgoHelper()
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
open := func() *sql.DB {
d, err := sql.Open(db.Dialect, path)
Expect(err).ToNot(HaveOccurred())
d.SetMaxOpenConns(1)
return d
}
d := open()
for _, stmt := range []string{
`create table t(a, b)`,
fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d)
insert into t select x, x + 10000 from s`, rows),
`create index i on t(a)`,
`pragma writable_schema=on`,
`update sqlite_master set sql = 'CREATE INDEX i ON t(b)' where name = 'i'`,
} {
_, err := d.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema
d = open()
DeferCleanup(func() { _ = d.Close() })
return d
}
var _ = Describe("IsFTSCorruptionOnly", func() {
It("is true when every issue mentions an FTS search table", func() {
Expect(db.IsFTSCorruptionOnly([]string{
`fts5: corruption found reading blob 42 from table "media_file_fts"`,
`malformed inverted index for FTS5 table main.album_fts`,
`fts5: corruption in "artist_fts"`,
})).To(BeTrue())
})
It("is false when any issue is outside the FTS search tables", func() {
Expect(db.IsFTSCorruptionOnly([]string{
`fts5: corruption found reading blob 42 from table "media_file_fts"`,
`*** in database main ***`,
})).To(BeFalse())
})
It("is false when there are no issues", func() {
Expect(db.IsFTSCorruptionOnly(nil)).To(BeFalse())
})
})
var _ = Describe("RebuildFTS schema guard", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
})
It("refuses to run on a schema older than the FTS migration", func() {
old := newDB(ctx, db.FTSSearchMigration-1)
err := db.RebuildFTS(ctx, old)
Expect(err).To(MatchError(ContainSubstring("migration")))
})
It("refuses to run on a database that was never migrated", func() {
empty, err := sql.Open(db.Dialect, "file::memory:")
Expect(err).ToNot(HaveOccurred())
empty.SetMaxOpenConns(1)
DeferCleanup(func() { _ = empty.Close() })
Expect(db.RebuildFTS(ctx, empty)).To(MatchError(ContainSubstring("start Navidrome once")))
})
It("runs on a post-FTS schema even when newer migrations are pending", func() {
behind := newDB(ctx, 20260702152457)
Expect(db.RebuildFTS(ctx, behind)).To(Succeed())
})
})
var _ = Describe("Repair", func() {
var (
ctx context.Context
database *sql.DB
)
BeforeEach(func() {
ctx = context.Background()
database = newDB(ctx, 0)
for _, stmt := range []string{
`insert into artist(id, name, search_normalized) values ('ar-1', 'Ramones', 'ramones')`,
`insert into album(id, name, search_normalized) values ('al-1', 'Rocket to Russia', 'rocket to russia')`,
`insert into media_file(id, title, search_normalized) values ('mf-1', 'Teenage Lobotomy', 'teenage lobotomy')`,
`insert into media_file(id, title, search_normalized) values ('mf-2', 'Rockaway Beach', 'rockaway beach')`,
} {
_, err := database.ExecContext(ctx, stmt)
Expect(err).ToNot(HaveOccurred())
}
})
corruptFTS := func(table string) {
// 8+ bytes of garbage: a 4-byte blob still parses as a valid empty structure record
_, err := database.ExecContext(ctx, `update `+table+`_data set block = x'deadbeefdeadbeef' where id > 1`) //nolint:gosec
Expect(err).ToNot(HaveOccurred())
}
searchFTS := func(table, term string) int {
var count int
err := database.QueryRowContext(ctx, `select count(*) from `+table+` where `+table+` match ?`, term).Scan(&count)
Expect(err).ToNot(HaveOccurred())
return count
}
// ftsSchema returns name -> whitespace-normalized DDL for the FTS tables,
// their shadow tables, and their triggers.
ftsSchema := func() map[string]string {
rows, err := database.QueryContext(ctx,
`select name, sql from sqlite_master where name like '%_fts%' and sql is not null`)
Expect(err).ToNot(HaveOccurred())
defer rows.Close()
ws := regexp.MustCompile(`\s+`)
schema := map[string]string{}
for rows.Next() {
var name, ddl string
Expect(rows.Scan(&name, &ddl)).To(Succeed())
schema[name] = ws.ReplaceAllString(ddl, " ")
}
Expect(rows.Err()).ToNot(HaveOccurred())
return schema
}
Describe("IntegrityCheck", func() {
It("returns no issues for a healthy database", func() {
issues, truncated, err := db.IntegrityCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(BeEmpty())
Expect(truncated).To(BeFalse())
})
It("reports corruption in an FTS index", func() {
corruptFTS("media_file_fts")
issues, truncated, err := db.IntegrityCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(issues).ToNot(BeEmpty())
Expect(strings.Join(issues, "\n")).To(ContainSubstring("media_file_fts"))
Expect(truncated).To(BeFalse())
})
It("flags the issue list as truncated when there are more issues than the limit", func() {
broken := openMismatchedIndexDB(ctx, 300)
issues, truncated, err := db.IntegrityCheck(ctx, broken)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(HaveLen(100))
Expect(truncated).To(BeTrue())
})
It("does not flag truncation when the issues exactly fill the limit", func() {
broken := openMismatchedIndexDB(ctx, 100)
issues, truncated, err := db.IntegrityCheck(ctx, broken)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(HaveLen(100))
Expect(truncated).To(BeFalse())
})
})
Describe("ForeignKeyCheck", func() {
It("returns no violations for a healthy database", func() {
violations, err := db.ForeignKeyCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(violations).To(BeEmpty())
})
It("reports rows referencing missing parents", func() {
_, err := database.ExecContext(ctx,
`insert into media_file(id, title, library_id) values ('mf-bad', 'Orphan', 999)`)
Expect(err).ToNot(HaveOccurred())
violations, err := db.ForeignKeyCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(violations).To(HaveLen(1))
Expect(violations[0].Table).To(Equal("media_file"))
Expect(violations[0].Parent).To(Equal("library"))
Expect(violations[0].Count).To(BeNumerically("==", 1))
})
})
Describe("VerifyFTS", func() {
It("passes on a healthy index", func() {
Expect(db.VerifyFTS(ctx, database)).To(Succeed())
})
It("fails on a corrupted index, naming the table", func() {
corruptFTS("album_fts")
Expect(db.VerifyFTS(ctx, database)).To(MatchError(ContainSubstring("album_fts")))
})
})
Describe("RebuildFTS", func() {
It("repairs a corrupted FTS index", func() {
corruptFTS("media_file_fts")
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
issues, _, err := db.IntegrityCheck(ctx, database)
Expect(err).ToNot(HaveOccurred())
Expect(issues).To(BeEmpty())
Expect(db.VerifyFTS(ctx, database)).To(Succeed())
Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1))
Expect(searchFTS("album_fts", "russia")).To(Equal(1))
Expect(searchFTS("artist_fts", "ramones")).To(Equal(1))
})
It("recreates tables and triggers dropped by hand", func() {
for _, table := range db.FTSTables {
for _, suffix := range db.FTSTriggerSuffixes {
_, err := database.ExecContext(ctx, "drop trigger "+table+suffix)
Expect(err).ToNot(HaveOccurred())
}
_, err := database.ExecContext(ctx, "drop table "+table)
Expect(err).ToNot(HaveOccurred())
}
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
Expect(searchFTS("media_file_fts", "rockaway")).To(Equal(1))
})
It("rolls back and keeps the old index when the rebuild fails", func() {
// Triggers go first: SQLite refuses to drop a column they reference.
for _, suffix := range db.FTSTriggerSuffixes {
_, err := database.ExecContext(ctx, "drop trigger media_file_fts"+suffix)
Expect(err).ToNot(HaveOccurred())
}
_, err := database.ExecContext(ctx, `alter table media_file drop column disc_subtitle`)
Expect(err).ToNot(HaveOccurred())
Expect(db.RebuildFTS(ctx, database)).ToNot(Succeed())
Expect(searchFTS("media_file_fts", "lobotomy")).To(Equal(1))
Expect(searchFTS("album_fts", "russia")).To(Equal(1))
})
It("produces the same schema as the migration", func() {
migrated := ftsSchema()
Expect(migrated).ToNot(BeEmpty())
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
Expect(ftsSchema()).To(Equal(migrated))
})
It("leaves working triggers behind", func() {
Expect(db.RebuildFTS(ctx, database)).To(Succeed())
_, err := database.ExecContext(ctx,
`insert into artist(id, name, search_normalized) values ('ar-2', 'Blondie', 'blondie')`)
Expect(err).ToNot(HaveOccurred())
Expect(searchFTS("artist_fts", "blondie")).To(Equal(1))
_, err = database.ExecContext(ctx, `delete from artist where id = 'ar-2'`)
Expect(err).ToNot(HaveOccurred())
Expect(searchFTS("artist_fts", "blondie")).To(BeZero())
})
})
})

View File

@ -32,9 +32,6 @@ type Share struct {
func (s Share) CoverArtID() ArtworkID {
ids := strings.SplitN(s.ResourceIDs, ",", 2)
if len(ids) == 0 {
return ArtworkID{}
}
switch s.ResourceType {
case "album":
return Album{ID: ids[0]}.CoverArtID()
@ -43,6 +40,10 @@ func (s Share) CoverArtID() ArtworkID {
case "artist":
return Artist{ID: ids[0]}.CoverArtID()
}
// Tracks can be empty when they went missing or the owner lost access to their library.
if len(s.Tracks) == 0 {
return ArtworkID{}
}
rnd := random.Int64N(len(s.Tracks))
return s.Tracks[rnd].CoverArtID()
}

19
model/share_test.go Normal file
View File

@ -0,0 +1,19 @@
package model_test
import (
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Share.CoverArtID", func() {
It("returns an empty artwork ID for a media file share with no visible tracks", func() {
s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1"}
Expect(s.CoverArtID()).To(Equal(model.ArtworkID{}))
})
It("picks a track's cover for a media file share", func() {
s := model.Share{ResourceType: "media_file", ResourceIDs: "mf-1", Tracks: model.MediaFiles{{ID: "mf-1"}}}
Expect(s.CoverArtID()).To(Equal(model.MediaFile{ID: "mf-1"}.CoverArtID()))
})
})

View File

@ -72,7 +72,6 @@ func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, e
}
func (r *shareRepository) loadMedia(share *model.Share) error {
var err error
ids := strings.Split(share.ResourceIDs, ",")
if len(ids) == 0 {
return nil
@ -80,15 +79,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
noMissing := func(cond Sqlizer) Sqlizer {
return And{cond, Eq{"missing": false}}
}
// Load as the share owner so their library access is applied, whoever renders the share.
ctx, err := r.ownerContext(share)
if err != nil {
return err
}
switch share.ResourceType {
case "artist":
// Match by album-artist participation, not the deprecated album_artist_id
// column (first album artist only), so co-album-artists are included too.
// Load as the share owner so their library access is applied.
ctx, err := r.ownerContext(share)
if err != nil {
return err
}
albumRepo := NewAlbumRepository(ctx, r.db)
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("album", ids, model.RoleAlbumArtist)), Sort: "artist"})
if err != nil {
@ -98,20 +97,15 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(ParticipantIDFilter("media_file", ids, model.RoleAlbumArtist)), Sort: "artist"})
return err
case "album":
albumRepo := NewAlbumRepository(r.ctx, r.db)
albumRepo := NewAlbumRepository(ctx, r.db)
share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})})
if err != nil {
return err
}
mfRepo := NewMediaFileRepository(r.ctx, r.db)
mfRepo := NewMediaFileRepository(ctx, r.db)
share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"})
return err
case "playlist":
// Load tracks as the share owner so their library access is applied.
ctx, err := r.ownerContext(share)
if err != nil {
return err
}
plsRepo := NewPlaylistRepository(ctx, r.db)
// Tracks returns nil when the playlist is no longer visible to the owner
// (e.g. it was made private after the share was created); leave the share
@ -127,7 +121,7 @@ func (r *shareRepository) loadMedia(share *model.Share) error {
share.Tracks = tracks.MediaFiles()
return nil
case "media_file":
mfRepo := NewMediaFileRepository(r.ctx, r.db)
mfRepo := NewMediaFileRepository(ctx, r.db)
tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})})
share.Tracks = sortByIdPosition(tracks, ids)
return err

View File

@ -228,7 +228,7 @@ var _ = Describe("ShareRepository", func() {
})
})
Describe("Artist share library scoping", func() {
Describe("Artist, album and media file share library scoping", func() {
var otherLib model.Library
var owner model.User
const primaryID = "share-aa-primary"
@ -267,20 +267,26 @@ var _ = Describe("ShareRepository", func() {
Expect(ur.Put(&owner)).To(Succeed())
Expect(ur.SetUserLibraries(owner.ID, []int{1})).To(Succeed())
_, err := b.NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": "art-share", "user": owner.ID, "desc": "Artist scope share",
"type": "artist", "ids": secondaryID, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
for _, s := range []struct{ id, typ, ids string }{
{"art-share", "artist", secondaryID},
{"art-album-share", "album", "art-album-ok,art-album-other"},
{"art-mf-share", "media_file", "art-ok,art-other"},
} {
_, err := b.NewQuery(`
INSERT INTO share (id, user_id, description, resource_type, resource_ids, created_at, updated_at)
VALUES ({:id}, {:user}, {:desc}, {:type}, {:ids}, {:created}, {:updated})
`).Bind(map[string]any{
"id": s.id, "user": owner.ID, "desc": "Scope share",
"type": s.typ, "ids": s.ids, "created": time.Now(), "updated": time.Now(),
}).Execute()
Expect(err).ToNot(HaveOccurred())
}
})
AfterEach(func() {
adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser)
b := GetDBXBuilder()
_, _ = b.NewQuery(`DELETE FROM share WHERE id = 'art-share'`).Execute()
_, _ = b.NewQuery(`DELETE FROM share WHERE id IN ('art-share', 'art-album-share', 'art-mf-share')`).Execute()
mr := NewMediaFileRepository(adminCtx, b).(*mediaFileRepository)
_, _ = mr.executeSQL(squirrel.Delete("media_file").Where(squirrel.Eq{"id": []string{"art-ok", "art-other"}}))
alr := NewAlbumRepository(adminCtx, b).(*albumRepository)
@ -309,6 +315,23 @@ var _ = Describe("ShareRepository", func() {
Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")),
"an album outside the owner's libraries must not appear in the share")
})
It("excludes albums and their tracks outside the owner's libraries from an album share", func() {
// Public share rendering has no user in the context.
share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-album-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Albums).To(ContainElement(HaveField("ID", "art-album-ok")))
Expect(share.Albums).ToNot(ContainElement(HaveField("ID", "art-album-other")))
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")))
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")))
})
It("excludes tracks outside the owner's libraries from a media file share", func() {
share, err := NewShareRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()).Get("art-mf-share")
Expect(err).ToNot(HaveOccurred())
Expect(share.Tracks).To(ContainElement(HaveField("ID", "art-ok")))
Expect(share.Tracks).ToNot(ContainElement(HaveField("ID", "art-other")))
})
})
Describe("Ownership Checks", func() {

View File

@ -93,7 +93,8 @@
"addToPlaylist": "Zu einer Wiedergabeliste hinzufügen",
"download": "Herunterladen",
"info": "Mehr Informationen",
"share": "Freigabe erstellen"
"share": "Freigabe erstellen",
"refresh": ""
},
"lists": {
"all": "Alle",
@ -155,11 +156,13 @@
"newPassword": "Neues Passwort",
"token": "Token",
"lastAccessAt": "Letzter Zugriff am",
"libraries": "Bibliotheken"
"libraries": "Bibliotheken",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "Die Änderung wird erst nach dem nächsten Login gültig",
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken"
"libraries": "Wähle spezifische Bibliotheken für diesen Benutzer, oder leer lassen für Standard Bibliotheken",
"scrobbleFilter": ""
},
"notifications": {
"created": "Benutzer erstellt",
@ -173,7 +176,8 @@
"adminAutoLibraries": "Administrator-Benutzer haben automatisch Zugriff auf alle Bibliotheken"
},
"validation": {
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein"
"librariesRequired": "Mindestens eine Bibliothek muss für nicht-administrator Benutzer ausgewählt sein",
"invalidScrobbleFilter": ""
}
},
"player": {
@ -196,6 +200,9 @@
"targetFormat": "Zielformat",
"defaultBitRate": "Standardbitrate",
"command": "Befehl"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -210,7 +217,8 @@
"songCount": "Titelanzahl",
"comment": "Kommentar",
"sync": "Auto-Import",
"path": "Importieren aus"
"path": "Importieren aus",
"starred": "Favorit"
},
"actions": {
"selectPlaylist": "Wiedergabeliste auswählen:",
@ -403,7 +411,8 @@
"requiredHosts": "Benötigte Hosts",
"configValidationError": "Validierung der Konfiguration fehlgeschlagen:",
"schemaRenderError": "Rendern der Konfiguration fehlgeschlagen. Das Schema das Plugins ist eventuell nicht korrekt.",
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff."
"allowWriteAccessHelp": "Wenn aktiviert, kann das Plugin Dateien in den Bibliotheken verändern. Als Standard haben Plugins nur Lesezugriff.",
"idHelp": ""
},
"placeholders": {
"configKey": "Schlüssel",
@ -599,7 +608,8 @@
"coverUploaded": "Cover aktualisiert",
"coverRemoved": "Cover entfernt",
"coverUploadError": "Fehler beim Hochladen des Covers",
"coverRemoveError": "Fehler beim Entfernen des Covers"
"coverRemoveError": "Fehler beim Entfernen des Covers",
"metadataRefreshStarted": ""
},
"menu": {
"library": "Bibliothek",
@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} von %{total} Bibliotheken",
"selectLibraries": "Bibliotheken auswählen",
"none": "Keine"
}
},
"onlyFavourites": "Nur Favoriten anzeigen"
},
"player": {
"playListsText": "Warteschlange abspielen",

View File

@ -38,7 +38,9 @@
"missing": "Απών",
"libraryName": "Βιβλιοθήκη",
"composer": "Συνθέτης",
"disc": "Δίσκος %{discNumber}"
"disc": "Δίσκος %{discNumber}",
"albumGain": "Κέρδος άλμπουμ",
"trackGain": "Κέρδος παρακολούθησης"
},
"actions": {
"addToQueue": "Αναπαραγωγη Μετα",
@ -91,7 +93,8 @@
"addToPlaylist": "Προσθηκη στη λιστα αναπαραγωγης",
"download": "Ληψη",
"info": "Εμφάνιση Πληροφοριών",
"share": "Μερίδιο"
"share": "Μερίδιο",
"refresh": "Ανανέωση μεταδεδομένων"
},
"lists": {
"all": "Όλα",
@ -153,11 +156,13 @@
"newPassword": "Νέος Κωδικός Πρόσβασης",
"token": "Token",
"lastAccessAt": "Τελευταία Πρόσβαση",
"libraries": "Βιβλιοθήκες"
"libraries": "Βιβλιοθήκες",
"scrobbleFilter": "Φίλτρο Scrobble"
},
"helperTexts": {
"name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση",
"libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη"
"libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη",
"scrobbleFilter": "Τα τραγούδια που αντιστοιχούν σε αυτούς τους κανόνες έξυπνης λίστας αναπαραγωγής δεν αποστέλλονται στα πρόσθετα Last.fm, ListenBrainz ή scrobbler. Χρησιμοποιεί την ίδια σύνταξη και συμπεριφορά JSON με τις έξυπνες λίστες αναπαραγωγής. Παράδειγμα: {\"all\":[{\"lt\":{\"rating\":4}}]}."
},
"notifications": {
"created": "Ο χρήστης δημιουργήθηκε",
@ -171,7 +176,8 @@
"adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες"
},
"validation": {
"librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές"
"librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές",
"invalidScrobbleFilter": "Πρέπει να υπάρχουν έγκυροι κανόνες έξυπνης λίστας αναπαραγωγής. Δεν υποστηρίζονται το όριο, η μετατόπιση και η καθυστέρηση ανανέωσης."
}
},
"player": {
@ -194,6 +200,9 @@
"targetFormat": "Μορφη Προορισμου",
"defaultBitRate": "Προκαθορισμένος Ρυθμός Bit",
"command": "Εντολή"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -208,7 +217,8 @@
"songCount": "Τραγούδια",
"comment": "Σχόλιο",
"sync": "Αυτόματη εισαγωγή",
"path": "Εισαγωγή από"
"path": "Εισαγωγή από",
"starred": "Ευνοούμενος"
},
"actions": {
"selectPlaylist": "Επιλέξτε μια λίστα αναπαραγωγής:",
@ -401,7 +411,8 @@
"requiredHosts": "Απαιτούμενοι hosts",
"configValidationError": "Η επικύρωση διαμόρφωσης απέτυχε:",
"schemaRenderError": "Δεν είναι δυνατή η απόδοση της φόρμας διαμόρφωσης. Το σχήμα της προσθήκης ενδέχεται να μην είναι έγκυρο.",
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση."
"allowWriteAccessHelp": "Όταν είναι ενεργοποιημένο, το πρόσθετο μπορεί να τροποποιήσει αρχεία στους καταλόγους της βιβλιοθήκης. Από προεπιλογή, τα πρόσθετα έχουν πρόσβαση μόνο για ανάγνωση.",
"idHelp": ""
},
"placeholders": {
"configKey": "κλειδί",
@ -597,7 +608,8 @@
"coverUploaded": "Το εξώφυλλο ενημερώθηκε",
"coverRemoved": "Το εξώφυλλο αφαιρέθηκε",
"coverUploadError": "Σφάλμα κατά τη μεταφόρτωση του εξωφύλλου",
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου"
"coverRemoveError": "Σφάλμα κατά την αφαίρεση του εξωφύλλου",
"metadataRefreshStarted": "Ανανέωση μεταδεδομένων στο παρασκήνιο"
},
"menu": {
"library": "Βιβλιοθήκη",
@ -632,7 +644,8 @@
"multipleLibraries": "%{selected} από %{total} Βιβλιοθήκες",
"selectLibraries": "Επιλέξτε βιβλιοθήκες",
"none": "Κανένα"
}
},
"onlyFavourites": "Εμφάνιση μόνο αγαπημένων"
},
"player": {
"playListsText": "Ουρά Αναπαραγωγής",

View File

@ -93,7 +93,8 @@
"addToPlaylist": "Lisää soittolistaan",
"download": "Lataa",
"info": "Info",
"share": "Jaa"
"share": "Jaa",
"refresh": "Päivitä metatiedot"
},
"lists": {
"all": "Kaikki",
@ -155,11 +156,13 @@
"newPassword": "Uusi salasana",
"token": "Avain",
"lastAccessAt": "Viimeisin käyttö",
"libraries": "Kirjastot"
"libraries": "Kirjastot",
"scrobbleFilter": "Scrobble-suodatin"
},
"helperTexts": {
"name": "Nimen muutos tulee voimaan kun seuraavan kerran kirjaudut sisään",
"libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja"
"libraries": "Valitse tietyt kirjastot tälle käyttäjälle tai jätä tyhjäksi käyttääksesi oletuskirjastoja",
"scrobbleFilter": "Älykkään soittolistan sääntöihin osumat kappaleet ohitetaan Last.fm-, ListenBrainz- ja skrobbausliitännäisissä. Käyttää samaa JSON-syntaksia ja toimintalogiikkaa kuin älykkäät soittolistat. Esimerkki: {\"all\":[{\"lt\":{\"rating\":4}}]}. Jätä tyhjäksi, jos haluat skrobata kaiken. Paikallisiin toistokertoihin tämä ei vaikuta."
},
"notifications": {
"created": "Käyttäjä luotu",
@ -173,7 +176,8 @@
"adminAutoLibraries": "Admin-käyttäjillä on automaattisesti pääsy kaikkiin kirjastoihin"
},
"validation": {
"librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille"
"librariesRequired": "Vähintään yksi kirjasto on valittava ei-admin käyttäjille",
"invalidScrobbleFilter": "Sääntöjen pitää olla kelvollisia älykkään soittolistan sääntöjä. Rajaus (limit), siirtymä (offset) ja päivitysviive eivät toimi tässä."
}
},
"player": {
@ -196,6 +200,9 @@
"targetFormat": "Kohde formaatti",
"defaultBitRate": "Oletus bittinopeus",
"command": "Komento"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -210,7 +217,8 @@
"songCount": "Kappaleita",
"comment": "Kommentti",
"sync": "Automaattinen tuonti",
"path": "Tuo"
"path": "Tuo",
"starred": "Suosikki"
},
"actions": {
"selectPlaylist": "Valitse soittolista:",
@ -403,7 +411,8 @@
"requiredHosts": "Vaaditut palvelimet",
"configValidationError": "Määrityksen validointi epäonnistui:",
"schemaRenderError": "Konfiguraatiolomaketta ei voi näyttää. Lisäosan skeema saattaa olla virheellinen.",
"allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus."
"allowWriteAccessHelp": "Kun otettu käyttöön, liitännäinen voi muokata tiedostoja kirjastohakemistoissa. Oletuksena liitännäisillä on vain luku -oikeus.",
"idHelp": "Lisäosan ID, joka johdetaan sen tiedostonimestä. Käytä sitä viitatessasi tähän lisäosaan määritelmäasetuksissa, kuten agenteissa."
},
"placeholders": {
"configKey": "avain",
@ -599,7 +608,8 @@
"coverUploaded": "Kansikuva päivitetty",
"coverRemoved": "Kansikuva poistettu",
"coverUploadError": "Virhe ladattaessa kansikuvaa",
"coverRemoveError": "Virhe poistettaessa kansikuvaa"
"coverRemoveError": "Virhe poistettaessa kansikuvaa",
"metadataRefreshStarted": "Metatietoja päivitetään taustalla"
},
"menu": {
"library": "Kirjasto",
@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} / %{total} kirjastoa",
"selectLibraries": "Valitse kirjastot",
"none": "Ei mitään"
}
},
"onlyFavourites": "Näytä vain suosikit"
},
"player": {
"playListsText": "Jono",

View File

@ -93,7 +93,8 @@
"addToPlaylist": "Engadir a Lista",
"download": "Descargar",
"info": "Obter info",
"share": "Compartir"
"share": "Compartir",
"refresh": "Actualizar metadatos"
},
"lists": {
"all": "Todo",
@ -155,11 +156,13 @@
"newPassword": "Novo contrasinal",
"token": "Token",
"lastAccessAt": "Último acceso",
"libraries": "Bibliotecas"
"libraries": "Bibliotecas",
"scrobbleFilter": "Filtro para scrobble"
},
"helperTexts": {
"name": "Os cambios no nome aplicaranse a próxima vez que accedas",
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
"libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto",
"scrobbleFilter": "As cancións que concorden coas regras desta lista intelixente non se envían a Last.fm, ListenBrainz ou complementos similares. O filtro usa a mesma sintaxe JSON e comportamento que as listas de reprodución intelixentes. Exemplo: {\"all\":[{\"lt\":{\"rating\":4}}]}. Deixar baleiro para enviar todo. Non lle afecta ao número de reproducións locais."
},
"notifications": {
"created": "Creouse a usuaria",
@ -173,7 +176,8 @@
"adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
},
"validation": {
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
"librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins",
"invalidScrobbleFilter": ""
}
},
"player": {
@ -196,6 +200,9 @@
"targetFormat": "Formato de destino",
"defaultBitRate": "Taxa de bit por defecto",
"command": "Orde"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -210,7 +217,8 @@
"songCount": "Cancións",
"comment": "Comentario",
"sync": "Autoimportación",
"path": "Importar desde"
"path": "Importar desde",
"starred": "Favorita"
},
"actions": {
"selectPlaylist": "Elixe unha lista:",
@ -403,7 +411,8 @@
"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.",
"allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura."
"allowWriteAccessHelp": "A activalo, este complemento pode modificar ficheiros nos directorios da biblioteca. Por defecto os complementos teñen acceso de só-lectura.",
"idHelp": "O ID do complemento, derivado do seu nome de ficheiro. Utilízao cando te refiras ao complemento nas opcións de configuración, como en Agents."
},
"placeholders": {
"configKey": "clave",
@ -435,7 +444,7 @@
"minValue": "Ten que ter polo menos %{min}",
"maxValue": "Ten que ter %{max} ou menos",
"number": "Ten que ser un número",
"email": "Ten que ser un email válido",
"email": "Ten que ser un correo válido",
"oneOf": "Ten que ser un de: %{options}",
"regex": "Ten que ter un formato específico (regexp): %{pattern}",
"unique": "Ten que ser único",
@ -509,7 +518,7 @@
}
},
"message": {
"about": "Acerca de",
"about": "Sobre",
"are_you_sure": "Tes certeza?",
"bulk_delete_content": "Tes a certeza de querer borrar a %{name} |||| Tes a certeza de querer eleminar estes %{smart_count} elementos?",
"bulk_delete_title": "Eliminar %{name} |||| Eliminar %{smart_count} %{name}",
@ -599,7 +608,8 @@
"coverUploaded": "Subiuse a capa",
"coverRemoved": "Retirouse a capa",
"coverUploadError": "Erro ao subir a capa",
"coverRemoveError": "Erro ao retirar a capa"
"coverRemoveError": "Erro ao retirar a capa",
"metadataRefreshStarted": "Actualizar en segundo plano os metadatos"
},
"menu": {
"library": "Biblioteca",
@ -626,7 +636,7 @@
}
},
"albumList": "Álbums",
"about": "Acerca de",
"about": "Sobre",
"playlists": "Listas de reprodución",
"sharedPlaylists": "Listas compartidas",
"librarySelector": {
@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
"selectLibraries": "Seleccionar Bibliotecas",
"none": "Ningunha"
}
},
"onlyFavourites": "Mostrar só as favoritas"
},
"player": {
"playListsText": "Reproducir cola",

View File

@ -37,7 +37,10 @@
"sampleRate": "Częstotliwość próbkowania",
"missing": "Brak",
"libraryName": "Biblioteka",
"composer": "Kompozytor"
"composer": "Kompozytor",
"disc": "Dysk %{discNumber}",
"albumGain": "Wzmocnienie albumu",
"trackGain": "Wzmocnienie utworu"
},
"actions": {
"addToQueue": "Odtwarzaj Później",
@ -90,7 +93,8 @@
"addToPlaylist": "Dodaj do Playlisty",
"download": "Pobierz",
"info": "Zdobądź Informacje",
"share": "Udostępnij"
"share": "Udostępnij",
"refresh": "Odśwież Metadane"
},
"lists": {
"all": "Wszystkie",
@ -152,11 +156,13 @@
"newPassword": "Nowe hasło",
"token": "Token",
"lastAccessAt": "Ostatnia Aktywność",
"libraries": "Biblioteki"
"libraries": "Biblioteki",
"scrobbleFilter": "Filtr scrobblowania"
},
"helperTexts": {
"name": "Zmiana nazwy będzie widoczna przy następnym logowaniu",
"libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki"
"libraries": "Wybierz biblioteki dla użytkownika lub pozostaw pustę, aby użyć domyślnej biblioteki",
"scrobbleFilter": "Utwory spełniające kryteria tych inteligentnych list odtwarzania nie są wysyłane do serwisów Last.fm, ListenBrainz ani do wtyczek typu scrobbler. Wykorzystywana jest tu ta sama składnia JSON i zasada działania, co w przypadku inteligentnych list odtwarzania. Przykład: {\"all\":[{\"lt\":{\"rating\":4}}]}. Pozostawienie pola pustego spowoduje scrobblowanie wszystkich utworów. Nie wpływa to na lokalne liczniki odtworzeń."
},
"notifications": {
"created": "Dodano użytkownika",
@ -170,7 +176,8 @@
"adminAutoLibraries": "Administratorzy automatycznie mają dostęp do wszystkich bibliotek"
},
"validation": {
"librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika"
"librariesRequired": "Przynajmniej jedna biblioteka musi być wybrana dla zwykłego użytkownika",
"invalidScrobbleFilter": "Reguły inteligentnej listy odtwarzania muszą być poprawne. Parametry limitu, przesunięcia oraz opóźnienia odświeżania nie są obsługiwane."
}
},
"player": {
@ -193,6 +200,9 @@
"targetFormat": "Format Docelowy",
"defaultBitRate": "Domyślny Bit Rate",
"command": "Komenda"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -207,7 +217,8 @@
"songCount": "Liczba utworów",
"comment": "Komentarz",
"sync": "Import automatyczny",
"path": "Zaimportuj z"
"path": "Zaimportuj z",
"starred": "Ulubione"
},
"actions": {
"selectPlaylist": "Wybierz playlistę:",
@ -353,7 +364,8 @@
"allUsers": "Zezwalaj wszystkim użytkownikom",
"selectedUsers": "Wybrani użytkownicy",
"allLibraries": "Zezwalaj dla wszystkich bibliotek",
"selectedLibraries": "Wybrane biblioteki"
"selectedLibraries": "Wybrane biblioteki",
"allowWriteAccess": "Zezwól na zapis"
},
"sections": {
"status": "Status",
@ -398,7 +410,9 @@
"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."
"schemaRenderError": "Nie można wyrenderować formularza konfiguracji. Schemat wtyczki może być nieprawidłowy.",
"allowWriteAccessHelp": "Po włączeniu wtyczka może modyfikować pliki w katalogach bibliotek. Domyślnie wtyczki mają dostęp tylko do odczytu.",
"idHelp": ""
},
"placeholders": {
"configKey": "klucz",
@ -588,7 +602,14 @@
"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",
"startingInstantMix": "Ładowanie Natychmiastowego Miksu..."
"startingInstantMix": "Ładowanie Natychmiastowego Miksu...",
"uploadCover": "Prześlij Okładkę",
"removeCover": "Usuń Okładkę",
"coverUploaded": "Okładka zaktualizowana",
"coverRemoved": "Okładka usunięta",
"coverUploadError": "Błąd przesyłania okładki",
"coverRemoveError": "Błąd usuwania okładki",
"metadataRefreshStarted": "Odświeżanie metadanych w tle"
},
"menu": {
"library": "Biblioteka",
@ -623,7 +644,8 @@
"multipleLibraries": "%{selected} z %{total} Bibliotek",
"selectLibraries": "Wybierz Biblioteki",
"none": "Żadna"
}
},
"onlyFavourites": "Pokaż tylko ulubione"
},
"player": {
"playListsText": "Kolejka Odtwarzania",
@ -674,7 +696,8 @@
"exportSuccess": "Konfiguracja wyeksportowana do schowka w formacie TOML",
"exportFailed": "Błąd kopiowania konfiguracji",
"devFlagsHeader": "Flagi Rozwojowe (mogą ulec zmianie/usunięciu)",
"devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach"
"devFlagsComment": "To są ustawienia eksperymentalne i mogą zostać usunięte w przyszłych wydaniach",
"downloadToml": "Konfiguracja Pobierania (TOML)"
}
},
"activity": {

View File

@ -35,12 +35,12 @@
"rawTags": "Tags originais",
"bitDepth": "Profundidade de bits",
"sampleRate": "Taxa de amostragem",
"albumGain": "Ganho do álbum",
"trackGain": "Ganho da faixa",
"missing": "Ausente",
"libraryName": "Biblioteca",
"composer": "Compositor",
"disc": "Disco %{discNumber}"
"disc": "Disco %{discNumber}",
"albumGain": "Ganho do álbum",
"trackGain": "Ganho da faixa"
},
"actions": {
"addToQueue": "Adicionar à fila",
@ -93,8 +93,8 @@
"addToPlaylist": "Adicionar à playlist",
"download": "Baixar",
"info": "Detalhes",
"refresh": "Atualizar Metadados",
"share": "Compartilhar"
"share": "Compartilhar",
"refresh": "Atualizar Metadados"
},
"lists": {
"all": "Todos",
@ -200,6 +200,9 @@
"targetFormat": "Formato",
"defaultBitRate": "Bitrate padrão",
"command": "Comando"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -214,7 +217,8 @@
"songCount": "Músicas",
"comment": "Comentário",
"sync": "Auto-importar",
"path": "Importar de"
"path": "Importar de",
"starred": "Favorita"
},
"actions": {
"selectPlaylist": "Selecione a playlist:",
@ -394,7 +398,6 @@
"invalidJson": "A configuração deve ser um JSON válido"
},
"messages": {
"idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents.",
"configHelp": "Configure o plugin usando pares chave-valor. Deixe vazio se o plugin não precisa de configuração.",
"clickPermissions": "Clique em uma permissão para ver detalhes",
"noConfig": "Nenhuma configuração definida",
@ -408,7 +411,8 @@
"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.",
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura."
"allowWriteAccessHelp": "Quando habilitado, o plugin pode modificar arquivos nos diretórios das bibliotecas. Por padrão, plugins têm acesso somente leitura.",
"idHelp": "O ID do plugin, derivado do nome do arquivo. Use-o ao referenciar este plugin em opções de configuração, como Agents."
},
"placeholders": {
"configKey": "chave",
@ -640,7 +644,8 @@
"multipleLibraries": "%{selected} de %{total} Bibliotecas",
"selectLibraries": "Selecionar Bibliotecas",
"none": "Nenhuma"
}
},
"onlyFavourites": "Somente favoritas"
},
"player": {
"playListsText": "Fila de Execução",

View File

@ -93,7 +93,8 @@
"addToPlaylist": "เพิ่มลงในเพลย์ลิสต์",
"download": "ดาวน์โหลด",
"info": "ดูรายละเอียด",
"share": "แบ่งปัน"
"share": "แบ่งปัน",
"refresh": ""
},
"lists": {
"all": "ทั้งหมด",
@ -155,11 +156,13 @@
"newPassword": "รหัสผ่านใหม่",
"token": "โทเคน",
"lastAccessAt": "เข้าใช้ล่าสุด",
"libraries": "ห้องสมุด"
"libraries": "ห้องสมุด",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
"libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น",
"scrobbleFilter": ""
},
"notifications": {
"created": "สร้างชื่อผู้ใช้",
@ -173,7 +176,8 @@
"adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
},
"validation": {
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
"librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล",
"invalidScrobbleFilter": ""
}
},
"player": {
@ -196,6 +200,9 @@
"targetFormat": "ชนิดไฟล์เสียง",
"defaultBitRate": "บิตเรท",
"command": "คำสั่ง"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -210,7 +217,8 @@
"songCount": "เพลง",
"comment": "ความคิดเห็น",
"sync": "นำเข้าอัตโนมัติ",
"path": "นำเข้าจาก"
"path": "นำเข้าจาก",
"starred": "ชื่นชอบ"
},
"actions": {
"selectPlaylist": "เลือกเพลย์ลิสต์",
@ -403,7 +411,8 @@
"requiredHosts": "ต้องการ Host",
"configValidationError": "การตั้งค่าเกิดความผิดพลาด",
"schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน",
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น"
"allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น",
"idHelp": ""
},
"placeholders": {
"configKey": "คีย์",
@ -599,7 +608,8 @@
"coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว",
"coverRemoved": "ภาพหน้าปกถูกลบแล้ว",
"coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด",
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด"
"coverRemoveError": "ลบภาพหน้าปกผิดพลาด",
"metadataRefreshStarted": ""
},
"menu": {
"library": "ห้องสมุดเพลง",
@ -634,7 +644,8 @@
"multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
"selectLibraries": "เลือกห้องสมุด",
"none": "ไม่มี"
}
},
"onlyFavourites": "แสดงเฉพาะที่ชื่นชอบ"
},
"player": {
"playListsText": "คิวเล่น",

View File

@ -38,7 +38,9 @@
"missing": "Поле відсутнє",
"libraryName": "Бібліотека",
"composer": "Композитор",
"disc": "Диск %{discNumber}"
"disc": "Диск %{discNumber}",
"albumGain": "",
"trackGain": ""
},
"actions": {
"addToQueue": "Прослухати пізніше",
@ -91,7 +93,8 @@
"addToPlaylist": "Додати у список відтворення",
"download": "Завантажити",
"info": "Отримати інформацію",
"share": "Поширити"
"share": "Поширити",
"refresh": ""
},
"lists": {
"all": "Усі",
@ -153,11 +156,13 @@
"newPassword": "Новий пароль",
"token": "Токен",
"lastAccessAt": "Останній доступ",
"libraries": "Бібліотеки"
"libraries": "Бібліотеки",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "Змінене ім'я буде відображатися при наступній авторизації",
"libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням"
"libraries": "Виберіть конкретні бібліотеки для цього користувача, або залиште поле порожнім, щоб використовувати бібліотеки за замовчуванням",
"scrobbleFilter": ""
},
"notifications": {
"created": "Користувача створено",
@ -171,7 +176,8 @@
"adminAutoLibraries": "Користувачі-адміністратори автоматично отримують доступ до всіх бібліотек"
},
"validation": {
"librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека"
"librariesRequired": "Для користувачів, які не є адміністраторами, має бути обрана хоча б одна бібліотека",
"invalidScrobbleFilter": ""
}
},
"player": {
@ -194,6 +200,9 @@
"targetFormat": "Цільовий формат",
"defaultBitRate": "Швидкість передачі бітів за замовчуванням",
"command": "Команда"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -208,7 +217,8 @@
"songCount": "Пісні",
"comment": "Коментар",
"sync": "Автоімпорт",
"path": "Імпортувати із"
"path": "Імпортувати із",
"starred": "Улюблене"
},
"actions": {
"selectPlaylist": "Вибрати список відтворення:",
@ -401,7 +411,8 @@
"requiredHosts": "Обов'язкові хости",
"configValidationError": "Перевірка конфігурації зазнала невдачі:",
"schemaRenderError": "Неможливо відобразити форму конфігурації. Схема плагіна може бути недійсною.",
"allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання."
"allowWriteAccessHelp": "При включенні плагін може змінювати файли в каталогах бібліотеки. За замовчуванням плагіни мають доступ лише для читання.",
"idHelp": ""
},
"placeholders": {
"configKey": "ключ",
@ -597,7 +608,8 @@
"coverUploaded": "Обкладинку оновлено",
"coverRemoved": "Обкладинка видалена",
"coverUploadError": "Помилка завантаження обкладинки",
"coverRemoveError": "Помилка видалення обкладинки"
"coverRemoveError": "Помилка видалення обкладинки",
"metadataRefreshStarted": ""
},
"menu": {
"library": "Бібліотека",
@ -632,7 +644,8 @@
"multipleLibraries": "%{selected} з %{total} Бібліотеки",
"selectLibraries": "Вибір бібліотек",
"none": "Відсутня"
}
},
"onlyFavourites": "Показати улюблене"
},
"player": {
"playListsText": "Грати по черзі",

View File

@ -93,7 +93,8 @@
"addToPlaylist": "加入至播放清單",
"download": "下載",
"info": "取得資訊",
"share": "分享"
"share": "分享",
"refresh": ""
},
"lists": {
"all": "所有",
@ -155,11 +156,13 @@
"newPassword": "新密碼",
"token": "權杖",
"lastAccessAt": "上次存取",
"libraries": "媒體庫"
"libraries": "媒體庫",
"scrobbleFilter": ""
},
"helperTexts": {
"name": "您的名稱會在下次登入時生效",
"libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫"
"libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫",
"scrobbleFilter": ""
},
"notifications": {
"created": "使用者已建立",
@ -173,7 +176,8 @@
"adminAutoLibraries": "管理員預設可存取所有媒體庫"
},
"validation": {
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
"librariesRequired": "非管理員使用者必須至少選擇一個媒體庫",
"invalidScrobbleFilter": ""
}
},
"player": {
@ -196,6 +200,9 @@
"targetFormat": "目標格式",
"defaultBitRate": "預設位元率",
"command": "指令"
},
"choices": {
"noDefaultBitRate": ""
}
},
"playlist": {
@ -210,7 +217,8 @@
"songCount": "歌曲數",
"comment": "註解",
"sync": "自動匯入",
"path": "匯入來源"
"path": "匯入來源",
"starred": "收藏"
},
"actions": {
"selectPlaylist": "選取播放清單:",
@ -403,7 +411,8 @@
"requiredHosts": "必要的 Hosts",
"configValidationError": "設定驗證失敗:",
"schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。",
"allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。"
"allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。",
"idHelp": ""
},
"placeholders": {
"configKey": "鍵",
@ -599,7 +608,8 @@
"coverUploaded": "已更新封面圖",
"coverRemoved": "已移除封面圖",
"coverUploadError": "上傳封面圖時發生錯誤",
"coverRemoveError": "移除封面圖時發生錯誤"
"coverRemoveError": "移除封面圖時發生錯誤",
"metadataRefreshStarted": ""
},
"menu": {
"library": "媒體庫",
@ -634,7 +644,8 @@
"multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
"selectLibraries": "選取媒體庫",
"none": "無"
}
},
"onlyFavourites": "僅顯示收藏"
},
"player": {
"playListsText": "播放佇列",

View File

@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/pl"
"github.com/navidrome/navidrome/utils/singleton"
"golang.org/x/time/rate"
)
@ -387,3 +388,12 @@ func (s *controller) trackProgress(ctx context.Context, progress <-chan *Progres
func (s *controller) sendMessage(ctx context.Context, status *events.ScanStatus) {
s.broker.SendBroadcastMessage(ctx, status)
}
// GetInstance returns the scanner singleton: Status reads the progress counters of the controller
// running the scan, and scheduler, watcher and signal scans do not start from the API's injector.
func GetInstance(rootCtx context.Context, ds model.DataStore, broker events.Broker,
pls playlists.Playlists, m metrics.Metrics) model.Scanner {
return singleton.GetInstance(func() *controller {
return New(rootCtx, ds, broker, pls, m).(*controller)
})
}

View File

@ -92,3 +92,13 @@ var _ = Describe("EffectiveFullScan", func() {
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeFalse())
})
})
var _ = Describe("GetInstance", func() {
It("returns the same controller to every caller", func() {
ds := &tests.MockDataStore{}
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
a := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance())
b := scanner.GetInstance(context.Background(), ds, events.NoopBroker(), pls, metrics.NewNoopInstance())
Expect(a).To(BeIdenticalTo(b))
})
})

View File

@ -96,6 +96,16 @@ func buildAuthPayload(user *model.User) map[string]any {
return payload
}
// MaxLoginBodySize bounds the payload of unauthenticated login routes across all APIs.
const MaxLoginBodySize = 8 << 10
func LimitLoginBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, MaxLoginBodySize)
next.ServeHTTP(w, r)
})
}
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
data := make(map[string]string)
decoder := json.NewDecoder(r.Body)
@ -150,6 +160,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password
err := ds.User(ctx).Put(&initialUser)
if err != nil {
log.Error(ctx, "Could not create initial user", "user", initialUser, err)
return fmt.Errorf("creating initial user: %w", err)
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@ -64,6 +65,14 @@ var _ = Describe("Auth", func() {
})
})
Describe("createAdminUser", func() {
It("returns the error when the user cannot be saved", func() {
ds = &tests.MockDataStore{MockedUser: &tests.MockedUserRepo{Error: errors.New("db is down")}}
err := createAdminUser(context.Background(), ds, "johndoe", "secret")
Expect(err).To(MatchError(ContainSubstring("db is down")))
})
})
Describe("Login from HTTP headers", func() {
const (
trustedIpv4 = "192.168.0.42"
@ -200,6 +209,13 @@ var _ = Describe("Auth", func() {
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
})
It("rejects a request body larger than the limit", func() {
body := `{"username":"janedoe", "password":"abc123", "padding":"` + strings.Repeat("x", MaxLoginBodySize) + `"}`
req = httptest.NewRequest("POST", "/login", strings.NewReader(body))
LimitLoginBody(http.HandlerFunc(login(ds))).ServeHTTP(resp, req)
Expect(resp.Code).To(Equal(http.StatusUnprocessableEntity))
})
It("logs in successfully if user exists", func() {
usr := ds.User(context.Background())
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})

View File

@ -78,12 +78,11 @@ func (api *Router) routes() http.Handler {
inner.Get("/quickconnect/enabled", api.quickConnectEnabled)
// Rate-limit the password login, mirroring the native /auth/login: it's an unauthenticated
// brute-force surface, so it must share the same per-client throttle when one is configured.
login := inner.With(server.LimitLoginBody)
if conf.Server.AuthRequestLimit > 0 {
limiter := server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
inner.With(limiter).Post("/users/authenticatebyname", api.authenticateByName)
} else {
inner.Post("/users/authenticatebyname", api.authenticateByName)
login = login.With(server.ClientIPRateLimiter(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength))
}
login.Post("/users/authenticatebyname", api.authenticateByName)
inner.Get("/users/public", api.getPublicUsers)
// Images are intentionally public: artwork isn't sensitive, matching Jellyfin's image handling.

View File

@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/jellyfin/dto"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
@ -101,3 +102,15 @@ var _ = Describe("AuthenticateByName", func() {
Expect(w.Code).To(Equal(http.StatusUnauthorized))
})
})
var _ = Describe("AuthenticateByName body limit", func() {
It("rejects a request body larger than the limit", func() {
ds := &tests.MockDataStore{}
api := &Router{ds: ds}
w := httptest.NewRecorder()
body := `{"Username":"alice","Pw":"secret","Padding":"` + strings.Repeat("x", server.MaxLoginBodySize) + `"}`
r := httptest.NewRequest("POST", "/Users/AuthenticateByName", strings.NewReader(body))
server.LimitLoginBody(http.HandlerFunc(api.authenticateByName)).ServeHTTP(w, r)
Expect(w.Code).To(Equal(http.StatusBadRequest))
})
})

View File

@ -204,6 +204,7 @@ func (s *Server) initRoutes() {
func (s *Server) mountAuthenticationRoutes() chi.Router {
r := s.router
return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) {
r.Use(LimitLoginBody)
if conf.Server.AuthRequestLimit > 0 {
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
"windowLength", conf.Server.AuthWindowLength)