mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
Merge branch 'master' into msi-property-fix
This commit is contained in:
commit
119d5784db
@ -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
|
||||
}
|
||||
|
||||
@ -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
99
cmd/doctor.go
Normal 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
124
cmd/doctor_test.go
Normal 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
55
cmd/search.go
Normal 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.")
|
||||
}
|
||||
20
cmd/utils.go
20
cmd/utils.go
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -42,7 +42,7 @@ var allProviders = wire.NewSet(
|
||||
lastfm.NewRouter,
|
||||
listenbrainz.NewRouter,
|
||||
events.GetBroker,
|
||||
scanner.New,
|
||||
scanner.GetInstance,
|
||||
scanner.GetWatcher,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
|
||||
@ -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()))
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
13
core/artwork/resize_test.go
Normal file
13
core/artwork/resize_test.go
Normal 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")))
|
||||
})
|
||||
})
|
||||
@ -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"}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
15
db/backup.go
15
db/backup.go
@ -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)
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
17
db/db.go
17
db/db.go
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
367
db/repair.go
Normal 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
309
db/repair_test.go
Normal 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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
19
model/share_test.go
Normal 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()))
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Ουρά Αναπαραγωγής",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "คิวเล่น",
|
||||
|
||||
@ -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": "Грати по черзі",
|
||||
|
||||
@ -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": "播放佇列",
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user