mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
wip
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
dd635c4e30
commit
8d4feb242b
369
cmd/backup.go
369
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)
|
||||
//}
|
||||
|
||||
78
cmd/root.go
78
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
331
db/backup.go
331
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
|
||||
//}
|
||||
|
||||
160
db/db.go
160
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...))
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
go.mod
8
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
|
||||
|
||||
17
go.sum
17
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=
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
35
ui/package-lock.json
generated
35
ui/package-lock.json
generated
@ -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"
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user