diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 3861d0b22..6241a283c 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -5,6 +5,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { excludeImage } databasePath + backupDirectoryPath generatedPath metadataPath scrapersPath diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 35671a10c..48f2de3dc 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -37,6 +37,8 @@ input ConfigGeneralInput { stashes: [StashConfigInput!] """Path to the SQLite database""" databasePath: String + """Path to backup directory""" + backupDirectoryPath: String """Path to generated files""" generatedPath: String """Path to import/export files""" @@ -116,6 +118,8 @@ type ConfigGeneralResult { stashes: [StashConfig!]! """Path to the SQLite database""" databasePath: String! + """Path to backup directory""" + backupDirectoryPath: String! """Path to generated files""" generatedPath: String! """Path to import/export files""" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 9881f46fb..9cf3ac9a4 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -84,6 +84,15 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen c.Set(config.Database, input.DatabasePath) } + existingBackupDirectoryPath := c.GetBackupDirectoryPath() + if input.BackupDirectoryPath != nil && existingBackupDirectoryPath != *input.BackupDirectoryPath { + if err := validateDir(config.BackupDirectoryPath, *input.BackupDirectoryPath, false); err != nil { + return makeConfigGeneralResult(), err + } + + c.Set(config.BackupDirectoryPath, input.BackupDirectoryPath) + } + existingGeneratedPath := c.GetGeneratedPath() if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath { if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil { diff --git a/internal/api/resolver_mutation_metadata.go b/internal/api/resolver_mutation_metadata.go index ff8635536..040dc9fc1 100644 --- a/internal/api/resolver_mutation_metadata.go +++ b/internal/api/resolver_mutation_metadata.go @@ -124,7 +124,13 @@ func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatab backupPath = f.Name() f.Close() } else { - backupPath = database.DatabaseBackupPath() + backupDirectoryPath := mgr.Config.GetBackupDirectoryPathOrDefault() + if backupDirectoryPath != "" { + if err := fsutil.EnsureDir(backupDirectoryPath); err != nil { + return nil, fmt.Errorf("could not create backup directory %v: %w", backupDirectoryPath, err) + } + } + backupPath = database.DatabaseBackupPath(backupDirectoryPath) } err := database.Backup(backupPath) @@ -141,7 +147,7 @@ func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatab baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - fn := filepath.Base(database.DatabaseBackupPath()) + fn := filepath.Base(database.DatabaseBackupPath("")) ret := baseURL + "/downloads/" + downloadHash + "/" + fn return &ret, nil } else { diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 95411977c..adc136206 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -85,6 +85,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { return &ConfigGeneralResult{ Stashes: config.GetStashPaths(), DatabasePath: config.GetDatabasePath(), + BackupDirectoryPath: config.GetBackupDirectoryPath(), GeneratedPath: config.GetGeneratedPath(), MetadataPath: config.GetMetadataPath(), ConfigFilePath: config.GetConfigFile(), diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 85ac935fb..6e453bed5 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -26,15 +26,16 @@ import ( var officialBuild string const ( - Stash = "stash" - Cache = "cache" - Generated = "generated" - Metadata = "metadata" - Downloads = "downloads" - ApiKey = "api_key" - Username = "username" - Password = "password" - MaxSessionAge = "max_session_age" + Stash = "stash" + Cache = "cache" + BackupDirectoryPath = "backup_directory_path" + Generated = "generated" + Metadata = "metadata" + Downloads = "downloads" + ApiKey = "api_key" + Username = "username" + Password = "password" + MaxSessionAge = "max_session_age" DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours @@ -525,6 +526,19 @@ func (i *Instance) GetDatabasePath() string { return i.getString(Database) } +func (i *Instance) GetBackupDirectoryPath() string { + return i.getString(BackupDirectoryPath) +} + +func (i *Instance) GetBackupDirectoryPathOrDefault() string { + ret := i.GetBackupDirectoryPath() + if ret == "" { + return i.GetConfigPath() + } + + return ret +} + func (i *Instance) GetJWTSignKey() []byte { return []byte(i.getString(JWTSignKey)) } @@ -1351,6 +1365,7 @@ func (i *Instance) setDefaultValues(write bool) error { // Set default scrapers and plugins paths i.main.SetDefault(ScrapersPath, defaultScrapersPath) i.main.SetDefault(PluginsPath, defaultPluginsPath) + if write { return i.main.WriteConfig() } diff --git a/internal/manager/config/config_concurrency_test.go b/internal/manager/config/config_concurrency_test.go index a7f1455e6..7b60bfb4c 100644 --- a/internal/manager/config/config_concurrency_test.go +++ b/internal/manager/config/config_concurrency_test.go @@ -26,6 +26,7 @@ func TestConcurrentConfigAccess(t *testing.T) { i.GetConfigFile() i.GetConfigPath() i.GetDefaultDatabaseFilePath() + i.Set(BackupDirectoryPath, i.GetBackupDirectoryPath()) i.GetStashPaths() _ = i.ValidateStashBoxes(nil) _ = i.Validate() diff --git a/internal/manager/manager.go b/internal/manager/manager.go index be871461d..206b80ed2 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -645,7 +645,14 @@ func (s *Manager) Migrate(ctx context.Context, input MigrateInput) error { // migration fails backupPath := input.BackupPath if backupPath == "" { - backupPath = database.DatabaseBackupPath() + backupPath = database.DatabaseBackupPath(s.Config.GetBackupDirectoryPath()) + } else { + // check if backup path is a filename or path + // filename goes into backup directory, path is kept as is + filename := filepath.Base(backupPath) + if backupPath == filename { + backupPath = filepath.Join(s.Config.GetBackupDirectoryPathOrDefault(), filename) + } } // perform database backup diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index bbff77716..07641c85e 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "sync" "time" @@ -253,8 +254,14 @@ func (db *Database) DatabasePath() string { return db.dbPath } -func (db *Database) DatabaseBackupPath() string { - return fmt.Sprintf("%s.%d.%s", db.dbPath, db.schemaVersion, time.Now().Format("20060102_150405")) +func (db *Database) DatabaseBackupPath(backupDirectoryPath string) string { + fn := fmt.Sprintf("%s.%d.%s", db.dbPath, db.schemaVersion, time.Now().Format("20060102_150405")) + + if backupDirectoryPath != "" { + return filepath.Join(backupDirectoryPath, fn) + } + + return fn } func (db *Database) Version() uint { diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index 66de2513f..46d02ba80 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -155,6 +155,14 @@ export const SettingsConfigurationPanel: React.FC = () => { value={general.pythonPath ?? undefined} onChange={(v) => saveGeneral({ pythonPath: v })} /> + + saveGeneral({ backupDirectoryPath: v })} + /> diff --git a/ui/v2.5/src/docs/en/Changelog/v0170.md b/ui/v2.5/src/docs/en/Changelog/v0170.md index 751434442..c98844e75 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0170.md +++ b/ui/v2.5/src/docs/en/Changelog/v0170.md @@ -5,6 +5,7 @@ After migrating, please run a scan on your entire library to populate missing da * Import/export schema has changed and is incompatible with the previous version. ### ✨ New Features +* Add backup location configuration setting. ([#2953](https://github.com/stashapp/stash/pull/2953)) * Allow overriding UI localisation strings. ([#2837](https://github.com/stashapp/stash/pull/2837)) * Populate name from query field when creating new performer/studio/tag/gallery. ([#2701](https://github.com/stashapp/stash/pull/2701)) * Added support for identical files. Identical files are assigned to the same scene/gallery/image and can be viewed in File Info. ([#2676](https://github.com/stashapp/stash/pull/2676)) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 24055f1e3..c8ea82e12 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -244,6 +244,10 @@ "username": "Username", "username_desc": "Username to access Stash. Leave blank to disable user authentication" }, + "backup_directory_path": { + "description": "Directory location for SQLite database file backups", + "heading": "Backup Directory Path" + }, "cache_location": "Directory location of the cache", "cache_path_head": "Cache Path", "calculate_md5_and_ohash_desc": "Calculate MD5 checksum in addition to oshash. Enabling will cause initial scans to be slower. File naming hash must be set to oshash to disable MD5 calculation.",