navidrome/cmd/backup.go

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

146 lines
4.1 KiB
Go
Raw Permalink Normal View History

package cmd
import (
"context"
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085) * fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-12 08:50:45 +08:00
"path/filepath"
"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)
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085) * fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-12 08:50:45 +08:00
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
_ = restoreCommand.MarkFlagRequired("backup-file")
backupRoot.AddCommand(restoreCommand)
}
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 != "" {
refactor(conf): replace eager dir creation with lazy Dir type (#5495) * feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-13 17:44:22 -03:00
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069) * feat(db): add repair command to rebuild a corrupted FTS5 search index A corrupted media_file_fts index made every scan fail with 'database disk image is malformed', and sqlite3's built-in 'rebuild' command cannot repair contentless FTS5 tables, leaving users to hand-drop tables and triggers. Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the reported corruption is confined to the FTS5 search tables, drops and recreates the three tables and their nine triggers and repopulates them from the base tables (which hold all the data, so nothing is lost). The result is verified with the FTS5-native 'integrity-check' command, which reads only the rebuilt indexes instead of re-scanning the whole database (on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second verify). A --rebuild flag forces the rebuild even when the check passes, for silently desynced indexes. The rebuild refuses to run while migrations are pending, and a schema-comparison test guards the duplicated DDL against drifting from the migration. The DbPath existence check and the YES confirmation prompt, previously copy-pasted across the backup commands, are extracted into shared cmd helpers used by both backup and repair. Part of #6067 * fix(db): type the FTS migration version as int64 for 32-bit builds The untyped constant defaults to int, which overflows on arm/v7 and 386. * feat(db): split repair into 'db doctor' and 'search rebuild' commands A single 'db repair' command promised more than it delivered: the only thing it could actually repair was the search index, and its diagnosis and its fix were welded together, so a forced rebuild paid the full integrity check twice. Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA integrity_check and PRAGMA foreign_key_check, and routes the user (to 'search rebuild' when corruption is FTS-only, to backup/.recover otherwise). 'navidrome search rebuild' just rebuilds and verifies the FTS index, which takes ~2s on a prod-size library instead of ~19s. * refactor(cmd): extract a testable doctor function and bound foreign key output Extract the doctor routing (check, classify, advise) into a function that takes an io.Writer, so the advice paths are unit-tested and the process exit happens in the cobra wrapper after the DB is closed (os.Exit was skipping the deferred close, leaving WAL/SHM files behind on the unhealthy paths). Aggregate foreign_key_check by (table, parent): the raw pragma emits one row per orphan, which is unbounded output on a large corrupted library. Also make confirmYES take an io.Reader, drop the unused return from the renamed requireExistingDB, share the FTS table list with the tests, and stop the schema-guard specs from paying for a seeded database they never use. * docs(cmd): promise 'never alters your data' instead of 'never modifies the database' Closing the doctor's connection can checkpoint a stale WAL into the main file (as any SQLite tool does), so the byte-level claim was too strong. The checks themselves are read-only and no logical content ever changes. * fix(cmd): make 'db doctor' advice honest when checks are inconclusive PRAGMA integrity_check stops at 100 errors and emits no marker row, so a saturated result was being read as the whole picture. IntegrityCheck now sets the limit itself and reports saturation as a truncated list, and doctor no longer claims corruption is limited to the search index in that case. Foreign key violations now print a next step instead of only flipping the exit code: migrations run with foreign_keys off, so orphan rows are a realistic leftover on a database that is not corrupt. Also corrects the 'search rebuild' help, which promised that 'db doctor' detects when a rebuild is needed -- integrity_check cannot see an index that is merely out of sync; gives the never-migrated case its intended message instead of a raw 'no such table: goose_db_version'; and extracts rebuildSearchIndex so the database is closed before log.Fatal exits. * refactor(cmd): promote 'db doctor' to a top-level 'doctor' command The 'db' group held a single subcommand, and the checks planned for it reach past the database: config, music folder permissions, external tools. None of those belong under 'db'. Promoting it also evens out the shape of the pair. The command that finds the problem is now top-level alongside 'search rebuild', the command that fixes it, matching the 'brew doctor' convention users already expect. 'db doctor' has never been released, so no alias or deprecation is needed. * refactor(db): tighten the doctor and search rebuild internals Follow-up cleanup with no behaviour change except where noted. integrity_check now asks the pragma for one row beyond the reported limit and treats that extra row as the proof it truncated, instead of inferring truncation from a saturated count. That distinguishes a list of exactly 100 issues from one that was cut short -- the old test could not, and 100 was SQLite's own default, so passing it was a no-op. ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving the prose to the layer that already owns the CLI vocabulary. The goose table probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database ever been migrated' has one spelling. Also folds ftsMigrationApplied into requireFTSMigration, lifts printFindings out of a closure that captured nothing, names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the drift test compares against the full migration chain, not the single frozen migration it claimed. * fix(db): verify the rebuilt search index before committing it RebuildFTS committed its transaction and only then ran the FTS5 integrity check, from the caller. A rebuild that produced a bad index was therefore already persisted by the time anyone noticed, leaving the user worse off than before they ran the command. The check now runs inside the transaction, so a rebuild that does not verify rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB signature for callers outside a transaction; the shared body takes the small execer interface that both *sql.DB and *sql.Tx satisfy. Adds a spec for the rollback: it removes a column the repopulating SELECT reads, so the transaction fails after the drops, and asserts the old index still answers queries. * refactor(cmd): drop the unused io.Reader parameter from confirmYES The reader was added as a test seam that no test ever used: all three callers pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused os import from backup.go and search.go. * fix(cmd): stop promising a scan clears every foreign key violation doctor told the user to run 'navidrome scan -f' for any foreign key violation. SQLStore.GC only purges albums, artists, folders, annotations, bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the next doctor run still reports them. player.user_id references user(id) and no scan phase touches that table at all. The advice now says a scan clears some of them and the rest have to be removed by hand, which keeps the next step the earlier round asked for without claiming a cleanup that does not happen. * docs(db): trim over-long comments on the doctor and rebuild paths Six comments ran past two lines or repeated something already stated nearby. The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which the new 'verifies before committing' sentence already implies, and a spec comment restated that same rationale a second time. * docs: drop em dashes from the comments added in this branch
2026-09-11 22:26:28 -04:00
requireExistingDB()
start := time.Now()
path, err := db.Backup(ctx)
if err != nil {
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085) * fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-12 08:50:45 +08:00
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
log.Info("Backup complete", "elapsed", elapsed, "path", path)
}
func runPrune(ctx context.Context) {
if backupDir != "" {
refactor(conf): replace eager dir creation with lazy Dir type (#5495) * feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan <deluan@navidrome.org> * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan <deluan@navidrome.org> * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan <deluan@navidrome.org>
2026-05-13 17:44:22 -03:00
conf.Server.Backup.Path = conf.NewDir(backupDir)
}
if backupCount != -1 {
conf.Server.Backup.Count = backupCount
}
feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069) * feat(db): add repair command to rebuild a corrupted FTS5 search index A corrupted media_file_fts index made every scan fail with 'database disk image is malformed', and sqlite3's built-in 'rebuild' command cannot repair contentless FTS5 tables, leaving users to hand-drop tables and triggers. Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the reported corruption is confined to the FTS5 search tables, drops and recreates the three tables and their nine triggers and repopulates them from the base tables (which hold all the data, so nothing is lost). The result is verified with the FTS5-native 'integrity-check' command, which reads only the rebuilt indexes instead of re-scanning the whole database (on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second verify). A --rebuild flag forces the rebuild even when the check passes, for silently desynced indexes. The rebuild refuses to run while migrations are pending, and a schema-comparison test guards the duplicated DDL against drifting from the migration. The DbPath existence check and the YES confirmation prompt, previously copy-pasted across the backup commands, are extracted into shared cmd helpers used by both backup and repair. Part of #6067 * fix(db): type the FTS migration version as int64 for 32-bit builds The untyped constant defaults to int, which overflows on arm/v7 and 386. * feat(db): split repair into 'db doctor' and 'search rebuild' commands A single 'db repair' command promised more than it delivered: the only thing it could actually repair was the search index, and its diagnosis and its fix were welded together, so a forced rebuild paid the full integrity check twice. Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA integrity_check and PRAGMA foreign_key_check, and routes the user (to 'search rebuild' when corruption is FTS-only, to backup/.recover otherwise). 'navidrome search rebuild' just rebuilds and verifies the FTS index, which takes ~2s on a prod-size library instead of ~19s. * refactor(cmd): extract a testable doctor function and bound foreign key output Extract the doctor routing (check, classify, advise) into a function that takes an io.Writer, so the advice paths are unit-tested and the process exit happens in the cobra wrapper after the DB is closed (os.Exit was skipping the deferred close, leaving WAL/SHM files behind on the unhealthy paths). Aggregate foreign_key_check by (table, parent): the raw pragma emits one row per orphan, which is unbounded output on a large corrupted library. Also make confirmYES take an io.Reader, drop the unused return from the renamed requireExistingDB, share the FTS table list with the tests, and stop the schema-guard specs from paying for a seeded database they never use. * docs(cmd): promise 'never alters your data' instead of 'never modifies the database' Closing the doctor's connection can checkpoint a stale WAL into the main file (as any SQLite tool does), so the byte-level claim was too strong. The checks themselves are read-only and no logical content ever changes. * fix(cmd): make 'db doctor' advice honest when checks are inconclusive PRAGMA integrity_check stops at 100 errors and emits no marker row, so a saturated result was being read as the whole picture. IntegrityCheck now sets the limit itself and reports saturation as a truncated list, and doctor no longer claims corruption is limited to the search index in that case. Foreign key violations now print a next step instead of only flipping the exit code: migrations run with foreign_keys off, so orphan rows are a realistic leftover on a database that is not corrupt. Also corrects the 'search rebuild' help, which promised that 'db doctor' detects when a rebuild is needed -- integrity_check cannot see an index that is merely out of sync; gives the never-migrated case its intended message instead of a raw 'no such table: goose_db_version'; and extracts rebuildSearchIndex so the database is closed before log.Fatal exits. * refactor(cmd): promote 'db doctor' to a top-level 'doctor' command The 'db' group held a single subcommand, and the checks planned for it reach past the database: config, music folder permissions, external tools. None of those belong under 'db'. Promoting it also evens out the shape of the pair. The command that finds the problem is now top-level alongside 'search rebuild', the command that fixes it, matching the 'brew doctor' convention users already expect. 'db doctor' has never been released, so no alias or deprecation is needed. * refactor(db): tighten the doctor and search rebuild internals Follow-up cleanup with no behaviour change except where noted. integrity_check now asks the pragma for one row beyond the reported limit and treats that extra row as the proof it truncated, instead of inferring truncation from a saturated count. That distinguishes a list of exactly 100 issues from one that was cut short -- the old test could not, and 100 was SQLite's own default, so passing it was a no-op. ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving the prose to the layer that already owns the CLI vocabulary. The goose table probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database ever been migrated' has one spelling. Also folds ftsMigrationApplied into requireFTSMigration, lifts printFindings out of a closure that captured nothing, names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the drift test compares against the full migration chain, not the single frozen migration it claimed. * fix(db): verify the rebuilt search index before committing it RebuildFTS committed its transaction and only then ran the FTS5 integrity check, from the caller. A rebuild that produced a bad index was therefore already persisted by the time anyone noticed, leaving the user worse off than before they ran the command. The check now runs inside the transaction, so a rebuild that does not verify rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB signature for callers outside a transaction; the shared body takes the small execer interface that both *sql.DB and *sql.Tx satisfy. Adds a spec for the rollback: it removes a column the repopulating SELECT reads, so the transaction fails after the drops, and asserts the old index still answers queries. * refactor(cmd): drop the unused io.Reader parameter from confirmYES The reader was added as a test seam that no test ever used: all three callers pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused os import from backup.go and search.go. * fix(cmd): stop promising a scan clears every foreign key violation doctor told the user to run 'navidrome scan -f' for any foreign key violation. SQLStore.GC only purges albums, artists, folders, annotations, bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the next doctor run still reports them. player.user_id references user(id) and no scan phase touches that table at all. The advice now says a scan clears some of them and the rest have to be removed by hand, which keeps the next step the earlier round asked for without claiming a cleanup that does not happen. * docs(db): trim over-long comments on the doctor and rebuild paths Six comments ran past two lines or repeated something already stated nearby. The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which the new 'verifies before committing' sentence already implies, and a spec comment restated that same rationale a second time. * docs: drop em dashes from the comments added in this branch
2026-09-11 22:26:28 -04:00
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
log.Warn("Prune cancelled")
return
}
feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069) * feat(db): add repair command to rebuild a corrupted FTS5 search index A corrupted media_file_fts index made every scan fail with 'database disk image is malformed', and sqlite3's built-in 'rebuild' command cannot repair contentless FTS5 tables, leaving users to hand-drop tables and triggers. Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the reported corruption is confined to the FTS5 search tables, drops and recreates the three tables and their nine triggers and repopulates them from the base tables (which hold all the data, so nothing is lost). The result is verified with the FTS5-native 'integrity-check' command, which reads only the rebuilt indexes instead of re-scanning the whole database (on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second verify). A --rebuild flag forces the rebuild even when the check passes, for silently desynced indexes. The rebuild refuses to run while migrations are pending, and a schema-comparison test guards the duplicated DDL against drifting from the migration. The DbPath existence check and the YES confirmation prompt, previously copy-pasted across the backup commands, are extracted into shared cmd helpers used by both backup and repair. Part of #6067 * fix(db): type the FTS migration version as int64 for 32-bit builds The untyped constant defaults to int, which overflows on arm/v7 and 386. * feat(db): split repair into 'db doctor' and 'search rebuild' commands A single 'db repair' command promised more than it delivered: the only thing it could actually repair was the search index, and its diagnosis and its fix were welded together, so a forced rebuild paid the full integrity check twice. Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA integrity_check and PRAGMA foreign_key_check, and routes the user (to 'search rebuild' when corruption is FTS-only, to backup/.recover otherwise). 'navidrome search rebuild' just rebuilds and verifies the FTS index, which takes ~2s on a prod-size library instead of ~19s. * refactor(cmd): extract a testable doctor function and bound foreign key output Extract the doctor routing (check, classify, advise) into a function that takes an io.Writer, so the advice paths are unit-tested and the process exit happens in the cobra wrapper after the DB is closed (os.Exit was skipping the deferred close, leaving WAL/SHM files behind on the unhealthy paths). Aggregate foreign_key_check by (table, parent): the raw pragma emits one row per orphan, which is unbounded output on a large corrupted library. Also make confirmYES take an io.Reader, drop the unused return from the renamed requireExistingDB, share the FTS table list with the tests, and stop the schema-guard specs from paying for a seeded database they never use. * docs(cmd): promise 'never alters your data' instead of 'never modifies the database' Closing the doctor's connection can checkpoint a stale WAL into the main file (as any SQLite tool does), so the byte-level claim was too strong. The checks themselves are read-only and no logical content ever changes. * fix(cmd): make 'db doctor' advice honest when checks are inconclusive PRAGMA integrity_check stops at 100 errors and emits no marker row, so a saturated result was being read as the whole picture. IntegrityCheck now sets the limit itself and reports saturation as a truncated list, and doctor no longer claims corruption is limited to the search index in that case. Foreign key violations now print a next step instead of only flipping the exit code: migrations run with foreign_keys off, so orphan rows are a realistic leftover on a database that is not corrupt. Also corrects the 'search rebuild' help, which promised that 'db doctor' detects when a rebuild is needed -- integrity_check cannot see an index that is merely out of sync; gives the never-migrated case its intended message instead of a raw 'no such table: goose_db_version'; and extracts rebuildSearchIndex so the database is closed before log.Fatal exits. * refactor(cmd): promote 'db doctor' to a top-level 'doctor' command The 'db' group held a single subcommand, and the checks planned for it reach past the database: config, music folder permissions, external tools. None of those belong under 'db'. Promoting it also evens out the shape of the pair. The command that finds the problem is now top-level alongside 'search rebuild', the command that fixes it, matching the 'brew doctor' convention users already expect. 'db doctor' has never been released, so no alias or deprecation is needed. * refactor(db): tighten the doctor and search rebuild internals Follow-up cleanup with no behaviour change except where noted. integrity_check now asks the pragma for one row beyond the reported limit and treats that extra row as the proof it truncated, instead of inferring truncation from a saturated count. That distinguishes a list of exactly 100 issues from one that was cut short -- the old test could not, and 100 was SQLite's own default, so passing it was a no-op. ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving the prose to the layer that already owns the CLI vocabulary. The goose table probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database ever been migrated' has one spelling. Also folds ftsMigrationApplied into requireFTSMigration, lifts printFindings out of a closure that captured nothing, names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the drift test compares against the full migration chain, not the single frozen migration it claimed. * fix(db): verify the rebuilt search index before committing it RebuildFTS committed its transaction and only then ran the FTS5 integrity check, from the caller. A rebuild that produced a bad index was therefore already persisted by the time anyone noticed, leaving the user worse off than before they ran the command. The check now runs inside the transaction, so a rebuild that does not verify rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB signature for callers outside a transaction; the shared body takes the small execer interface that both *sql.DB and *sql.Tx satisfy. Adds a spec for the rollback: it removes a column the repopulating SELECT reads, so the transaction fails after the drops, and asserts the old index still answers queries. * refactor(cmd): drop the unused io.Reader parameter from confirmYES The reader was added as a test seam that no test ever used: all three callers pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused os import from backup.go and search.go. * fix(cmd): stop promising a scan clears every foreign key violation doctor told the user to run 'navidrome scan -f' for any foreign key violation. SQLStore.GC only purges albums, artists, folders, annotations, bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the next doctor run still reports them. player.user_id references user(id) and no scan phase touches that table at all. The advice now says a scan clears some of them and the rest have to be removed by hand, which keeps the next step the earlier round asked for without claiming a cleanup that does not happen. * docs(db): trim over-long comments on the doctor and rebuild paths Six comments ran past two lines or repeated something already stated nearby. The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which the new 'verifies before committing' sentence already implies, and a spec comment restated that same rationale a second time. * docs: drop em dashes from the comments added in this branch
2026-09-11 22:26:28 -04:00
requireExistingDB()
start := time.Now()
count, err := db.Prune(ctx)
if err != nil {
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085) * fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-12 08:50:45 +08:00
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
}
elapsed := time.Since(start)
log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count)
}
func runRestore(ctx context.Context) {
feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069) * feat(db): add repair command to rebuild a corrupted FTS5 search index A corrupted media_file_fts index made every scan fail with 'database disk image is malformed', and sqlite3's built-in 'rebuild' command cannot repair contentless FTS5 tables, leaving users to hand-drop tables and triggers. Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the reported corruption is confined to the FTS5 search tables, drops and recreates the three tables and their nine triggers and repopulates them from the base tables (which hold all the data, so nothing is lost). The result is verified with the FTS5-native 'integrity-check' command, which reads only the rebuilt indexes instead of re-scanning the whole database (on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second verify). A --rebuild flag forces the rebuild even when the check passes, for silently desynced indexes. The rebuild refuses to run while migrations are pending, and a schema-comparison test guards the duplicated DDL against drifting from the migration. The DbPath existence check and the YES confirmation prompt, previously copy-pasted across the backup commands, are extracted into shared cmd helpers used by both backup and repair. Part of #6067 * fix(db): type the FTS migration version as int64 for 32-bit builds The untyped constant defaults to int, which overflows on arm/v7 and 386. * feat(db): split repair into 'db doctor' and 'search rebuild' commands A single 'db repair' command promised more than it delivered: the only thing it could actually repair was the search index, and its diagnosis and its fix were welded together, so a forced rebuild paid the full integrity check twice. Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA integrity_check and PRAGMA foreign_key_check, and routes the user (to 'search rebuild' when corruption is FTS-only, to backup/.recover otherwise). 'navidrome search rebuild' just rebuilds and verifies the FTS index, which takes ~2s on a prod-size library instead of ~19s. * refactor(cmd): extract a testable doctor function and bound foreign key output Extract the doctor routing (check, classify, advise) into a function that takes an io.Writer, so the advice paths are unit-tested and the process exit happens in the cobra wrapper after the DB is closed (os.Exit was skipping the deferred close, leaving WAL/SHM files behind on the unhealthy paths). Aggregate foreign_key_check by (table, parent): the raw pragma emits one row per orphan, which is unbounded output on a large corrupted library. Also make confirmYES take an io.Reader, drop the unused return from the renamed requireExistingDB, share the FTS table list with the tests, and stop the schema-guard specs from paying for a seeded database they never use. * docs(cmd): promise 'never alters your data' instead of 'never modifies the database' Closing the doctor's connection can checkpoint a stale WAL into the main file (as any SQLite tool does), so the byte-level claim was too strong. The checks themselves are read-only and no logical content ever changes. * fix(cmd): make 'db doctor' advice honest when checks are inconclusive PRAGMA integrity_check stops at 100 errors and emits no marker row, so a saturated result was being read as the whole picture. IntegrityCheck now sets the limit itself and reports saturation as a truncated list, and doctor no longer claims corruption is limited to the search index in that case. Foreign key violations now print a next step instead of only flipping the exit code: migrations run with foreign_keys off, so orphan rows are a realistic leftover on a database that is not corrupt. Also corrects the 'search rebuild' help, which promised that 'db doctor' detects when a rebuild is needed -- integrity_check cannot see an index that is merely out of sync; gives the never-migrated case its intended message instead of a raw 'no such table: goose_db_version'; and extracts rebuildSearchIndex so the database is closed before log.Fatal exits. * refactor(cmd): promote 'db doctor' to a top-level 'doctor' command The 'db' group held a single subcommand, and the checks planned for it reach past the database: config, music folder permissions, external tools. None of those belong under 'db'. Promoting it also evens out the shape of the pair. The command that finds the problem is now top-level alongside 'search rebuild', the command that fixes it, matching the 'brew doctor' convention users already expect. 'db doctor' has never been released, so no alias or deprecation is needed. * refactor(db): tighten the doctor and search rebuild internals Follow-up cleanup with no behaviour change except where noted. integrity_check now asks the pragma for one row beyond the reported limit and treats that extra row as the proof it truncated, instead of inferring truncation from a saturated count. That distinguishes a list of exactly 100 issues from one that was cut short -- the old test could not, and 100 was SQLite's own default, so passing it was a no-op. ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving the prose to the layer that already owns the CLI vocabulary. The goose table probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database ever been migrated' has one spelling. Also folds ftsMigrationApplied into requireFTSMigration, lifts printFindings out of a closure that captured nothing, names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the drift test compares against the full migration chain, not the single frozen migration it claimed. * fix(db): verify the rebuilt search index before committing it RebuildFTS committed its transaction and only then ran the FTS5 integrity check, from the caller. A rebuild that produced a bad index was therefore already persisted by the time anyone noticed, leaving the user worse off than before they ran the command. The check now runs inside the transaction, so a rebuild that does not verify rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB signature for callers outside a transaction; the shared body takes the small execer interface that both *sql.DB and *sql.Tx satisfy. Adds a spec for the rollback: it removes a column the repopulating SELECT reads, so the transaction fails after the drops, and asserts the old index still answers queries. * refactor(cmd): drop the unused io.Reader parameter from confirmYES The reader was added as a test seam that no test ever used: all three callers pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused os import from backup.go and search.go. * fix(cmd): stop promising a scan clears every foreign key violation doctor told the user to run 'navidrome scan -f' for any foreign key violation. SQLStore.GC only purges albums, artists, folders, annotations, bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the next doctor run still reports them. player.user_id references user(id) and no scan phase touches that table at all. The advice now says a scan clears some of them and the rest have to be removed by hand, which keeps the next step the earlier round asked for without claiming a cleanup that does not happen. * docs(db): trim over-long comments on the doctor and rebuild paths Six comments ran past two lines or repeated something already stated nearby. The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which the new 'verifies before committing' sentence already implies, and a spec comment restated that same rationale a second time. * docs: drop em dashes from the comments added in this branch
2026-09-11 22:26:28 -04:00
requireExistingDB()
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085) * fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-12 08:50:45 +08:00
// A relative --backup-file is resolved against Backup.Path, the same folder
// `backup create` writes to. Without this, the value was treated as relative
// to the working directory, where the file does not exist.
if !filepath.IsAbs(restorePath) {
backupPath, err := conf.Server.Backup.Path.Path()
if err != nil {
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
return
}
restorePath = filepath.Join(backupPath, restorePath)
}
feat(cli): add 'doctor' and 'search rebuild' commands to recover from FTS5 corruption (#6069) * feat(db): add repair command to rebuild a corrupted FTS5 search index A corrupted media_file_fts index made every scan fail with 'database disk image is malformed', and sqlite3's built-in 'rebuild' command cannot repair contentless FTS5 tables, leaving users to hand-drop tables and triggers. Add 'navidrome db repair': it runs PRAGMA integrity_check, and when the reported corruption is confined to the FTS5 search tables, drops and recreates the three tables and their nine triggers and repopulates them from the base tables (which hold all the data, so nothing is lost). The result is verified with the FTS5-native 'integrity-check' command, which reads only the rebuilt indexes instead of re-scanning the whole database (on a 761MB production copy: ~9s full check, ~1s rebuild, sub-second verify). A --rebuild flag forces the rebuild even when the check passes, for silently desynced indexes. The rebuild refuses to run while migrations are pending, and a schema-comparison test guards the duplicated DDL against drifting from the migration. The DbPath existence check and the YES confirmation prompt, previously copy-pasted across the backup commands, are extracted into shared cmd helpers used by both backup and repair. Part of #6067 * fix(db): type the FTS migration version as int64 for 32-bit builds The untyped constant defaults to int, which overflows on arm/v7 and 386. * feat(db): split repair into 'db doctor' and 'search rebuild' commands A single 'db repair' command promised more than it delivered: the only thing it could actually repair was the search index, and its diagnosis and its fix were welded together, so a forced rebuild paid the full integrity check twice. Split it: 'navidrome db doctor' is strictly read-only, runs both PRAGMA integrity_check and PRAGMA foreign_key_check, and routes the user (to 'search rebuild' when corruption is FTS-only, to backup/.recover otherwise). 'navidrome search rebuild' just rebuilds and verifies the FTS index, which takes ~2s on a prod-size library instead of ~19s. * refactor(cmd): extract a testable doctor function and bound foreign key output Extract the doctor routing (check, classify, advise) into a function that takes an io.Writer, so the advice paths are unit-tested and the process exit happens in the cobra wrapper after the DB is closed (os.Exit was skipping the deferred close, leaving WAL/SHM files behind on the unhealthy paths). Aggregate foreign_key_check by (table, parent): the raw pragma emits one row per orphan, which is unbounded output on a large corrupted library. Also make confirmYES take an io.Reader, drop the unused return from the renamed requireExistingDB, share the FTS table list with the tests, and stop the schema-guard specs from paying for a seeded database they never use. * docs(cmd): promise 'never alters your data' instead of 'never modifies the database' Closing the doctor's connection can checkpoint a stale WAL into the main file (as any SQLite tool does), so the byte-level claim was too strong. The checks themselves are read-only and no logical content ever changes. * fix(cmd): make 'db doctor' advice honest when checks are inconclusive PRAGMA integrity_check stops at 100 errors and emits no marker row, so a saturated result was being read as the whole picture. IntegrityCheck now sets the limit itself and reports saturation as a truncated list, and doctor no longer claims corruption is limited to the search index in that case. Foreign key violations now print a next step instead of only flipping the exit code: migrations run with foreign_keys off, so orphan rows are a realistic leftover on a database that is not corrupt. Also corrects the 'search rebuild' help, which promised that 'db doctor' detects when a rebuild is needed -- integrity_check cannot see an index that is merely out of sync; gives the never-migrated case its intended message instead of a raw 'no such table: goose_db_version'; and extracts rebuildSearchIndex so the database is closed before log.Fatal exits. * refactor(cmd): promote 'db doctor' to a top-level 'doctor' command The 'db' group held a single subcommand, and the checks planned for it reach past the database: config, music folder permissions, external tools. None of those belong under 'db'. Promoting it also evens out the shape of the pair. The command that finds the problem is now top-level alongside 'search rebuild', the command that fixes it, matching the 'brew doctor' convention users already expect. 'db doctor' has never been released, so no alias or deprecation is needed. * refactor(db): tighten the doctor and search rebuild internals Follow-up cleanup with no behaviour change except where noted. integrity_check now asks the pragma for one row beyond the reported limit and treats that extra row as the proof it truncated, instead of inferring truncation from a saturated count. That distinguishes a list of exactly 100 issues from one that was cut short -- the old test could not, and 100 was SQLite's own default, so passing it was a no-op. ForeignKeyCheck returns []FKViolation instead of pre-formatted English, moving the prose to the layer that already owns the CLI vocabulary. The goose table probe shared with isSchemaEmpty becomes hasGooseTable, so 'has this database ever been migrated' has one spelling. Also folds ftsMigrationApplied into requireFTSMigration, lifts printFindings out of a closure that captured nothing, names the FTS trigger suffixes once, and corrects the ftsSchemaDDL comment: the drift test compares against the full migration chain, not the single frozen migration it claimed. * fix(db): verify the rebuilt search index before committing it RebuildFTS committed its transaction and only then ran the FTS5 integrity check, from the caller. A rebuild that produced a bad index was therefore already persisted by the time anyone noticed, leaving the user worse off than before they ran the command. The check now runs inside the transaction, so a rebuild that does not verify rolls back and leaves the original index in place. VerifyFTS keeps its *sql.DB signature for callers outside a transaction; the shared body takes the small execer interface that both *sql.DB and *sql.Tx satisfy. Adds a spec for the rollback: it removes a column the repopulating SELECT reads, so the transaction fails after the drops, and asserts the old index still answers queries. * refactor(cmd): drop the unused io.Reader parameter from confirmYES The reader was added as a test seam that no test ever used: all three callers pass os.Stdin. Back to fmt.Scanln, which drops the parameter and the now-unused os import from backup.go and search.go. * fix(cmd): stop promising a scan clears every foreign key violation doctor told the user to run 'navidrome scan -f' for any foreign key violation. SQLStore.GC only purges albums, artists, folders, annotations, bookmarks, tags and playlist tracks, so orphans elsewhere survive it and the next doctor run still reports them. player.user_id references user(id) and no scan phase touches that table at all. The advice now says a scan clears some of them and the rest have to be removed by hand, which keeps the next step the earlier round asked for without claiming a cleanup that does not happen. * docs(db): trim over-long comments on the doctor and rebuild paths Six comments ran past two lines or repeated something already stated nearby. The RebuildFTS doc claimed the rebuild rolls back on a column mismatch, which the new 'verifies before committing' sentence already implies, and a spec comment restated that same rationale a second time. * docs: drop em dashes from the comments added in this branch
2026-09-11 22:26:28 -04:00
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
log.Warn("Restore cancelled")
return
}
start := time.Now()
err := db.Restore(ctx, restorePath)
if err != nil {
fix(cli): fail restore when the backup file does not exist instead of wiping the database (#6085) * fix(db): fail restore when the backup file does not exist instead of wiping the database `navidrome backup restore -b <file>` passed the flag value straight to the SQLite driver, which opens databases with SQLITE_OPEN_CREATE by default. If the file was not found (for example a file name relative to the working directory instead of the backup directory), the driver silently created an empty database and the backup API copied that emptiness over the live database, reporting 'Restore complete' with an empty instance afterwards. Two changes: - db.Restore now opens the backup file read-only, so a missing file is an error and nothing gets created or overwritten. - A relative --backup-file is resolved against Backup.Path, the same folder 'backup create' writes to; absolute paths keep working as before. Fixes #6083 * fix(db): stat the backup file instead of opening it read-only The read-only DSN added in the previous commit works for the reported case but breaks on other paths: 'file:' + path is parsed as a URI, so a '#' truncates the path and a '%' sequence is percent-decoded, and a read-only open of a WAL database leaves '-shm'/'-wal' sidecars next to the backup. Those sidecars then matched the unanchored prune regex, so 'backup prune -k 3' right after a restore deleted real backups and kept one. Stat the file before opening it and keep passing the plain path to the driver. Paths containing '?' are rejected, since go-sqlite3 splits the DSN there and would otherwise open (and create) a different file. The prune regex is anchored so sidecars are never counted as backups. Also fixes the restore/backup/prune error logs, which printed BasePath (the web URL prefix) instead of the backup location. --------- Co-authored-by: Deluan <deluan@navidrome.org>
2026-09-12 08:50:45 +08:00
log.Fatal("Error restoring database", "backupFile", restorePath, err)
}
elapsed := time.Since(start)
log.Info("Restore complete", "elapsed", elapsed)
}