From 8d4feb242b1c39eacb1ee03508973c03f344aaf7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Oct 2025 08:11:01 -0400 Subject: [PATCH] wip Signed-off-by: Deluan --- cmd/backup.go | 369 ++++++++++++----------- cmd/root.go | 78 ++--- core/artwork/reader_artist.go | 2 +- db/backup.go | 331 ++++++++++---------- db/db.go | 160 +++++----- db/migrations/migration.go | 40 --- go.mod | 8 +- go.sum | 17 +- model/criteria/criteria_suite_test.go | 2 +- model/metadata/metadata_suite_test.go | 2 +- model/model_suite_test.go | 2 +- persistence/album_repository.go | 2 +- persistence/artist_repository.go | 16 +- persistence/helpers.go | 2 +- persistence/library_repository.go | 7 +- persistence/mediafile_repository.go | 5 +- persistence/persistence_suite_test.go | 2 +- persistence/player_repository.go | 4 +- persistence/playlist_repository.go | 7 +- persistence/playlist_track_repository.go | 3 +- persistence/share_repository.go | 2 +- persistence/sql_annotations.go | 11 +- persistence/sql_base_repository.go | 13 +- persistence/sql_bookmarks.go | 1 + persistence/sql_participations.go | 24 +- persistence/tag_repository.go | 24 +- persistence/user_repository.go | 45 +-- scanner/scanner.go | 5 +- ui/package-lock.json | 35 +++ 29 files changed, 627 insertions(+), 592 deletions(-) diff --git a/cmd/backup.go b/cmd/backup.go index ab73f7537..aa962e258 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -1,186 +1,187 @@ package cmd -import ( - "context" - "fmt" - "os" - "strings" - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/db" - "github.com/navidrome/navidrome/log" - "github.com/spf13/cobra" -) - -var ( - backupCount int - backupDir string - force bool - restorePath string -) - -func init() { - rootCmd.AddCommand(backupRoot) - - backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup") - backupRoot.AddCommand(backupCmd) - - pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups") - pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration") - 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().BoolVarP(&force, "force", "f", false, "bypass restore warning") - _ = restoreCommand.MarkFlagRequired("backup-file") - backupRoot.AddCommand(restoreCommand) -} - -var ( - backupRoot = &cobra.Command{ - Use: "backup", - Aliases: []string{"bkp"}, - Short: "Create, restore and prune database backups", - Long: "Create, restore and prune database backups", - } - - backupCmd = &cobra.Command{ - Use: "create", - Short: "Create a backup database", - Long: "Manually backup Navidrome database. This will ignore BackupCount", - Run: func(cmd *cobra.Command, _ []string) { - runBackup(cmd.Context()) - }, - } - - pruneCmd = &cobra.Command{ - Use: "prune", - Short: "Prune database backups", - Long: "Manually prune database backups according to backup rules", - Run: func(cmd *cobra.Command, _ []string) { - runPrune(cmd.Context()) - }, - } - - restoreCommand = &cobra.Command{ - Use: "restore", - Short: "Restore Navidrome database", - Long: "Restore Navidrome database from a backup. This must be done offline", - Run: func(cmd *cobra.Command, _ []string) { - runRestore(cmd.Context()) - }, - } -) - -func runBackup(ctx context.Context) { - if backupDir != "" { - conf.Server.Backup.Path = 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 - } - - start := time.Now() - path, err := db.Backup(ctx) - if err != nil { - log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err) - } - - elapsed := time.Since(start) - log.Info("Backup complete", "elapsed", elapsed, "path", path) -} - -func runPrune(ctx context.Context) { - if backupDir != "" { - conf.Server.Backup.Path = backupDir - } - - if backupCount != -1 { - 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) - return - } - - start := time.Now() - count, err := db.Prune(ctx) - if err != nil { - log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err) - } - - elapsed := time.Since(start) - - log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count) -} - -func runRestore(ctx context.Context) { - 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 - } - - 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") - return - } - } - - start := time.Now() - err := db.Restore(ctx, restorePath) - if err != nil { - log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err) - } - - elapsed := time.Since(start) - log.Info("Restore complete", "elapsed", elapsed) -} +// +//import ( +// "context" +// "fmt" +// "os" +// "strings" +// "time" +// +// "github.com/navidrome/navidrome/conf" +// "github.com/navidrome/navidrome/db" +// "github.com/navidrome/navidrome/log" +// "github.com/spf13/cobra" +//) +// +//var ( +// backupCount int +// backupDir string +// force bool +// restorePath string +//) +// +//func init() { +// rootCmd.AddCommand(backupRoot) +// +// backupCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory to manually make backup") +// backupRoot.AddCommand(backupCmd) +// +// pruneCmd.Flags().StringVarP(&backupDir, "backup-dir", "d", "", "directory holding Navidrome backups") +// pruneCmd.Flags().IntVarP(&backupCount, "keep-count", "k", -1, "specify the number of backups to keep. 0 remove ALL backups, and negative values mean to use the default from configuration") +// 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().BoolVarP(&force, "force", "f", false, "bypass restore warning") +// _ = restoreCommand.MarkFlagRequired("backup-file") +// backupRoot.AddCommand(restoreCommand) +//} +// +//var ( +// backupRoot = &cobra.Command{ +// Use: "backup", +// Aliases: []string{"bkp"}, +// Short: "Create, restore and prune database backups", +// Long: "Create, restore and prune database backups", +// } +// +// backupCmd = &cobra.Command{ +// Use: "create", +// Short: "Create a backup database", +// Long: "Manually backup Navidrome database. This will ignore BackupCount", +// Run: func(cmd *cobra.Command, _ []string) { +// runBackup(cmd.Context()) +// }, +// } +// +// pruneCmd = &cobra.Command{ +// Use: "prune", +// Short: "Prune database backups", +// Long: "Manually prune database backups according to backup rules", +// Run: func(cmd *cobra.Command, _ []string) { +// runPrune(cmd.Context()) +// }, +// } +// +// restoreCommand = &cobra.Command{ +// Use: "restore", +// Short: "Restore Navidrome database", +// Long: "Restore Navidrome database from a backup. This must be done offline", +// Run: func(cmd *cobra.Command, _ []string) { +// runRestore(cmd.Context()) +// }, +// } +//) +// +//func runBackup(ctx context.Context) { +// if backupDir != "" { +// conf.Server.Backup.Path = 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 +// } +// +// start := time.Now() +// path, err := db.Backup(ctx) +// if err != nil { +// log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err) +// } +// +// elapsed := time.Since(start) +// log.Info("Backup complete", "elapsed", elapsed, "path", path) +//} +// +//func runPrune(ctx context.Context) { +// if backupDir != "" { +// conf.Server.Backup.Path = backupDir +// } +// +// if backupCount != -1 { +// 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) +// return +// } +// +// start := time.Now() +// count, err := db.Prune(ctx) +// if err != nil { +// log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err) +// } +// +// elapsed := time.Since(start) +// +// log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count) +//} +// +//func runRestore(ctx context.Context) { +// 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 +// } +// +// 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") +// return +// } +// } +// +// start := time.Now() +// err := db.Restore(ctx, restorePath) +// if err != nil { +// log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err) +// } +// +// elapsed := time.Since(start) +// log.Info("Restore complete", "elapsed", elapsed) +//} diff --git a/cmd/root.go b/cmd/root.go index 9618b16e6..515b24137 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,7 +16,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/resources" - "github.com/navidrome/navidrome/scanner" "github.com/navidrome/navidrome/scheduler" "github.com/navidrome/navidrome/server/backgrounds" "github.com/spf13/cobra" @@ -81,7 +80,6 @@ func runNavidrome(ctx context.Context) { g.Go(startPlaybackServer(ctx)) g.Go(schedulePeriodicBackup(ctx)) g.Go(startInsightsCollector(ctx)) - g.Go(scheduleDBOptimizer(ctx)) g.Go(startPluginManager(ctx)) g.Go(runInitialScan(ctx)) if conf.Server.Scanner.Enabled { @@ -236,51 +234,37 @@ func startScanWatcher(ctx context.Context) func() error { func schedulePeriodicBackup(ctx context.Context) func() error { return func() error { - schedule := conf.Server.Backup.Schedule - if schedule == "" { - log.Info(ctx, "Periodic backup is DISABLED") - return nil - } - - schedulerInstance := scheduler.GetInstance() - - log.Info("Scheduling periodic backup", "schedule", schedule) - _, err := schedulerInstance.Add(schedule, func() { - start := time.Now() - path, err := db.Backup(ctx) - elapsed := time.Since(start) - if err != nil { - log.Error(ctx, "Error backing up database", "elapsed", elapsed, err) - return - } - log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path) - - count, err := db.Prune(ctx) - if err != nil { - log.Error(ctx, "Error pruning database", "error", err) - } else if count > 0 { - log.Info(ctx, "Successfully pruned old files", "count", count) - } else { - log.Info(ctx, "No backups pruned") - } - }) - - return err - } -} - -func scheduleDBOptimizer(ctx context.Context) func() error { - return func() error { - log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) - schedulerInstance := scheduler.GetInstance() - _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { - if scanner.IsScanning() { - log.Debug(ctx, "Skipping DB optimization because a scan is in progress") - return - } - db.Optimize(ctx) - }) - return err + //schedule := conf.Server.Backup.Schedule + //if schedule == "" { + // log.Info(ctx, "Periodic backup is DISABLED") + // return nil + //} + // + //schedulerInstance := scheduler.GetInstance() + // + //log.Info("Scheduling periodic backup", "schedule", schedule) + //_, err := schedulerInstance.Add(schedule, func() { + // start := time.Now() + // path, err := db.Backup(ctx) + // elapsed := time.Since(start) + // if err != nil { + // log.Error(ctx, "Error backing up database", "elapsed", elapsed, err) + // return + // } + // log.Info(ctx, "Backup complete", "elapsed", elapsed, "path", path) + // + // count, err := db.Prune(ctx) + // if err != nil { + // log.Error(ctx, "Error pruning database", "error", err) + // } else if count > 0 { + // log.Info(ctx, "Successfully pruned old files", "count", count) + // } else { + // log.Info(ctx, "No backups pruned") + // } + //}) + // + //return err + return nil } } diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index cb029a16e..c8ce28aa4 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -44,7 +44,7 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{ Filters: squirrel.And{ squirrel.Eq{"album_artist_id": artID.ID}, - squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, + squirrel.Eq{"jsonb_array_length(participants->'albumartist')": 1}, }, }) if err != nil { diff --git a/db/backup.go b/db/backup.go index 8b0f18b1b..c8f5fb396 100644 --- a/db/backup.go +++ b/db/backup.go @@ -1,167 +1,168 @@ package db -import ( - "context" - "database/sql" - "errors" - "fmt" - "os" - "path/filepath" - "regexp" - "slices" - "time" - - "github.com/mattn/go-sqlite3" - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/log" -) - -const ( - backupPrefix = "navidrome_backup" - backupRegexString = backupPrefix + "_(.+)\\.db" -) - -var backupRegex = regexp.MustCompile(backupRegexString) - -const backupSuffixLayout = "2006.01.02_15.04.05" - -func backupPath(t time.Time) string { - return filepath.Join( - conf.Server.Backup.Path, - fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), - ) -} - -func backupOrRestore(ctx context.Context, isBackup bool, path string) error { - // heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/ - existingConn, err := Db().Conn(ctx) - if err != nil { - return fmt.Errorf("getting existing connection: %w", err) - } - defer existingConn.Close() - - backupDb, err := sql.Open(Driver, path) - if err != nil { - return fmt.Errorf("opening backup database in '%s': %w", path, err) - } - defer backupDb.Close() - - backupConn, err := backupDb.Conn(ctx) - if err != nil { - return fmt.Errorf("getting backup connection: %w", err) - } - defer backupConn.Close() - - err = existingConn.Raw(func(existing any) error { - return backupConn.Raw(func(backup any) error { - var sourceOk, destOk bool - var sourceConn, destConn *sqlite3.SQLiteConn - - if isBackup { - sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn) - destConn, destOk = backup.(*sqlite3.SQLiteConn) - } else { - sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn) - destConn, destOk = existing.(*sqlite3.SQLiteConn) - } - - if !sourceOk { - return fmt.Errorf("error trying to convert source to sqlite connection") - } - if !destOk { - return fmt.Errorf("error trying to convert destination to sqlite connection") - } - - backupOp, err := destConn.Backup("main", sourceConn, "main") - if err != nil { - return fmt.Errorf("error starting sqlite backup: %w", err) - } - defer backupOp.Close() - - // Caution: -1 means that sqlite will hold a read lock until the operation finishes - // This will lock out other writes that could happen at the same time - done, err := backupOp.Step(-1) - if !done { - return fmt.Errorf("backup not done with step -1") - } - if err != nil { - return fmt.Errorf("error during backup step: %w", err) - } - - err = backupOp.Finish() - if err != nil { - return fmt.Errorf("error finishing backup: %w", err) - } - - return nil - }) - }) - - return err -} - -func Backup(ctx context.Context) (string, error) { - destPath := backupPath(time.Now()) - log.Debug(ctx, "Creating backup", "path", destPath) - err := backupOrRestore(ctx, true, destPath) - if err != nil { - return "", err - } - - return destPath, nil -} - -func Restore(ctx context.Context, path string) error { - log.Debug(ctx, "Restoring backup", "path", path) - return backupOrRestore(ctx, false, path) -} - -func Prune(ctx context.Context) (int, error) { - files, err := os.ReadDir(conf.Server.Backup.Path) - if err != nil { - return 0, fmt.Errorf("unable to read database backup entries: %w", err) - } - - var backupTimes []time.Time - - for _, file := range files { - if !file.IsDir() { - submatch := backupRegex.FindStringSubmatch(file.Name()) - if len(submatch) == 2 { - timestamp, err := time.Parse(backupSuffixLayout, submatch[1]) - if err == nil { - backupTimes = append(backupTimes, timestamp) - } - } - } - } - - if len(backupTimes) <= conf.Server.Backup.Count { - return 0, nil - } - - slices.SortFunc(backupTimes, func(a, b time.Time) int { - return b.Compare(a) - }) - - pruneCount := 0 - var errs []error - - for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] { - log.Debug(ctx, "Pruning backup", "time", timeToPrune) - path := backupPath(timeToPrune) - err = os.Remove(path) - if err != nil { - errs = append(errs, err) - } else { - pruneCount++ - } - } - - if len(errs) > 0 { - err = errors.Join(errs...) - log.Error(ctx, "Failed to delete one or more files", "errors", err) - } - - return pruneCount, err -} +// +//import ( +// "context" +// "database/sql" +// "errors" +// "fmt" +// "os" +// "path/filepath" +// "regexp" +// "slices" +// "time" +// +// "github.com/mattn/go-sqlite3" +// "github.com/navidrome/navidrome/conf" +// "github.com/navidrome/navidrome/log" +//) +// +//const ( +// backupPrefix = "navidrome_backup" +// backupRegexString = backupPrefix + "_(.+)\\.db" +//) +// +//var backupRegex = regexp.MustCompile(backupRegexString) +// +//const backupSuffixLayout = "2006.01.02_15.04.05" +// +//func backupPath(t time.Time) string { +// return filepath.Join( +// conf.Server.Backup.Path, +// fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), +// ) +//} +// +//func backupOrRestore(ctx context.Context, isBackup bool, path string) error { +// // heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/ +// existingConn, err := Db().Conn(ctx) +// if err != nil { +// return fmt.Errorf("getting existing connection: %w", err) +// } +// defer existingConn.Close() +// +// backupDb, err := sql.Open(Driver, path) +// if err != nil { +// return fmt.Errorf("opening backup database in '%s': %w", path, err) +// } +// defer backupDb.Close() +// +// backupConn, err := backupDb.Conn(ctx) +// if err != nil { +// return fmt.Errorf("getting backup connection: %w", err) +// } +// defer backupConn.Close() +// +// err = existingConn.Raw(func(existing any) error { +// return backupConn.Raw(func(backup any) error { +// var sourceOk, destOk bool +// var sourceConn, destConn *sqlite3.SQLiteConn +// +// if isBackup { +// sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn) +// destConn, destOk = backup.(*sqlite3.SQLiteConn) +// } else { +// sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn) +// destConn, destOk = existing.(*sqlite3.SQLiteConn) +// } +// +// if !sourceOk { +// return fmt.Errorf("error trying to convert source to sqlite connection") +// } +// if !destOk { +// return fmt.Errorf("error trying to convert destination to sqlite connection") +// } +// +// backupOp, err := destConn.Backup("main", sourceConn, "main") +// if err != nil { +// return fmt.Errorf("error starting sqlite backup: %w", err) +// } +// defer backupOp.Close() +// +// // Caution: -1 means that sqlite will hold a read lock until the operation finishes +// // This will lock out other writes that could happen at the same time +// done, err := backupOp.Step(-1) +// if !done { +// return fmt.Errorf("backup not done with step -1") +// } +// if err != nil { +// return fmt.Errorf("error during backup step: %w", err) +// } +// +// err = backupOp.Finish() +// if err != nil { +// return fmt.Errorf("error finishing backup: %w", err) +// } +// +// return nil +// }) +// }) +// +// return err +//} +// +//func Backup(ctx context.Context) (string, error) { +// destPath := backupPath(time.Now()) +// log.Debug(ctx, "Creating backup", "path", destPath) +// err := backupOrRestore(ctx, true, destPath) +// if err != nil { +// return "", err +// } +// +// return destPath, nil +//} +// +//func Restore(ctx context.Context, path string) error { +// log.Debug(ctx, "Restoring backup", "path", path) +// return backupOrRestore(ctx, false, path) +//} +// +//func Prune(ctx context.Context) (int, error) { +// files, err := os.ReadDir(conf.Server.Backup.Path) +// if err != nil { +// return 0, fmt.Errorf("unable to read database backup entries: %w", err) +// } +// +// var backupTimes []time.Time +// +// for _, file := range files { +// if !file.IsDir() { +// submatch := backupRegex.FindStringSubmatch(file.Name()) +// if len(submatch) == 2 { +// timestamp, err := time.Parse(backupSuffixLayout, submatch[1]) +// if err == nil { +// backupTimes = append(backupTimes, timestamp) +// } +// } +// } +// } +// +// if len(backupTimes) <= conf.Server.Backup.Count { +// return 0, nil +// } +// +// slices.SortFunc(backupTimes, func(a, b time.Time) int { +// return b.Compare(a) +// }) +// +// pruneCount := 0 +// var errs []error +// +// for _, timeToPrune := range backupTimes[conf.Server.Backup.Count:] { +// log.Debug(ctx, "Pruning backup", "time", timeToPrune) +// path := backupPath(timeToPrune) +// err = os.Remove(path) +// if err != nil { +// errs = append(errs, err) +// } else { +// pruneCount++ +// } +// } +// +// if len(errs) > 0 { +// err = errors.Join(errs...) +// log.Error(ctx, "Failed to delete one or more files", "errors", err) +// } +// +// return pruneCount, err +//} diff --git a/db/db.go b/db/db.go index cb1ebd9e3..4bffbfce7 100644 --- a/db/db.go +++ b/db/db.go @@ -5,20 +5,22 @@ import ( "database/sql" "embed" "fmt" - "runtime" + "path/filepath" + "strings" + "time" - "github.com/mattn/go-sqlite3" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/navidrome/navidrome/conf" _ "github.com/navidrome/navidrome/db/migrations" "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/utils/hasher" "github.com/navidrome/navidrome/utils/singleton" "github.com/pressly/goose/v3" ) var ( - Dialect = "sqlite3" - Driver = Dialect + "_custom" + Dialect = "postgres" + Driver = "pgx" Path string ) @@ -27,29 +29,73 @@ var embedMigrations embed.FS const migrationsFolder = "migrations" +var postgresInstance *embeddedpostgres.EmbeddedPostgres + func Db() *sql.DB { return singleton.GetInstance(func() *sql.DB { - sql.Register(Driver, &sqlite3.SQLiteDriver{ - ConnectHook: func(conn *sqlite3.SQLiteConn) error { - return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false) - }, - }) - Path = conf.Server.DbPath - if Path == ":memory:" { - Path = "file::memory:?cache=shared&_foreign_keys=on" - conf.Server.DbPath = Path + start := time.Now() + log.Info("Starting Embedded Postgres...") + postgresInstance = embeddedpostgres.NewDatabase( + embeddedpostgres. + DefaultConfig(). + Port(5432). + //Password(password). + Logger(&logAdapter{ctx: context.Background()}). + DataPath(filepath.Join(conf.Server.DataFolder, "postgres")). + BinariesPath(filepath.Join(conf.Server.CacheFolder, "postgres")), + ) + if err := postgresInstance.Start(); err != nil { + if !strings.Contains(err.Error(), "already listening on port") { + _ = postgresInstance.Stop() + log.Fatal("Failed to start embedded Postgres", err) + } + log.Info("Server already running on port 5432, assuming it's our embedded Postgres", "elapsed", time.Since(start)) + } else { + log.Info("Embedded Postgres started", "elapsed", time.Since(start)) } + + // Create the navidrome database if it doesn't exist + adminPath := "postgresql://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable" + adminDB, err := sql.Open(Driver, adminPath) + if err != nil { + _ = postgresInstance.Stop() + log.Fatal("Error connecting to admin database", err) + } + defer adminDB.Close() + + // Check if navidrome database exists, create if not + var exists bool + err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = 'navidrome')").Scan(&exists) + if err != nil { + _ = postgresInstance.Stop() + log.Fatal("Error checking if database exists", err) + } + if !exists { + log.Info("Creating navidrome database...") + _, err = adminDB.Exec("CREATE DATABASE navidrome") + if err != nil { + _ = postgresInstance.Stop() + log.Fatal("Error creating navidrome database", err) + } + } + + // TODO: Implement seeded random function + //sql.Register(Driver, &sqlite3.SQLiteDriver{ + // ConnectHook: func(conn *sqlite3.SQLiteConn) error { + // return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false) + // }, + //}) + //Path = conf.Server.DbPath + // Ensure client does not attempt TLS when connecting to the embedded Postgres + // and avoid shadowing the package-level Path variable. + Path = "postgresql://postgres:postgres@127.0.0.1:5432/navidrome?sslmode=disable" log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) - db.SetMaxOpenConns(max(4, runtime.NumCPU())) + //db.SetMaxOpenConns(max(4, runtime.NumCPU())) if err != nil { + _ = postgresInstance.Stop() log.Fatal("Error opening database", err) } - _, err = db.Exec("PRAGMA optimize=0x10002") - if err != nil { - log.Error("Error applying PRAGMA optimize", err) - return nil - } return db }) } @@ -58,33 +104,24 @@ func Close(ctx context.Context) { // Ignore cancellations when closing the DB ctx = context.WithoutCancel(ctx) - // Run optimize before closing - Optimize(ctx) - log.Info(ctx, "Closing Database") err := Db().Close() if err != nil { log.Error(ctx, "Error closing Database", err) } + if postgresInstance != nil { + err = postgresInstance.Stop() + if err != nil { + log.Error(ctx, "Error stopping embedded Postgres", err) + } + } } func Init(ctx context.Context) func() { db := Db() - // Disable foreign_keys to allow re-creating tables in migrations - _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=off") - defer func() { - _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=on") - if err != nil { - log.Error(ctx, "Error re-enabling foreign_keys", err) - } - }() - if err != nil { - log.Error(ctx, "Error disabling foreign_keys", err) - } - goose.SetBaseFS(embedMigrations) - err = goose.SetDialect(Dialect) + err := goose.SetDialect(Dialect) if err != nil { log.Fatal(ctx, "Invalid DB driver", "driver", Driver, err) } @@ -99,51 +136,17 @@ func Init(ctx context.Context) func() { log.Fatal(ctx, "Failed to apply new migrations", err) } - if hasSchemaChanges { - log.Debug(ctx, "Applying PRAGMA optimize after schema changes") - _, err = db.ExecContext(ctx, "PRAGMA optimize") - if err != nil { - log.Error(ctx, "Error applying PRAGMA optimize", err) - } - } - return func() { Close(ctx) } } -// Optimize runs PRAGMA optimize on each connection in the pool -func Optimize(ctx context.Context) { - numConns := Db().Stats().OpenConnections - if numConns == 0 { - log.Debug(ctx, "No open connections to optimize") - return - } - log.Debug(ctx, "Optimizing open connections", "numConns", numConns) - var conns []*sql.Conn - for i := 0; i < numConns; i++ { - conn, err := Db().Conn(ctx) - conns = append(conns, conn) - if err != nil { - log.Error(ctx, "Error getting connection from pool", err) - continue - } - _, err = conn.ExecContext(ctx, "PRAGMA optimize;") - if err != nil { - log.Error(ctx, "Error running PRAGMA optimize", err) - } - } - - // Return all connections to the Connection Pool - for _, conn := range conns { - conn.Close() - } -} - type statusLogger struct{ numPending int } func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) } func (l *statusLogger) Printf(format string, v ...interface{}) { + // format is part of the goose logger signature; reference it to avoid linter warnings + _ = format if len(v) < 1 { return } @@ -165,11 +168,15 @@ func hasPendingMigrations(ctx context.Context, db *sql.DB, folder string) bool { } 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 + rows, err := db.QueryContext(ctx, "SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename = 'goose_db_version';") // nolint:rowserrcheck if err != nil { log.Fatal(ctx, "Database could not be opened!", err) } - defer rows.Close() + defer func() { + if cerr := rows.Close(); cerr != nil { + log.Error(ctx, "Error closing rows", cerr) + } + }() return !rows.Next() } @@ -178,6 +185,11 @@ type logAdapter struct { silent bool } +func (l *logAdapter) Write(p []byte) (n int, err error) { + log.Debug(l.ctx, string(p)) + return len(p), nil +} + func (l *logAdapter) Fatal(v ...interface{}) { log.Fatal(l.ctx, fmt.Sprint(v...)) } diff --git a/db/migrations/migration.go b/db/migrations/migration.go index 8d8f8a91e..735b8fde0 100644 --- a/db/migrations/migration.go +++ b/db/migrations/migration.go @@ -78,43 +78,3 @@ func createExecuteFunc(ctx context.Context, tx *sql.Tx) execStmtFunc { } } } - -// Hack way to add a new `not null` column to a table, setting the initial value for existing rows based on a -// SQL expression. It is done in 3 steps: -// 1. Add the column as nullable. Due to the way SQLite manipulates the DDL in memory, we need to add extra padding -// to the default value to avoid truncating it when changing the column to not null -// 2. Update the column with the initial value -// 3. Change the column to not null with the default value -// -// Based on https://stackoverflow.com/a/25917323 -func createAddColumnFunc(ctx context.Context, tx *sql.Tx) addColumnFunc { - return func(tableName, columnName, columnType, defaultValue, initialValue string) execFunc { - return func() error { - // Format the `default null` value to have the same length as the final defaultValue - finalLen := len(fmt.Sprintf(`%s not`, defaultValue)) - tempDefault := fmt.Sprintf(`default %s null`, strings.Repeat(" ", finalLen)) - _, err := tx.ExecContext(ctx, fmt.Sprintf(` -alter table %s add column %s %s %s;`, tableName, columnName, columnType, tempDefault)) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, fmt.Sprintf(` -update %s set %s = %s where %[2]s is null;`, tableName, columnName, initialValue)) - if err != nil { - return err - } - _, err = tx.ExecContext(ctx, fmt.Sprintf(` -PRAGMA writable_schema = on; -UPDATE sqlite_master -SET sql = replace(sql, '%[1]s %[2]s %[5]s', '%[1]s %[2]s default %[3]s not null') -WHERE type = 'table' - AND name = '%[4]s'; -PRAGMA writable_schema = off; -`, columnName, columnType, defaultValue, tableName, tempDefault)) - if err != nil { - return err - } - return err - } - } -} diff --git a/go.mod b/go.mod index 265cbfa6d..a7fa3ada0 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/djherbis/times v1.6.0 github.com/dustin/go-humanize v1.0.1 github.com/fatih/structs v1.1.0 + github.com/fergusstrange/embedded-postgres v1.32.0 github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 @@ -33,6 +34,7 @@ require ( github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 + github.com/jackc/pgx/v5 v5.7.6 github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 @@ -40,7 +42,6 @@ require ( github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v2 v2.1.6 github.com/matoous/go-nanoid/v2 v2.1.0 - github.com/mattn/go-sqlite3 v1.14.32 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.27.1 @@ -94,6 +95,9 @@ require ( github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect @@ -103,6 +107,7 @@ require ( github.com/lestrrat-go/httprc v1.0.6 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -123,6 +128,7 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index f9e620fb2..de0667d34 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fergusstrange/embedded-postgres v1.32.0 h1:kh2ozEvAx2A0LoIJZEGNwHmoFTEQD243KrHjifcYGMo= +github.com/fergusstrange/embedded-postgres v1.32.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -120,6 +122,14 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -162,14 +172,14 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -259,6 +269,7 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -279,6 +290,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/model/criteria/criteria_suite_test.go b/model/criteria/criteria_suite_test.go index 36e74cfa4..9fddf1707 100644 --- a/model/criteria/criteria_suite_test.go +++ b/model/criteria/criteria_suite_test.go @@ -3,7 +3,7 @@ package criteria import ( "testing" - _ "github.com/mattn/go-sqlite3" + //_ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" diff --git a/model/metadata/metadata_suite_test.go b/model/metadata/metadata_suite_test.go index fc299c7e9..4fb05ec45 100644 --- a/model/metadata/metadata_suite_test.go +++ b/model/metadata/metadata_suite_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/djherbis/times" - _ "github.com/mattn/go-sqlite3" + //_ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" diff --git a/model/model_suite_test.go b/model/model_suite_test.go index 39184edc1..c9eb478ba 100644 --- a/model/model_suite_test.go +++ b/model/model_suite_test.go @@ -3,7 +3,7 @@ package model_test import ( "testing" - _ "github.com/mattn/go-sqlite3" + //_ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" diff --git a/persistence/album_repository.go b/persistence/album_repository.go index 6f9bb3b48..441e5d850 100644 --- a/persistence/album_repository.go +++ b/persistence/album_repository.go @@ -219,7 +219,7 @@ func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { func (r *albumRepository) selectAlbum(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). - LeftJoin("library on album.library_id = library.id") + LeftJoin("library on album.library_id = library.id").GroupBy("library_path", "library_name") sql = r.withAnnotation(sql, "album.id") return r.applyLibraryFilter(sql) } diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 6d08c27db..760dcca62 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -187,8 +187,7 @@ func (r *artistRepository) applyLibraryFilterToArtistQuery(query SelectBuilder) func (r *artistRepository) selectArtist(options ...model.QueryOptions) SelectBuilder { // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}} query := r.newSelect(options...).Columns("artist.*", - "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") - + "JSON_OBJECT_AGG(library_artist.library_id, library_artist.stats::jsonb) as library_stats_json") query = r.applyLibraryFilterToArtistQuery(query) query = query.GroupBy("artist.id") return r.withAnnotation(query, "artist.id") @@ -348,12 +347,15 @@ set missing = (artist.id not in (select artist_id from artists_with_non_missing_ func (r *artistRepository) RefreshPlayCounts() (int64, error) { query := Expr(` with play_counts as ( - select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date + select user_id, + (elem->>'id')::text as artist_id, + sum(play_count) as total_play_count, + max(play_date) as last_play_date from media_file join annotation on item_id = media_file.id - left join json_tree(participants, '$.artist') as jt - where atom is not null and key = 'id' - group by user_id, atom + cross join jsonb_array_elements(participants->'artist') as elem + where elem->>'id' is not null + group by user_id, elem->>'id' ) insert into annotation (user_id, item_id, item_type, play_count, play_date) select user_id, artist_id, 'artist', total_play_count, last_play_date @@ -361,7 +363,7 @@ from play_counts where total_play_count > 0 on conflict (user_id, item_id, item_type) do update set play_count = excluded.play_count, - play_date = excluded.play_date; + play_date = excluded.play_date `) return r.executeSQL(query) } diff --git a/persistence/helpers.go b/persistence/helpers.go index 73815ae45..48de94c5f 100644 --- a/persistence/helpers.go +++ b/persistence/helpers.go @@ -87,6 +87,6 @@ var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`) // It finds order column names anywhere in the substring func mapSortOrder(tableName, order string) string { order = strings.ToLower(order) - repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName) + repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1)::citext)", tableName) return sortOrderRegex.ReplaceAllString(order, repl) } diff --git a/persistence/library_repository.go b/persistence/library_repository.go index 314b682bb..e0d841943 100644 --- a/persistence/library_repository.go +++ b/persistence/library_repository.go @@ -117,7 +117,7 @@ func (r *libraryRepository) Put(l *model.Library) error { sql := Expr(` INSERT INTO user_library (user_id, library_id) SELECT u.id, l.id -FROM user u +FROM "user" u CROSS JOIN library l WHERE u.is_admin = true ON CONFLICT (user_id, library_id) DO NOTHING;`, @@ -173,11 +173,6 @@ func (r *libraryRepository) ScanEnd(id int) error { Set("last_scan_started_at", time.Time{}). Where(Eq{"id": id}) _, err := r.executeSQL(sq) - if err != nil { - return err - } - // https://www.sqlite.org/pragma.html#pragma_optimize - _, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;")) return err } diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index e7883947a..ae2fb67c6 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -137,7 +137,8 @@ func (r *mediaFileRepository) Put(m *model.MediaFile) error { func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { sql := r.newSelect(options...).Columns("media_file.*", "library.path as library_path", "library.name as library_name"). - LeftJoin("library on media_file.library_id = library.id") + LeftJoin("library on media_file.library_id = library.id"). + GroupBy("library.id") sql = r.withAnnotation(sql, "media_file.id") sql = r.withBookmark(sql, "media_file.id") return r.applyLibraryFilter(sql) @@ -193,7 +194,7 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me } func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) { - sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths}) + sel := r.newSelect().Columns("*").Where(Eq{"path::citext": paths}) var res dbMediaFiles if err := r.queryAll(sel, &res); err != nil { return nil, err diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 1007d84fe..fc3f6f9b8 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - _ "github.com/mattn/go-sqlite3" + //_ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 73c820753..a2ad82b4c 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -35,8 +35,8 @@ func (r *playerRepository) Put(p *model.Player) error { func (r *playerRepository) selectPlayer(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...). Columns("player.*"). - Join("user ON player.user_id = user.id"). - Columns("user.user_name username") + Join(`"user" ON player.user_id = "user".id`). + Columns(`"user".user_name username`) } func (r *playerRepository) Get(id string) (*model.Player, error) { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 046284e1f..de40fef53 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -217,8 +217,8 @@ func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, } func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Join("user on user.id = owner_id"). - Columns(r.tableName+".*", "user.user_name as owner_name") + return r.newSelect(options...).Join(`"user" on "user".id = owner_id`). + Columns(r.tableName+".*", `"user".user_name as owner_name`) } func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { @@ -399,6 +399,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla " AND annotation.user_id = '" + userID + "')"). Join("media_file f on f.id = media_file_id"). Join("library on f.library_id = library.id"). + GroupBy("library_path"). Where(Eq{"playlist_id": id}) tracks := dbPlaylistTracks{} err := r.queryAll(tracksQuery, &tracks) @@ -470,7 +471,7 @@ func (r *playlistRepository) removeOrphans() error { Join("playlist p on playlist_tracks.playlist_id = p.id"). LeftJoin("media_file mf on playlist_tracks.media_file_id = mf.id"). Where(Eq{"mf.id": nil}). - GroupBy("playlist_tracks.playlist_id") + GroupBy("playlist_tracks.playlist_id", "p.name") var pls []struct{ Id, Name string } err := r.queryAll(sel, &pls) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 01eec0d02..f46baa174 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -116,8 +116,9 @@ func (r *playlistTrackRepository) GetAll(options ...model.QueryOptions) (model.P } func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { - query := r.newSelect(options...).Columns("distinct mf.album_id"). + query := r.newSelect(options...).Columns("mf.album_id"). Join("media_file mf on mf.id = media_file_id"). + GroupBy("mf.album_id"). Where(Eq{"playlist_id": r.playlistId}) var ids []string err := r.queryAllSlice(query, &ids) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index d943943e0..b5af638eb 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -39,7 +39,7 @@ func (r *shareRepository) Delete(id string) error { } func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...).Join("user u on u.id = share.user_id"). + return r.newSelect(options...).Join(`"user" u on u.id = share.user_id`). Columns("share.*", "user_name as username") } diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go index 6691b553c..a8dd31268 100644 --- a/persistence/sql_annotations.go +++ b/persistence/sql_annotations.go @@ -24,10 +24,17 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec "annotation.item_id = "+idField+ " AND annotation.user_id = '"+userID+"')"). Columns( - "coalesce(starred, 0) as starred", + "coalesce(starred, false) as starred", "coalesce(rating, 0) as rating", "starred_at", - "play_date", + "play_date"). + GroupBy( + idField, + "annotation.starred", + "annotation.rating", + "annotation.starred_at", + "annotation.play_date", + "annotation.play_count", ) if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" { query = query.Columns( diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index ce026a3c3..081b9c2c4 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -13,6 +13,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/lann/builder" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -386,12 +387,20 @@ func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOpt countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). RemoveOffset().RemoveLimit(). - OrderBy(r.tableName + ".id"). // To remove any ORDER BY clause that could slow down the query + GroupBy(r.tableName + ".id"). From(r.tableName) + // To remove any ORDER BY clause that could slow down the query + countQuery = builder.Delete(countQuery, "OrderByParts").(SelectBuilder) countQuery = r.applyFilters(countQuery, options...) var res struct{ Count int64 } err := r.queryOne(countQuery, &res) - return res.Count, err + if errors.Is(err, model.ErrNotFound) { + return 0, nil + } + if err != nil { + return 0, err + } + return res.Count, nil } func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) { diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go index 52c4b8e9c..4c6d61811 100644 --- a/persistence/sql_bookmarks.go +++ b/persistence/sql_bookmarks.go @@ -23,6 +23,7 @@ func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectB LeftJoin("bookmark on (" + "bookmark.item_id = " + idField + " AND bookmark.user_id = '" + userID + "')"). + GroupBy("bookmark.position"). Columns("coalesce(position, 0) as bookmark_position") } diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go index d88eca45e..53550b0cb 100644 --- a/persistence/sql_participations.go +++ b/persistence/sql_participations.go @@ -80,18 +80,18 @@ func (r sqlRepository) updateParticipants(itemID string, participants model.Part // Build the INSERT query using json_each and INNER JOIN to artist table // to automatically filter out non-existent artist IDs query := fmt.Sprintf(` - INSERT INTO %[1]s_artists (%[1]s_id, artist_id, role, sub_role) - SELECT ?, - json_extract(value, '$.artist_id') as artist_id, - json_extract(value, '$.role') as role, - COALESCE(json_extract(value, '$.sub_role'), '') as sub_role - -- Parse the flat JSON array: [{"artist_id": "id", "role": "role", "sub_role": "subRole"}] - FROM json_each(?) -- Iterate through each array element - -- CRITICAL: Only insert records for artists that actually exist in the database - JOIN artist ON artist.id = json_extract(value, '$.artist_id') -- Filter out non-existent artist IDs via INNER JOIN - -- Handle duplicate insertions gracefully (e.g., if called multiple times) - ON CONFLICT (artist_id, %[1]s_id, role, sub_role) DO NOTHING -- Ignore duplicates - `, r.tableName) + INSERT INTO %[1]s_artists (%[1]s_id, artist_id, role, sub_role) + SELECT ?, + value->>'artist_id' as artist_id, + value->>'role' as role, + COALESCE(value->>'sub_role', '') as sub_role + -- Parse the flat JSON array: [{"artist_id": "id", "role": "role", "sub_role": "subRole"}] + FROM jsonb_array_elements(?::jsonb) -- Iterate through each array element + -- CRITICAL: Only insert records for artists that actually exist in the database + JOIN artist ON artist.id = value->>'artist_id' -- Filter out non-existent artist IDs via INNER JOIN + -- Handle duplicate insertions gracefully (e.g., if called multiple times) + ON CONFLICT (artist_id, %[1]s_id, role, sub_role) DO NOTHING -- Ignore duplicates +`, r.tableName) _, err = r.executeSQL(Expr(query, itemID, string(participantsJSON))) return err diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go index b224450ab..f8368b033 100644 --- a/persistence/tag_repository.go +++ b/persistence/tag_repository.go @@ -76,16 +76,20 @@ DO UPDATE SET %[1]s_count = excluded.%[1]s_count; func (r *tagRepository) purgeUnused() error { del := Delete(r.tableName).Where(` - id not in (select jt.value - from album left join json_tree(album.tags, '$') as jt - where atom is not null - and key = 'id' - UNION - select jt.value - from media_file left join json_tree(media_file.tags, '$') as jt - where atom is not null - and key = 'id') -`) + id not in ( + select DISTINCT elem->>'id' + from album + cross join lateral jsonb_each(album.tags) as tag_type(key, value) + cross join lateral jsonb_array_elements(tag_type.value) as elem + where elem->>'id' is not null + UNION + select DISTINCT elem->>'id' + from media_file + cross join lateral jsonb_each(media_file.tags) as tag_type(key, value) + cross join lateral jsonb_array_elements(tag_type.value) as elem + where elem->>'id' is not null + ) + `) c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("error purging unused tags: %w", err) diff --git a/persistence/user_repository.go b/persistence/user_repository.go index a7181b1a7..2c82fa786 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -55,7 +55,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r := &userRepository{} r.ctx = ctx r.db = db - r.tableName = "user" + r.tableName = `"user"` r.registerModel(&model.User{}, map[string]filterFunc{ "password": invalidFilter(ctx), "name": r.withTableName(startsWithFilter), @@ -69,21 +69,26 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository // selectUserWithLibraries returns a SelectBuilder that includes library information func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...). - Columns(`user.*`, - `COALESCE(json_group_array(json_object( - 'id', library.id, - 'name', library.name, - 'path', library.path, - 'remote_path', library.remote_path, - 'last_scan_at', library.last_scan_at, - 'last_scan_started_at', library.last_scan_started_at, - 'full_scan_in_progress', library.full_scan_in_progress, - 'updated_at', library.updated_at, - 'created_at', library.created_at - )) FILTER (WHERE library.id IS NOT NULL), '[]') AS libraries_json`). - LeftJoin("user_library ul ON user.id = ul.user_id"). + Columns(`"user".*`, + `COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', library.id, + 'name', library.name, + 'path', library.path, + 'remote_path', library.remote_path, + 'last_scan_at', library.last_scan_at, + 'last_scan_started_at', library.last_scan_started_at, + 'full_scan_in_progress', library.full_scan_in_progress, + 'updated_at', library.updated_at, + 'created_at', library.created_at + ) + ) FILTER (WHERE library.id IS NOT NULL), + '[]'::jsonb + )::text AS libraries_json`). + LeftJoin(`user_library ul ON "user".id = ul.user_id`). LeftJoin("library ON ul.library_id = library.id"). - GroupBy("user.id") + GroupBy(`"user".id`) } func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { @@ -91,7 +96,7 @@ func (r *userRepository) CountAll(qo ...model.QueryOptions) (int64, error) { } func (r *userRepository) Get(id string) (*model.User, error) { - sel := r.selectUserWithLibraries().Where(Eq{"user.id": id}) + sel := r.selectUserWithLibraries().Where(Eq{`"user".id`: id}) var res dbUser err := r.queryOne(sel, &res) if err != nil { @@ -144,7 +149,7 @@ func (r *userRepository) Put(u *model.User) error { // Auto-assign all libraries to admin users in a single SQL operation if u.IsAdmin { sql := Expr( - "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library", + "INSERT INTO user_library (user_id, library_id) SELECT ?, id FROM library ON CONFLICT DO NOTHING", u.ID, ) if _, err := r.executeSQL(sql); err != nil { @@ -153,7 +158,7 @@ func (r *userRepository) Put(u *model.User) error { } else if isNewUser { // Only for new regular users // Auto-assign default libraries to new regular users sql := Expr( - "INSERT OR IGNORE INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true", + "INSERT INTO user_library (user_id, library_id) SELECT ?, id FROM library WHERE default_new_users = true ON CONFLICT DO NOTHING", u.ID, ) if _, err := r.executeSQL(sql); err != nil { @@ -165,7 +170,7 @@ func (r *userRepository) Put(u *model.User) error { } func (r *userRepository) FindFirstAdmin() (*model.User, error) { - sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) + sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{`"user".is_admin`: true}) var usr dbUser err := r.queryOne(sel, &usr) if err != nil { @@ -175,7 +180,7 @@ func (r *userRepository) FindFirstAdmin() (*model.User, error) { } func (r *userRepository) FindByUsername(username string) (*model.User, error) { - sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username)) + sel := r.selectUserWithLibraries().Where(Expr(`"user".user_name = ?`, username)) var usr dbUser err := r.queryOne(sel, &usr) if err != nil { diff --git a/scanner/scanner.go b/scanner/scanner.go index 04a5c2456..9b94159b1 100644 --- a/scanner/scanner.go +++ b/scanner/scanner.go @@ -11,7 +11,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" - "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/run" @@ -181,9 +180,7 @@ func (s *scannerImpl) runRefreshStats(ctx context.Context, state *scanState) fun func (s *scannerImpl) runOptimize(ctx context.Context) func() error { return func() error { - start := time.Now() - db.Optimize(ctx) - log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start)) + // TODO: Noop for now return nil } } diff --git a/ui/package-lock.json b/ui/package-lock.json index c0901a73d..91fdd33be 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -120,6 +120,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1616,6 +1617,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -1638,6 +1640,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -2284,6 +2287,7 @@ "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.12.4.tgz", "integrity": "sha512-tr7xekNlM9LjA6pagJmL8QCgZXaubWUwkJnoYcMKd4gw/t4XiyvnTkjdGrUVicyB2BsdaAv1tvow45bPM4sSwQ==", "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4", "@material-ui/styles": "^4.11.5", @@ -2328,6 +2332,7 @@ "version": "4.11.3", "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.11.3.tgz", "integrity": "sha512-IKHlyx6LDh8n19vzwH5RtHIOHl9Tu90aAAxcbWME6kp4dmvODM3UvOHJeMIDzUbd4muuJKHmlNoBN+mDY4XkBA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.4.4" }, @@ -2959,6 +2964,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "peer": true, "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" @@ -3005,6 +3011,7 @@ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3024,6 +3031,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz", "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", @@ -3155,6 +3163,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3470,6 +3479,7 @@ "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4020,6 +4030,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", @@ -4431,6 +4442,7 @@ "version": "6.9.3", "resolved": "https://registry.npmjs.org/connected-react-router/-/connected-react-router-6.9.3.tgz", "integrity": "sha512-4ThxysOiv/R2Dc4Cke1eJwjKwH1Y51VDwlOrOfs1LjpdYOVvCNjNkZDayo7+sx42EeGJPQUNchWkjAIJdXGIOQ==", + "peer": true, "dependencies": { "lodash.isequalwith": "^4.4.0", "prop-types": "^15.7.2" @@ -5257,6 +5269,7 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5817,6 +5830,7 @@ "version": "4.20.10", "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", + "peer": true, "dependencies": { "@babel/runtime": "^7.10.0" }, @@ -5832,6 +5846,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", + "peer": true, "peerDependencies": { "final-form": "^4.20.8" } @@ -6318,6 +6333,7 @@ "version": "4.10.1", "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "peer": true, "dependencies": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", @@ -8381,6 +8397,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8465,6 +8482,7 @@ "version": "3.19.12", "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", + "peer": true, "dependencies": { "classnames": "~2.3.1", "date-fns": "^1.29.0", @@ -8821,6 +8839,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -8898,6 +8917,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -8973,6 +8993,7 @@ "version": "6.5.9", "resolved": "https://registry.npmjs.org/react-final-form/-/react-final-form-6.5.9.tgz", "integrity": "sha512-x3XYvozolECp3nIjly+4QqxdjSSWfcnpGEL5K8OBT6xmGrq5kBqbA6+/tOqoom9NwqIPPbxPNsOViFlbKgowbA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4" }, @@ -8989,6 +9010,7 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.19.4" }, @@ -9085,6 +9107,7 @@ "version": "7.2.9", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.15.4", "@types/react-redux": "^7.1.20", @@ -9118,6 +9141,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -9137,6 +9161,7 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -9304,6 +9329,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -9313,6 +9339,7 @@ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz", "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==", "license": "MIT", + "peer": true, "dependencies": { "@redux-saga/core": "^1.4.2" } @@ -9549,6 +9576,7 @@ "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.41.1.tgz", "integrity": "sha512-70qfem+U3hAgwNgOlnUQiIdfKHLELUxsEWbFWg3aErPUvsyXYF1HALJBwoDgMUhRWyn+SqWVneDTnO/Kbey9hg==", "devOptional": true, + "peer": true, "dependencies": { "@types/estree": "1.0.7" }, @@ -10395,6 +10423,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -10614,6 +10643,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10858,6 +10888,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -10979,6 +11010,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -10991,6 +11023,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz", "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==", "dev": true, + "peer": true, "dependencies": { "@vitest/expect": "4.0.3", "@vitest/mocker": "4.0.3", @@ -11422,6 +11455,7 @@ "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11466,6 +11500,7 @@ "version": "2.79.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "peer": true, "bin": { "rollup": "dist/bin/rollup" },