diff --git a/src/NzbDrone.Api/Config/HostConfigModule.cs b/src/NzbDrone.Api/Config/HostConfigModule.cs index d661a5ecf..d17247ba1 100644 --- a/src/NzbDrone.Api/Config/HostConfigModule.cs +++ b/src/NzbDrone.Api/Config/HostConfigModule.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.IO; +using System.Linq; using System.Reflection; using FluentValidation; using NzbDrone.Common.EnvironmentInfo; @@ -46,6 +47,10 @@ namespace NzbDrone.Api.Config SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default"); SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script); + + SharedValidator.RuleFor(c => c.BackupFolder).IsValidPath().When(c => Path.IsPathRooted(c.BackupFolder)); + SharedValidator.RuleFor(c => c.BackupInterval).InclusiveBetween(1, 7); + SharedValidator.RuleFor(c => c.BackupRetention).InclusiveBetween(1, 90); } private HostConfigResource GetHostConfig() diff --git a/src/NzbDrone.Api/Config/HostConfigResource.cs b/src/NzbDrone.Api/Config/HostConfigResource.cs index 90d51ab19..4befaf489 100644 --- a/src/NzbDrone.Api/Config/HostConfigResource.cs +++ b/src/NzbDrone.Api/Config/HostConfigResource.cs @@ -1,4 +1,4 @@ -using Sonarr.Http.REST; +using Sonarr.Http.REST; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; @@ -33,6 +33,9 @@ namespace NzbDrone.Api.Config public string ProxyPassword { get; set; } public string ProxyBypassFilter { get; set; } public bool ProxyBypassLocalAddresses { get; set; } + public string BackupFolder { get; set; } + public int BackupInterval { get; set; } + public int BackupRetention { get; set; } } public static class HostConfigResourceMapper @@ -66,7 +69,10 @@ namespace NzbDrone.Api.Config ProxyUsername = configService.ProxyUsername, ProxyPassword = configService.ProxyPassword, ProxyBypassFilter = configService.ProxyBypassFilter, - ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses + ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses, + BackupFolder = configService.BackupFolder, + BackupInterval = configService.BackupInterval, + BackupRetention = configService.BackupRetention }; } } diff --git a/src/NzbDrone.Common/ArchiveService.cs b/src/NzbDrone.Common/ArchiveService.cs index 02aa777c9..2a45d7215 100644 --- a/src/NzbDrone.Common/ArchiveService.cs +++ b/src/NzbDrone.Common/ArchiveService.cs @@ -1,10 +1,10 @@ -using System.IO; +using System; +using System.IO; using ICSharpCode.SharpZipLib.Core; using ICSharpCode.SharpZipLib.GZip; using ICSharpCode.SharpZipLib.Tar; using ICSharpCode.SharpZipLib.Zip; using NLog; -using NzbDrone.Common.EnvironmentInfo; namespace NzbDrone.Common { @@ -27,11 +27,10 @@ namespace NzbDrone.Common { _logger.Debug("Extracting archive [{0}] to [{1}]", compressedFile, destination); - if (OsInfo.IsWindows) + if (compressedFile.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) { ExtractZip(compressedFile, destination); } - else { ExtractTgz(compressedFile, destination); @@ -120,4 +119,4 @@ namespace NzbDrone.Common } } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index ca3ead7cd..916bcceb9 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -474,5 +474,13 @@ namespace NzbDrone.Common.Disk } } } + + public void SaveStream(Stream stream, string path) + { + using (var fileStream = OpenWriteStream(path)) + { + stream.CopyTo(fileStream); + } + } } } diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 5ed461fbb..f98529ead 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Security.AccessControl; @@ -48,5 +48,6 @@ namespace NzbDrone.Common.Disk List GetDirectoryInfos(string path); List GetFileInfos(string path); void RemoveEmptySubfolders(string path); + void SaveStream(Stream stream, string path); } } diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index fefa21c61..da7dd1d46 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -12,10 +12,10 @@ namespace NzbDrone.Common.Extensions { private const string APP_CONFIG_FILE = "config.xml"; private const string DB = "sonarr.db"; + private const string DB_RESTORE = "sonarr.restore"; private const string LOG_DB = "logs.db"; private const string NLOG_CONFIG_FILE = "nlog.config"; private const string UPDATE_CLIENT_EXE = "Sonarr.Update.exe"; - private const string BACKUP_FOLDER = "Backups"; private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "sonarr_update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "Sonarr" + Path.DirectorySeparatorChar; @@ -274,16 +274,16 @@ namespace NzbDrone.Common.Extensions return Path.Combine(GetUpdateSandboxFolder(appFolderInfo), UPDATE_CLIENT_EXE); } - public static string GetBackupFolder(this IAppFolderInfo appFolderInfo) - { - return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_FOLDER); - } - public static string GetDatabase(this IAppFolderInfo appFolderInfo) { return Path.Combine(GetAppDataPath(appFolderInfo), DB); } + public static string GetDatabaseRestore(this IAppFolderInfo appFolderInfo) + { + return Path.Combine(GetAppDataPath(appFolderInfo), DB_RESTORE); + } + public static string GetLogDatabase(this IAppFolderInfo appFolderInfo) { return Path.Combine(GetAppDataPath(appFolderInfo), LOG_DB); diff --git a/src/NzbDrone.Core/Backup/BackupService.cs b/src/NzbDrone.Core/Backup/BackupService.cs index 3bd564b2b..ea88a294d 100644 --- a/src/NzbDrone.Core/Backup/BackupService.cs +++ b/src/NzbDrone.Core/Backup/BackupService.cs @@ -1,17 +1,17 @@ -using System; +using System; using System.Collections.Generic; -using System.Data; -using System.Data.SQLite; using System.IO; using System.Linq; +using System.Net; +using System.Text; using System.Text.RegularExpressions; -using Marr.Data; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Commands; @@ -21,6 +21,9 @@ namespace NzbDrone.Core.Backup { void Backup(BackupType backupType); List GetBackups(); + void Restore(string backupFileName); + string GetBackupFolder(); + string GetBackupFolder(BackupType backupType); } public class BackupService : IBackupService, IExecute @@ -31,11 +34,12 @@ namespace NzbDrone.Core.Backup private readonly IDiskProvider _diskProvider; private readonly IAppFolderInfo _appFolderInfo; private readonly IArchiveService _archiveService; + private readonly IConfigService _configService; private readonly Logger _logger; private string _backupTempFolder; - private static readonly Regex BackupFileRegex = new Regex(@"nzbdrone_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex BackupFileRegex = new Regex(@"(nzbdrone|sonarr)_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); public BackupService(IMainDatabase maindDb, IMakeDatabaseBackup makeDatabaseBackup, @@ -43,6 +47,7 @@ namespace NzbDrone.Core.Backup IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, IArchiveService archiveService, + IConfigService configService, Logger logger) { _maindDb = maindDb; @@ -51,9 +56,10 @@ namespace NzbDrone.Core.Backup _diskProvider = diskProvider; _appFolderInfo = appFolderInfo; _archiveService = archiveService; + _configService = configService; _logger = logger; - _backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "nzbdrone_backup"); + _backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "sonarr_backup"); } public void Backup(BackupType backupType) @@ -63,7 +69,7 @@ namespace NzbDrone.Core.Backup _diskProvider.EnsureFolder(_backupTempFolder); _diskProvider.EnsureFolder(GetBackupFolder(backupType)); - var backupFilename = string.Format("nzbdrone_backup_{0:yyyy.MM.dd_HH.mm.ss}.zip", DateTime.Now); + var backupFilename = string.Format("sonarr_backup_{0:yyyy.MM.dd_HH.mm.ss}.zip", DateTime.Now); var backupPath = Path.Combine(GetBackupFolder(backupType), backupFilename); Cleanup(); @@ -75,9 +81,15 @@ namespace NzbDrone.Core.Backup BackupConfigFile(); BackupDatabase(); + CreateVersionInfo(); _logger.ProgressDebug("Creating backup zip"); + + // Delete journal file created during database backup + _diskProvider.DeleteFile(Path.Combine(_backupTempFolder, "sonarr.db-journal")); + _archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, SearchOption.TopDirectoryOnly)); + _logger.ProgressDebug("Backup zip created"); } @@ -103,6 +115,68 @@ namespace NzbDrone.Core.Backup return backups; } + public void Restore(string backupFileName) + { + if (backupFileName.EndsWith(".zip")) + { + var restoredFile = false; + var temporaryPath = Path.Combine(_appFolderInfo.TempFolder, "sonarr_backup_restore"); + + _archiveService.Extract(backupFileName, temporaryPath); + + foreach (var file in _diskProvider.GetFiles(temporaryPath, SearchOption.TopDirectoryOnly)) + { + var fileName = Path.GetFileName(file); + + if (fileName.Equals("Config.xml", StringComparison.InvariantCultureIgnoreCase)) + { + _diskProvider.MoveFile(file, _appFolderInfo.GetConfigPath(), true); + restoredFile = true; + } + + if (fileName.Equals("nzbdrone.db", StringComparison.InvariantCultureIgnoreCase)) + { + _diskProvider.MoveFile(file, _appFolderInfo.GetDatabaseRestore(), true); + restoredFile = true; + } + + if (fileName.Equals("sonarr.db", StringComparison.InvariantCultureIgnoreCase)) + { + _diskProvider.MoveFile(file, _appFolderInfo.GetDatabaseRestore(), true); + restoredFile = true; + } + } + + if (!restoredFile) + { + throw new RestoreBackupFailedException(HttpStatusCode.NotFound, "Unable to restore database file from backup"); + } + + _diskProvider.DeleteFolder(temporaryPath, true); + + return; + } + + _diskProvider.MoveFile(backupFileName, _appFolderInfo.GetDatabaseRestore(), true); + } + + public string GetBackupFolder() + { + var backupFolder = _configService.BackupFolder; + + if (Path.IsPathRooted(backupFolder)) + { + return backupFolder; + } + + return Path.Combine(_appFolderInfo.GetAppDataPath(), backupFolder); + } + + public string GetBackupFolder(BackupType backupType) + { + return Path.Combine(GetBackupFolder(), backupType.ToString().ToLower()); + } + private void Cleanup() { if (_diskProvider.FolderExists(_backupTempFolder)) @@ -128,16 +202,25 @@ namespace NzbDrone.Core.Backup _diskTransferService.TransferFile(configFile, tempConfigFile, TransferMode.Copy); } + private void CreateVersionInfo() + { + var builder = new StringBuilder(); + + builder.AppendLine(BuildInfo.Version.ToString()); + } + private void CleanupOldBackups(BackupType backupType) { - _logger.Debug("Cleaning up old backup files"); + var retention = _configService.BackupRetention; + + _logger.Debug("Cleaning up backup files older than {0} days", retention); var files = GetBackupFiles(GetBackupFolder(backupType)); foreach (var file in files) { var lastWriteTime = _diskProvider.FileGetLastWrite(file); - if (lastWriteTime.AddDays(28) < DateTime.UtcNow) + if (lastWriteTime.AddDays(retention) < DateTime.UtcNow) { _logger.Debug("Deleting old backup file: {0}", file); _diskProvider.DeleteFile(file); @@ -147,11 +230,6 @@ namespace NzbDrone.Core.Backup _logger.Debug("Finished cleaning up old backup files"); } - private string GetBackupFolder(BackupType backupType) - { - return Path.Combine(_appFolderInfo.GetBackupFolder(), backupType.ToString().ToLower()); - } - private IEnumerable GetBackupFiles(string path) { var files = _diskProvider.GetFiles(path, SearchOption.TopDirectoryOnly); diff --git a/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs b/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs new file mode 100644 index 000000000..3a06b1b1b --- /dev/null +++ b/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs @@ -0,0 +1,16 @@ +using System.Net; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.Backup +{ + public class RestoreBackupFailedException : NzbDroneClientException + { + public RestoreBackupFailedException(HttpStatusCode statusCode, string message, params object[] args) : base(statusCode, message, args) + { + } + + public RestoreBackupFailedException(HttpStatusCode statusCode, string message) : base(statusCode, message) + { + } + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 00870dfb7..f7144cf04 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.Linq; using NLog; using NzbDrone.Common.EnsureThat; -using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; @@ -331,6 +330,12 @@ namespace NzbDrone.Core.Configuration public bool ProxyBypassLocalAddresses => GetValueBoolean("ProxyBypassLocalAddresses", true); + public string BackupFolder => GetValue("BackupFolder", "Backups"); + + public int BackupInterval => GetValueInt("BackupInterval", 7); + + public int BackupRetention => GetValueInt("BackupRetention", 28); + private string GetValue(string key) { return GetValue(key, string.Empty); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 17c3a6a17..c60284ee0 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -76,5 +76,10 @@ namespace NzbDrone.Core.Configuration string ProxyPassword { get; } string ProxyBypassFilter { get; } bool ProxyBypassLocalAddresses { get; } + + // Backups + string BackupFolder { get; } + int BackupInterval { get; } + int BackupRetention { get; } } } diff --git a/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs b/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs new file mode 100644 index 000000000..4be69f5d0 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs @@ -0,0 +1,56 @@ +using System; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; + +namespace NzbDrone.Core.Datastore +{ + public interface IRestoreDatabase + { + void Restore(); + } + + public class DatabaseRestorationService : IRestoreDatabase + { + private readonly IDiskProvider _diskProvider; + private readonly IAppFolderInfo _appFolderInfo; + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DatabaseRestorationService)); + + public DatabaseRestorationService(IDiskProvider diskProvider, IAppFolderInfo appFolderInfo) + { + _diskProvider = diskProvider; + _appFolderInfo = appFolderInfo; + } + + public void Restore() + { + var dbRestorePath = _appFolderInfo.GetDatabaseRestore(); + + if (!_diskProvider.FileExists(dbRestorePath)) + { + return; + } + + try + { + Logger.Info("Restoring Database"); + + var dbPath = _appFolderInfo.GetDatabase(); + + _diskProvider.DeleteFile(dbPath + "-shm"); + _diskProvider.DeleteFile(dbPath + "-wal"); + _diskProvider.DeleteFile(dbPath + "-journal"); + _diskProvider.DeleteFile(dbPath); + + _diskProvider.MoveFile(dbRestorePath, dbPath); + } + catch (Exception e) + { + Logger.Error(e, "Failed to restore database"); + throw; + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/DbFactory.cs b/src/NzbDrone.Core/Datastore/DbFactory.cs index e49df1c21..a871fcd07 100644 --- a/src/NzbDrone.Core/Datastore/DbFactory.cs +++ b/src/NzbDrone.Core/Datastore/DbFactory.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Data.SQLite; using Marr.Data; using Marr.Data.Reflection; @@ -24,6 +24,7 @@ namespace NzbDrone.Core.Datastore private readonly IMigrationController _migrationController; private readonly IConnectionStringFactory _connectionStringFactory; private readonly IDiskProvider _diskProvider; + private readonly IRestoreDatabase _restoreDatabaseService; static DbFactory() { @@ -54,11 +55,13 @@ namespace NzbDrone.Core.Datastore public DbFactory(IMigrationController migrationController, IConnectionStringFactory connectionStringFactory, - IDiskProvider diskProvider) + IDiskProvider diskProvider, + IRestoreDatabase restoreDatabaseService) { _migrationController = migrationController; _connectionStringFactory = connectionStringFactory; _diskProvider = diskProvider; + _restoreDatabaseService = restoreDatabaseService; } public IDatabase Create(MigrationType migrationType = MigrationType.Main) @@ -70,17 +73,20 @@ namespace NzbDrone.Core.Datastore { string connectionString; - switch (migrationContext.MigrationType) { case MigrationType.Main: { connectionString = _connectionStringFactory.MainDbConnectionString; + CreateMain(connectionString, migrationContext); + break; } case MigrationType.Log: { connectionString = _connectionStringFactory.LogDbConnectionString; + CreateLog(connectionString, migrationContext); + break; } default: @@ -89,44 +95,6 @@ namespace NzbDrone.Core.Datastore } } - try - { - _migrationController.Migrate(connectionString, migrationContext); - } - catch (SQLiteException ex) - { - var fileName = _connectionStringFactory.GetDatabasePath(connectionString); - - if (migrationContext.MigrationType == MigrationType.Log) - { - Logger.Error(ex, "Logging database is corrupt, attempting to recreate it automatically"); - - try - { - _diskProvider.DeleteFile(fileName + "-shm"); - _diskProvider.DeleteFile(fileName + "-wal"); - _diskProvider.DeleteFile(fileName + "-journal"); - _diskProvider.DeleteFile(fileName); - } - catch (Exception) - { - Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually."); - } - - _migrationController.Migrate(connectionString, migrationContext); - } - - else - { - if (OsInfo.IsOsx) - { - throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-use-sonarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", ex, fileName); - } - - throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", ex, fileName); - } - } - var db = new Database(migrationContext.MigrationType.ToString(), () => { var dataMapper = new DataMapper(SQLiteFactory.Instance, connectionString) @@ -139,5 +107,53 @@ namespace NzbDrone.Core.Datastore return db; } + + private void CreateMain(string connectionString, MigrationContext migrationContext) + { + try + { + _restoreDatabaseService.Restore(); + _migrationController.Migrate(connectionString, migrationContext); + } + catch (SQLiteException e) + { + var fileName = _connectionStringFactory.GetDatabasePath(connectionString); + + if (OsInfo.IsOsx) + { + throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-use-sonarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", e, fileName); + } + + throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", e, fileName); + } + } + + private void CreateLog(string connectionString, MigrationContext migrationContext) + { + try + { + _migrationController.Migrate(connectionString, migrationContext); + } + catch (SQLiteException e) + { + var fileName = _connectionStringFactory.GetDatabasePath(connectionString); + + Logger.Error(e, "Logging database is corrupt, attempting to recreate it automatically"); + + try + { + _diskProvider.DeleteFile(fileName + "-shm"); + _diskProvider.DeleteFile(fileName + "-wal"); + _diskProvider.DeleteFile(fileName + "-journal"); + _diskProvider.DeleteFile(fileName); + } + catch (Exception) + { + Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually."); + } + + _migrationController.Migrate(connectionString, migrationContext); + } + } } } diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index 5756a1084..02773759a 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using NLog; @@ -67,7 +67,12 @@ namespace NzbDrone.Core.Jobs new ScheduledTask{ Interval = 6*60, TypeName = typeof(CheckHealthCommand).FullName}, new ScheduledTask{ Interval = 12*60, TypeName = typeof(RefreshSeriesCommand).FullName}, new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName}, - new ScheduledTask{ Interval = 7*24*60, TypeName = typeof(BackupCommand).FullName}, + + new ScheduledTask + { + Interval = GetBackupInterval(), + TypeName = typeof(BackupCommand).FullName + }, new ScheduledTask { @@ -104,6 +109,13 @@ namespace NzbDrone.Core.Jobs } } + private int GetBackupInterval() + { + var interval = _configService.BackupInterval; + + return interval * 60 * 24; + } + private int GetRssSyncInterval() { var interval = _configService.RssSyncInterval; diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 753741ff7..908f383ff 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -125,6 +125,7 @@ + @@ -185,6 +186,7 @@ + diff --git a/src/NzbDrone.Host/Router.cs b/src/NzbDrone.Host/Router.cs index db6f347b9..bb047a514 100644 --- a/src/NzbDrone.Host/Router.cs +++ b/src/NzbDrone.Host/Router.cs @@ -104,7 +104,5 @@ namespace NzbDrone.Host } } } - - } } diff --git a/src/Sonarr.Api.V3/Config/HostConfigModule.cs b/src/Sonarr.Api.V3/Config/HostConfigModule.cs index 64eb27319..121449649 100644 --- a/src/Sonarr.Api.V3/Config/HostConfigModule.cs +++ b/src/Sonarr.Api.V3/Config/HostConfigModule.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.IO; +using System.Linq; using System.Reflection; using FluentValidation; using NzbDrone.Common.EnvironmentInfo; @@ -46,6 +47,11 @@ namespace Sonarr.Api.V3.Config SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default"); SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script); + + SharedValidator.RuleFor(c => c.BackupFolder).IsValidPath().When(c => Path.IsPathRooted(c.BackupFolder)); + SharedValidator.RuleFor(c => c.BackupInterval).InclusiveBetween(1, 7); + SharedValidator.RuleFor(c => c.BackupRetention).InclusiveBetween(1, 90); + } private HostConfigResource GetHostConfig() @@ -75,6 +81,7 @@ namespace Sonarr.Api.V3.Config .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); _configFileProvider.SaveConfigDictionary(dictionary); + _configService.SaveConfigDictionary(dictionary); if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace()) { diff --git a/src/Sonarr.Api.V3/Config/HostConfigResource.cs b/src/Sonarr.Api.V3/Config/HostConfigResource.cs index a33bbedcc..56262ec90 100644 --- a/src/Sonarr.Api.V3/Config/HostConfigResource.cs +++ b/src/Sonarr.Api.V3/Config/HostConfigResource.cs @@ -1,4 +1,4 @@ -using NzbDrone.Common.Http.Proxy; +using NzbDrone.Common.Http.Proxy; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; @@ -33,6 +33,9 @@ namespace Sonarr.Api.V3.Config public string ProxyPassword { get; set; } public string ProxyBypassFilter { get; set; } public bool ProxyBypassLocalAddresses { get; set; } + public string BackupFolder { get; set; } + public int BackupInterval { get; set; } + public int BackupRetention { get; set; } } public static class HostConfigResourceMapper @@ -66,7 +69,10 @@ namespace Sonarr.Api.V3.Config ProxyUsername = configService.ProxyUsername, ProxyPassword = configService.ProxyPassword, ProxyBypassFilter = configService.ProxyBypassFilter, - ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses + ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses, + BackupFolder = configService.BackupFolder, + BackupInterval = configService.BackupInterval, + BackupRetention = configService.BackupRetention }; } } diff --git a/src/Sonarr.Api.V3/System/Backup/BackupModule.cs b/src/Sonarr.Api.V3/System/Backup/BackupModule.cs index 9bee19942..ea1870c47 100644 --- a/src/Sonarr.Api.V3/System/Backup/BackupModule.cs +++ b/src/Sonarr.Api.V3/System/Backup/BackupModule.cs @@ -1,19 +1,39 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; +using Nancy; +using NzbDrone.Common.Crypto; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Backup; using Sonarr.Http; +using Sonarr.Http.Extensions; +using Sonarr.Http.REST; namespace Sonarr.Api.V3.System.Backup { public class BackupModule : SonarrRestModule { private readonly IBackupService _backupService; + private readonly IAppFolderInfo _appFolderInfo; + private readonly IDiskProvider _diskProvider; - public BackupModule(IBackupService backupService) : base("system/backup") + private static readonly List ValidExtensions = new List { ".zip", ".db", ".xml" }; + + public BackupModule(IBackupService backupService, + IAppFolderInfo appFolderInfo, + IDiskProvider diskProvider) + : base("system/backup") { _backupService = backupService; + _appFolderInfo = appFolderInfo; + _diskProvider = diskProvider; GetResourceAll = GetBackupFiles; + DeleteResource = DeleteBackup; + + Post[@"/restore/(?[\d]{1,10})"] = x => Restore((int)x.Id); + Post["/restore/upload"] = x => UploadAndRestore(); } public List GetBackupFiles() @@ -22,7 +42,7 @@ namespace Sonarr.Api.V3.System.Backup return backups.Select(b => new BackupResource { - Id = b.Name.GetHashCode(), + Id = GetBackupId(b), Name = b.Name, Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}", Type = b.Type, @@ -31,5 +51,83 @@ namespace Sonarr.Api.V3.System.Backup .OrderByDescending(b => b.Time) .ToList(); } + + private void DeleteBackup(int id) + { + var backup = GetBackup(id); + var path = GetBackupPath(backup); + + if (!_diskProvider.FileExists(path)) + { + throw new NotFoundException(); + } + + _diskProvider.DeleteFile(path); + } + + public Response Restore(int id) + { + var backup = GetBackup(id); + + if (backup == null) + { + throw new NotFoundException(); + } + + var path = GetBackupPath(backup); + + _backupService.Restore(path); + + return new + { + RestartRequired = true + }.AsResponse(); + } + + public Response UploadAndRestore() + { + var files = Context.Request.Files.ToList(); + + if (files.Empty()) + { + throw new BadRequestException("file must be provided"); + } + + var file = files.First(); + var extension = Path.GetExtension(file.Name); + + if (!ValidExtensions.Contains(extension)) + { + throw new UnsupportedMediaTypeException($"Invalid extension, must be one of: {ValidExtensions.Join(", ")}"); + } + + var path = Path.Combine(_appFolderInfo.TempFolder, $"sonarr_backup_restore{extension}"); + + _diskProvider.SaveStream(file.Value, path); + _backupService.Restore(path); + + // Cleanup restored file + _diskProvider.DeleteFile(path); + + return new + { + RestartRequired = true + }.AsResponse(); + } + + private string GetBackupPath(NzbDrone.Core.Backup.Backup backup) + { + return Path.Combine(_backupService.GetBackupFolder(backup.Type), backup.Name); + } + + private int GetBackupId(NzbDrone.Core.Backup.Backup backup) + { + return HashConverter.GetHashInt31($"backup-{backup.Type}-{backup.Name}"); + } + + private NzbDrone.Core.Backup.Backup GetBackup(int id) + { + return _backupService.GetBackups().SingleOrDefault(b => GetBackupId(b) == id); + } } } diff --git a/src/Sonarr.Api.V3/System/SystemModule.cs b/src/Sonarr.Api.V3/System/SystemModule.cs index 4ff87cce5..22e97fdc2 100644 --- a/src/Sonarr.Api.V3/System/SystemModule.cs +++ b/src/Sonarr.Api.V3/System/SystemModule.cs @@ -1,4 +1,5 @@ -using Nancy; +using System.Threading.Tasks; +using Nancy; using Nancy.Routing; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; @@ -81,14 +82,14 @@ namespace Sonarr.Api.V3.System private Response Shutdown() { - _lifecycleService.Shutdown(); - return "".AsResponse(); + Task.Factory.StartNew(() => _lifecycleService.Shutdown()); + return new { ShuttingDown = true }.AsResponse(); } private Response Restart() { - _lifecycleService.Restart(); - return "".AsResponse(); + Task.Factory.StartNew(() => _lifecycleService.Restart()); + return new { Restarting = true }.AsResponse(); } } } diff --git a/src/Sonarr.Http/Frontend/Mappers/BackupFileMapper.cs b/src/Sonarr.Http/Frontend/Mappers/BackupFileMapper.cs index 2ce35f9f6..4f37913f1 100644 --- a/src/Sonarr.Http/Frontend/Mappers/BackupFileMapper.cs +++ b/src/Sonarr.Http/Frontend/Mappers/BackupFileMapper.cs @@ -2,30 +2,31 @@ using System.IO; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; +using NzbDrone.Core.Backup; namespace Sonarr.Http.Frontend.Mappers { public class BackupFileMapper : StaticResourceMapperBase { + private readonly IBackupService _backupService; private readonly IAppFolderInfo _appFolderInfo; - public BackupFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger) + public BackupFileMapper(IBackupService backupService, IDiskProvider diskProvider, Logger logger) : base(diskProvider, logger) { - _appFolderInfo = appFolderInfo; + _backupService = backupService; } public override string Map(string resourceUrl) { var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar); - return Path.Combine(_appFolderInfo.GetBackupFolder(), path); + return Path.Combine(_backupService.GetBackupFolder(), path); } public override bool CanHandle(string resourceUrl) { - return resourceUrl.StartsWith("/backup/") && resourceUrl.ContainsIgnoreCase("nzbdrone_backup_") && resourceUrl.EndsWith(".zip"); + return resourceUrl.StartsWith("/backup/") && BackupService.BackupFileRegex.IsMatch(resourceUrl); } } -} \ No newline at end of file +} diff --git a/src/Sonarr.Http/REST/UnsupportedMediaTypeException.cs b/src/Sonarr.Http/REST/UnsupportedMediaTypeException.cs new file mode 100644 index 000000000..596d7823c --- /dev/null +++ b/src/Sonarr.Http/REST/UnsupportedMediaTypeException.cs @@ -0,0 +1,13 @@ +using Nancy; +using Sonarr.Http.Exceptions; + +namespace Sonarr.Http.REST +{ + public class UnsupportedMediaTypeException : ApiException + { + public UnsupportedMediaTypeException(object content = null) + : base(HttpStatusCode.UnsupportedMediaType, content) + { + } + } +} diff --git a/src/Sonarr.Http/Sonarr.Http.csproj b/src/Sonarr.Http/Sonarr.Http.csproj index d576cb09e..dc92b05a0 100644 --- a/src/Sonarr.Http/Sonarr.Http.csproj +++ b/src/Sonarr.Http/Sonarr.Http.csproj @@ -121,6 +121,7 @@ +