package db
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"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.MustPath(),
fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)),
)
}
func openBackupFile(path string) (*sql.DB, error) {
Db()
backupDb, err := sql.Open(Driver, path)
if err != nil {
return nil, fmt.Errorf("opening backup database in '%s': %w", path, err)
}
return backupDb, nil
}
func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
destConn, err := dest.Conn(ctx)
if err != nil {
return fmt.Errorf("getting destination connection: %w", err)
}
defer destConn.Close()
sourceConn, err := source.Conn(ctx)
if err != nil {
return fmt.Errorf("getting source connection: %w", err)
}
defer sourceConn.Close()
return destConn.Raw(func(rawDest any) error {
return sourceConn.Raw(func(rawSource any) error {
sqliteDest, destOk := rawDest.(*sqlite3.SQLiteConn)
if !destOk {
return fmt.Errorf("error trying to convert destination to sqlite connection")
}
sqliteSource, sourceOk := rawSource.(*sqlite3.SQLiteConn)
if !sourceOk {
return fmt.Errorf("error trying to convert source to sqlite connection")
}
backupOp, err := sqliteDest.Backup("main", sqliteSource, "main")
if err != nil {
return fmt.Errorf("error starting sqlite backup: %w", err)
}
defer backupOp.Close()
done, err := backupOp.Step(-1)
if err != nil {
return fmt.Errorf("error during backup step: %w", err)
}
if !done {
return fmt.Errorf("backup not done with step -1")
}
err = backupOp.Finish()
if err != nil {
return fmt.Errorf("error finishing backup: %w", err)
}
return nil
})
})
}
func checkRestoreSource(ctx context.Context, backupDb *sql.DB, path string, integrityCheck bool) error {
var tables int
err := backupDb.QueryRowContext(ctx,
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('goose_db_version', 'user', 'media_file')").Scan(&tables)
if err != nil {
return fmt.Errorf("reading backup file '%s': %w", path, err)
}
if tables != 3 {
return fmt.Errorf("backup file '%s' is not a Navidrome database", path)
}
var applied int
err = backupDb.QueryRowContext(ctx,
"SELECT count(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0").Scan(&applied)
if err != nil {
return fmt.Errorf("reading backup file '%s': %w", path, err)
}
if applied == 0 {
return fmt.Errorf("backup file '%s' has no applied migrations", path)
}
check := "PRAGMA quick_check(1)"
if integrityCheck {
check = "PRAGMA integrity_check(1)"
}
var result string
if err := backupDb.QueryRowContext(ctx, check).Scan(&result); err != nil {
return fmt.Errorf("checking backup file '%s': %w", path, err)
}
if result != "ok" {
return fmt.Errorf("backup file '%s' is damaged: %s", path, result)
}
return nil
}
func Backup(ctx context.Context) (string, error) {
destPath := backupPath(time.Now())
log.Debug(ctx, "Creating backup", "path", destPath)
backupDb, err := openBackupFile(destPath)
if err != nil {
return "", err
}
defer backupDb.Close()
if err := copyDatabase(ctx, backupDb, Db()); err != nil {
return "", err
}
return destPath, nil
}
func Restore(ctx context.Context, path string, integrityCheck bool) error {
log.Debug(ctx, "Restoring backup", "path", path)
if strings.ContainsRune(path, '?') {
return fmt.Errorf("backup path cannot contain '?': %s", path)
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("backup file not available: %w", err)
}
backupDb, err := openBackupFile(path)
if err != nil {
return err
}
defer backupDb.Close()
if err := checkRestoreSource(ctx, backupDb, path, integrityCheck); err != nil {
return err
}
return copyDatabase(ctx, Db(), backupDb)
}
func Prune(ctx context.Context) (int, error) {
backupDir, err := conf.Server.Backup.Path.Path()
if err != nil {
return 0, fmt.Errorf("backup directory not available: %w", err)
}
files, err := os.ReadDir(backupDir)
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
}