From 6454a35ef831157fb10d8cbdf39017b2df2b8449 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 22 Jan 2025 18:20:57 +0100 Subject: Extract trickplay files into own subdirectory --- .../AppBase/BaseApplicationPaths.cs | 58 ++++++---------------- 1 file changed, 14 insertions(+), 44 deletions(-) (limited to 'Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs') diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index dc845b2d7..f0cca9efd 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -34,76 +34,46 @@ namespace Emby.Server.Implementations.AppBase DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } - /// - /// Gets the path to the program data folder. - /// - /// The program data path. + /// public string ProgramDataPath { get; } /// public string WebPath { get; } - /// - /// Gets the path to the system folder. - /// - /// The path to the system folder. + /// public string ProgramSystemPath { get; } = AppContext.BaseDirectory; - /// - /// Gets the folder path to the data directory. - /// - /// The data directory. + /// public string DataPath { get; } /// public string VirtualDataPath => "%AppDataPath%"; - /// - /// Gets the image cache path. - /// - /// The image cache path. + /// public string ImageCachePath => Path.Combine(CachePath, "images"); - /// - /// Gets the path to the plugin directory. - /// - /// The plugins path. + /// public string PluginsPath => Path.Combine(ProgramDataPath, "plugins"); - /// - /// Gets the path to the plugin configurations directory. - /// - /// The plugin configurations path. + /// public string PluginConfigurationsPath => Path.Combine(PluginsPath, "configurations"); - /// - /// Gets the path to the log directory. - /// - /// The log directory path. + /// public string LogDirectoryPath { get; } - /// - /// Gets the path to the application configuration root directory. - /// - /// The configuration directory path. + /// public string ConfigurationDirectoryPath { get; } - /// - /// Gets the path to the system configuration file. - /// - /// The system configuration file path. + /// public string SystemConfigurationFilePath => Path.Combine(ConfigurationDirectoryPath, "system.xml"); - /// - /// Gets or sets the folder path to the cache directory. - /// - /// The cache directory. + /// public string CachePath { get; set; } - /// - /// Gets the folder path to the temp directory within the cache folder. - /// - /// The temp directory. + /// public string TempDirectory => Path.Join(Path.GetTempPath(), "jellyfin"); + + /// + public string TrickplayPath => Path.Combine(DataPath, "trickplay"); } } -- cgit v1.2.3 From 8ee358de2ca956d22c14f28c3783ba99acd87a32 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Sat, 26 Apr 2025 18:30:25 +0300 Subject: Check for path overlaps (#12832) --- .../AppBase/BaseApplicationPaths.cs | 45 +++++++++++++++++++++- .../AppBase/BaseConfigurationManager.cs | 1 + .../Library/LibraryManager.cs | 2 - .../ServerApplicationPaths.cs | 7 ++++ Jellyfin.Server/Program.cs | 1 + .../EncodingConfigurationExtensions.cs | 3 +- .../Configuration/IApplicationPaths.cs | 13 +++++++ MediaBrowser.Controller/IServerApplicationPaths.cs | 4 ++ 8 files changed, 71 insertions(+), 5 deletions(-) (limited to 'Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs') diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index f0cca9efd..d1376f18a 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using MediaBrowser.Common.Configuration; namespace Emby.Server.Implementations.AppBase @@ -30,7 +32,6 @@ namespace Emby.Server.Implementations.AppBase ConfigurationDirectoryPath = configurationDirectoryPath; CachePath = cacheDirectoryPath; WebPath = webDirectoryPath; - DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } @@ -75,5 +76,47 @@ namespace Emby.Server.Implementations.AppBase /// public string TrickplayPath => Path.Combine(DataPath, "trickplay"); + + /// + public virtual void MakeSanityCheckOrThrow() + { + CreateAndCheckMarker(ConfigurationDirectoryPath, "config"); + CreateAndCheckMarker(LogDirectoryPath, "log"); + CreateAndCheckMarker(PluginsPath, "plugin"); + CreateAndCheckMarker(ProgramDataPath, "data"); + CreateAndCheckMarker(CachePath, "cache"); + CreateAndCheckMarker(DataPath, "data"); + } + + /// + public void CreateAndCheckMarker(string path, string markerName, bool recursive = false) + { + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + + CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive); + } + + private IEnumerable GetMarkers(string path, bool recursive = false) + { + return Directory.EnumerateFiles(path, ".jellyfin-*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + } + + private void CheckOrCreateMarker(string path, string markerName, bool recursive = false) + { + var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName); + if (otherMarkers != null) + { + throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}."); + } + + var markerPath = Path.Combine(path, markerName); + if (!File.Exists(markerPath)) + { + File.Create(markerPath).Dispose(); + } + } } } diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index 9bc3a0204..81ef0e5f9 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -227,6 +227,7 @@ namespace Emby.Server.Implementations.AppBase Logger.LogInformation("Setting cache path: {Path}", cachePath); ((BaseApplicationPaths)CommonApplicationPaths).CachePath = cachePath; + CommonApplicationPaths.CreateAndCheckMarker(((BaseApplicationPaths)CommonApplicationPaths).CachePath, "cache"); } /// diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 40045782b..a6eddbbc3 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -768,8 +768,6 @@ namespace Emby.Server.Implementations.Library { var rootFolderPath = _configurationManager.ApplicationPaths.RootFolderPath; - Directory.CreateDirectory(rootFolderPath); - var rootFolder = GetItemById(GetNewItemId(rootFolderPath, typeof(AggregateFolder))) as AggregateFolder ?? (ResolvePath(_fileSystem.GetDirectoryInfo(rootFolderPath)) as Folder ?? throw new InvalidOperationException("Something went very wong")) .DeepCopy(); diff --git a/Emby.Server.Implementations/ServerApplicationPaths.cs b/Emby.Server.Implementations/ServerApplicationPaths.cs index 725df98da..f049e6647 100644 --- a/Emby.Server.Implementations/ServerApplicationPaths.cs +++ b/Emby.Server.Implementations/ServerApplicationPaths.cs @@ -96,5 +96,12 @@ namespace Emby.Server.Implementations /// public string VirtualInternalMetadataPath => "%MetadataPath%"; + + /// + public override void MakeSanityCheckOrThrow() + { + base.MakeSanityCheckOrThrow(); + CreateAndCheckMarker(RootFolderPath, "root"); + } } } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 8d0bf73f6..511306755 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -77,6 +77,7 @@ namespace Jellyfin.Server { _startTimestamp = Stopwatch.GetTimestamp(); ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options); + appPaths.MakeSanityCheckOrThrow(); // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager Environment.SetEnvironmentVariable("JELLYFIN_LOG_DIR", appPaths.LogDirectoryPath); diff --git a/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs b/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs index 70a4fe409..78e96ab47 100644 --- a/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs +++ b/MediaBrowser.Common/Configuration/EncodingConfigurationExtensions.cs @@ -35,8 +35,7 @@ namespace MediaBrowser.Common.Configuration transcodingTempPath = Path.Combine(configurationManager.CommonApplicationPaths.CachePath, "transcodes"); } - // Make sure the directory exists - Directory.CreateDirectory(transcodingTempPath); + configurationManager.CommonApplicationPaths.CreateAndCheckMarker(transcodingTempPath, "transcode", true); return transcodingTempPath; } } diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs index 7a8ab3236..fa0d8247b 100644 --- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs +++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs @@ -90,5 +90,18 @@ namespace MediaBrowser.Common.Configuration /// /// The trickplay path. string TrickplayPath { get; } + + /// + /// Checks and creates all known base paths. + /// + void MakeSanityCheckOrThrow(); + + /// + /// Checks and creates the given path and adds it with a marker file if non existant. + /// + /// The path to check. + /// The common marker file name. + /// Check for other settings paths recursivly. + void CreateAndCheckMarker(string path, string markerName, bool recursive = false); } } diff --git a/MediaBrowser.Controller/IServerApplicationPaths.cs b/MediaBrowser.Controller/IServerApplicationPaths.cs index 608286cd8..a6e83a02c 100644 --- a/MediaBrowser.Controller/IServerApplicationPaths.cs +++ b/MediaBrowser.Controller/IServerApplicationPaths.cs @@ -2,6 +2,10 @@ #pragma warning disable CS1591 +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; using MediaBrowser.Common.Configuration; namespace MediaBrowser.Controller -- cgit v1.2.3 From 0c3ba30de214eddcd6118c3b695b08e5482bf7ed Mon Sep 17 00:00:00 2001 From: Bond-009 Date: Sun, 4 May 2025 16:40:34 +0200 Subject: Cleanup file related code (#14023) --- .../AppBase/BaseApplicationPaths.cs | 8 ++-- .../IO/ManagedFileSystem.cs | 8 ++-- .../Library/DotIgnoreIgnoreRule.cs | 2 +- .../Library/LibraryManager.cs | 2 +- .../Library/MediaSourceManager.cs | 12 +++--- .../Localization/LocalizationManager.cs | 2 +- Jellyfin.Api/Controllers/SyncPlayController.cs | 2 +- .../StorageHelpers/StorageHelper.cs | 5 +-- .../Trickplay/TrickplayManager.cs | 48 +++++++++++----------- .../Extensions/ApiServiceCollectionExtensions.cs | 2 +- .../Attachments/AttachmentExtractor.cs | 6 +-- MediaBrowser.Model/IO/AsyncFile.cs | 8 ++++ src/Jellyfin.Extensions/FileHelper.cs | 20 +++++++++ src/Jellyfin.LiveTv/Channels/ChannelManager.cs | 4 +- tests/Jellyfin.Extensions.Tests/FileHelperTests.cs | 23 +++++++++++ .../Plugins/PluginManagerTests.cs | 5 ++- .../OpenApiSpecTests.cs | 3 +- 17 files changed, 104 insertions(+), 56 deletions(-) create mode 100644 src/Jellyfin.Extensions/FileHelper.cs create mode 100644 tests/Jellyfin.Extensions.Tests/FileHelperTests.cs (limited to 'Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs') diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index d1376f18a..18ebd628d 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; namespace Emby.Server.Implementations.AppBase @@ -91,10 +92,7 @@ namespace Emby.Server.Implementations.AppBase /// public void CreateAndCheckMarker(string path, string markerName, bool recursive = false) { - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - } + Directory.CreateDirectory(path); CheckOrCreateMarker(path, $".jellyfin-{markerName}", recursive); } @@ -115,7 +113,7 @@ namespace Emby.Server.Implementations.AppBase var markerPath = Path.Combine(path, markerName); if (!File.Exists(markerPath)) { - File.Create(markerPath).Dispose(); + FileHelper.CreateEmpty(markerPath); } } } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index ac5933a69..077eb7945 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -159,13 +159,13 @@ namespace Emby.Server.Implementations.IO catch (IOException) { // Cross device move requires a copy - Directory.CreateDirectory(destination); - foreach (string file in Directory.GetFiles(source)) + var directory = Directory.CreateDirectory(destination); + foreach (var file in directory.EnumerateFiles()) { - File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), true); + file.CopyTo(Path.Combine(destination, file.Name), true); } - Directory.Delete(source, true); + directory.Delete(true); } } diff --git a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs index 2c186c917..b0ed1de8d 100644 --- a/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs +++ b/Emby.Server.Implementations/Library/DotIgnoreIgnoreRule.cs @@ -20,7 +20,7 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule } var parentDir = directory.Parent; - if (parentDir == null || parentDir.FullName == directory.FullName) + if (parentDir is null) { return null; } diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 64a96c4e5..51f330746 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2945,7 +2945,7 @@ namespace Emby.Server.Implementations.Library { var path = Path.Combine(virtualFolderPath, collectionType.ToString()!.ToLowerInvariant() + ".collection"); // Can't be null with legal values? - await File.WriteAllBytesAsync(path, []).ConfigureAwait(false); + FileHelper.CreateEmpty(path); } CollectionFolder.SaveLibraryOptions(virtualFolderPath, options); diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs index c6cfd5391..ab30971e2 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -681,17 +681,17 @@ namespace Emby.Server.Implementations.Library mediaInfo = await _mediaEncoder.GetMediaInfo( new MediaInfoRequest - { - MediaSource = mediaSource, - MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video, - ExtractChapters = false - }, + { + MediaSource = mediaSource, + MediaType = isAudio ? DlnaProfileType.Audio : DlnaProfileType.Video, + ExtractChapters = false + }, cancellationToken).ConfigureAwait(false); if (cacheFilePath is not null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - FileStream createStream = File.Create(cacheFilePath); + FileStream createStream = AsyncFile.Create(cacheFilePath); await using (createStream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 17db7ad4c..242f2af56 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -520,7 +520,7 @@ namespace Emby.Server.Implementations.Localization public bool TryGetISO6392TFromB(string isoB, [NotNullWhen(true)] out string? isoT) { // Unlikely case the dictionary is not (yet) initialized properly - if (_iso6392BtoT == null) + if (_iso6392BtoT is null) { isoT = null; return false; diff --git a/Jellyfin.Api/Controllers/SyncPlayController.cs b/Jellyfin.Api/Controllers/SyncPlayController.cs index fbab2a784..3d6874079 100644 --- a/Jellyfin.Api/Controllers/SyncPlayController.cs +++ b/Jellyfin.Api/Controllers/SyncPlayController.cs @@ -125,7 +125,7 @@ public class SyncPlayController : BaseJellyfinApiController { var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); var group = _syncPlayManager.GetGroup(currentSession, id); - return group == null ? NotFound() : Ok(group); + return group is null ? NotFound() : Ok(group); } /// diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index e351160c1..b2f54be7e 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -72,10 +72,7 @@ public static class StorageHelper private static void TestDataDirectorySize(string path, ILogger logger, long threshold = -1) { logger.LogDebug("Check path {TestPath} for storage capacity", path); - if (!Directory.Exists(path)) - { - Directory.CreateDirectory(path); - } + Directory.CreateDirectory(path); var drive = new DriveInfo(path); if (threshold != -1 && drive.AvailableFreeSpace < threshold) diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs index bf39f13a7..f7dd92e01 100644 --- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs +++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs @@ -97,28 +97,28 @@ public class TrickplayManager : ITrickplayManager var existingResolution = resolution.Key; var tileWidth = resolution.Value.TileWidth; var tileHeight = resolution.Value.TileHeight; - var shouldBeSavedWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia; - var localOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false); - var mediaOutputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true); - if (shouldBeSavedWithMedia && Directory.Exists(localOutputDir)) + var shouldBeSavedWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia; + var localOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, false)); + var mediaOutputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, existingResolution, true)); + if (shouldBeSavedWithMedia && localOutputDir.Exists) { - var localDirFiles = Directory.GetFiles(localOutputDir); - var mediaDirExists = Directory.Exists(mediaOutputDir); - if (localDirFiles.Length > 0 && ((mediaDirExists && Directory.GetFiles(mediaOutputDir).Length == 0) || !mediaDirExists)) + var localDirFiles = localOutputDir.EnumerateFiles(); + var mediaDirExists = mediaOutputDir.Exists; + if (localDirFiles.Any() && ((mediaDirExists && mediaOutputDir.EnumerateFiles().Any()) || !mediaDirExists)) { // Move images from local dir to media dir - MoveContent(localOutputDir, mediaOutputDir); + MoveContent(localOutputDir.FullName, mediaOutputDir.FullName); _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, mediaOutputDir); } } - else if (!shouldBeSavedWithMedia && Directory.Exists(mediaOutputDir)) + else if (!shouldBeSavedWithMedia && mediaOutputDir.Exists) { - var mediaDirFiles = Directory.GetFiles(mediaOutputDir); - var localDirExists = Directory.Exists(localOutputDir); - if (mediaDirFiles.Length > 0 && ((localDirExists && Directory.GetFiles(localOutputDir).Length == 0) || !localDirExists)) + var mediaDirFiles = mediaOutputDir.EnumerateFiles(); + var localDirExists = localOutputDir.Exists; + if (mediaDirFiles.Any() && ((localDirExists && localOutputDir.EnumerateFiles().Any()) || !localDirExists)) { // Move images from media dir to local dir - MoveContent(mediaOutputDir, localOutputDir); + MoveContent(mediaOutputDir.FullName, localOutputDir.FullName); _logger.LogInformation("Moved trickplay images for {ItemName} to {Location}", video.Name, localOutputDir); } } @@ -131,10 +131,10 @@ public class TrickplayManager : ITrickplayManager var parent = Directory.GetParent(sourceFolder); if (parent is not null) { - var parentContent = Directory.GetDirectories(parent.FullName); - if (parentContent.Length == 0) + var parentContent = parent.EnumerateDirectories(); + if (!parentContent.Any()) { - Directory.Delete(parent.FullName); + parent.Delete(); } } } @@ -220,13 +220,13 @@ public class TrickplayManager : ITrickplayManager var tileWidth = options.TileWidth; var tileHeight = options.TileHeight; - var saveWithMedia = libraryOptions is null ? false : libraryOptions.SaveTrickplayWithMedia; - var outputDir = GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia); + var saveWithMedia = libraryOptions is not null && libraryOptions.SaveTrickplayWithMedia; + var outputDir = new DirectoryInfo(GetTrickplayDirectory(video, tileWidth, tileHeight, actualWidth, saveWithMedia)); // Import existing trickplay tiles - if (!replace && Directory.Exists(outputDir)) + if (!replace && outputDir.Exists) { - var existingFiles = Directory.GetFiles(outputDir); + var existingFiles = outputDir.GetFiles(); if (existingFiles.Length > 0) { var hasTrickplayResolution = await HasTrickplayResolutionAsync(video.Id, actualWidth).ConfigureAwait(false); @@ -251,9 +251,9 @@ public class TrickplayManager : ITrickplayManager foreach (var tile in existingFiles) { - var image = _imageEncoder.GetImageSize(tile); + var image = _imageEncoder.GetImageSize(tile.FullName); localTrickplayInfo.Height = Math.Max(localTrickplayInfo.Height, (int)Math.Ceiling((double)image.Height / localTrickplayInfo.TileHeight)); - var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tile).Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000)); + var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / localTrickplayInfo.TileWidth / localTrickplayInfo.TileHeight / (localTrickplayInfo.Interval / 1000)); localTrickplayInfo.Bandwidth = Math.Max(localTrickplayInfo.Bandwidth, bitrate); } @@ -296,7 +296,7 @@ public class TrickplayManager : ITrickplayManager .ToList(); // Create tiles - var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir); + var trickplayInfo = CreateTiles(images, actualWidth, options, outputDir.FullName); // Save tiles info try @@ -319,7 +319,7 @@ public class TrickplayManager : ITrickplayManager // Make sure no files stay in metadata folders on failure // if tiles info wasn't saved. - Directory.Delete(outputDir, true); + outputDir.Delete(true); } } catch (Exception ex) diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index b04e55baa..09a4e2ed3 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -215,7 +215,7 @@ namespace Jellyfin.Server.Extensions }); // Add all xml doc files to swagger generator. - var xmlFiles = Directory.GetFiles( + var xmlFiles = Directory.EnumerateFiles( AppContext.BaseDirectory, "*.xml", SearchOption.TopDirectoryOnly); diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 1f2bc2403..48a0654bb 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -133,9 +133,9 @@ namespace MediaBrowser.MediaEncoding.Attachments var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id); using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false)) { - Directory.CreateDirectory(outputFolder); - var fileNames = Directory.GetFiles(outputFolder, "*", SearchOption.TopDirectoryOnly).Select(f => Path.GetFileName(f)); - var missingFiles = mediaSource.MediaAttachments.Where(a => !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)); + var directory = Directory.CreateDirectory(outputFolder); + var fileNames = directory.GetFiles("*", SearchOption.TopDirectoryOnly).Select(f => f.Name).ToHashSet(); + var missingFiles = mediaSource.MediaAttachments.Where(a => a.FileName is not null && !fileNames.Contains(a.FileName) && !string.Equals(a.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)); if (!missingFiles.Any()) { // Skip extraction if all files already exist diff --git a/MediaBrowser.Model/IO/AsyncFile.cs b/MediaBrowser.Model/IO/AsyncFile.cs index 3c8007d1c..a9db6b81c 100644 --- a/MediaBrowser.Model/IO/AsyncFile.cs +++ b/MediaBrowser.Model/IO/AsyncFile.cs @@ -26,6 +26,14 @@ namespace MediaBrowser.Model.IO Options = FileOptions.Asynchronous }; + /// + /// Creates, or truncates and overwrites, a file in the specified path. + /// + /// The path and name of the file to create. + /// A that provides read/write access to the file specified in path. + public static FileStream Create(string path) + => new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); + /// /// Opens an existing file for reading. /// diff --git a/src/Jellyfin.Extensions/FileHelper.cs b/src/Jellyfin.Extensions/FileHelper.cs new file mode 100644 index 000000000..b1ccf8d47 --- /dev/null +++ b/src/Jellyfin.Extensions/FileHelper.cs @@ -0,0 +1,20 @@ +using System.IO; + +namespace Jellyfin.Extensions; + +/// +/// Provides helper functions for . +/// +public static class FileHelper +{ + /// + /// Creates, or truncates a file in the specified path. + /// + /// The path and name of the file to create. + public static void CreateEmpty(string path) + { + using (File.OpenHandle(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None)) + { + } + } +} diff --git a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs index 0ca294a28..5addcd26e 100644 --- a/src/Jellyfin.LiveTv/Channels/ChannelManager.cs +++ b/src/Jellyfin.LiveTv/Channels/ChannelManager.cs @@ -363,7 +363,7 @@ namespace Jellyfin.LiveTv.Channels Directory.CreateDirectory(Path.GetDirectoryName(path)); - FileStream createStream = File.Create(path); + FileStream createStream = AsyncFile.Create(path); await using (createStream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false); @@ -866,7 +866,7 @@ namespace Jellyfin.LiveTv.Channels { Directory.CreateDirectory(Path.GetDirectoryName(path)); - var createStream = File.Create(path); + var createStream = AsyncFile.Create(path); await using (createStream.ConfigureAwait(false)) { await JsonSerializer.SerializeAsync(createStream, result, _jsonOptions).ConfigureAwait(false); diff --git a/tests/Jellyfin.Extensions.Tests/FileHelperTests.cs b/tests/Jellyfin.Extensions.Tests/FileHelperTests.cs new file mode 100644 index 000000000..fb6a5dd0a --- /dev/null +++ b/tests/Jellyfin.Extensions.Tests/FileHelperTests.cs @@ -0,0 +1,23 @@ +using System.IO; +using Xunit; + +namespace Jellyfin.Extensions.Tests; + +public static class FileHelperTests +{ + [Fact] + public static void CreateEmpty_Valid_Correct() + { + var path = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + var fileInfo = new FileInfo(path); + + Assert.False(fileInfo.Exists); + + FileHelper.CreateEmpty(path); + + fileInfo.Refresh(); + Assert.True(fileInfo.Exists); + + File.Delete(path); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index 934024826..b289c763b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using AutoFixture; using Emby.Server.Implementations.Library; using Emby.Server.Implementations.Plugins; +using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common.Plugins; @@ -85,7 +86,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins var dllPath = Path.GetDirectoryName(Path.Combine(_pluginPath, dllFile))!; Directory.CreateDirectory(dllPath); - File.Create(Path.Combine(dllPath, filename)); + FileHelper.CreateEmpty(Path.Combine(dllPath, filename)); var metafilePath = Path.Combine(_pluginPath, "meta.json"); File.WriteAllText(metafilePath, JsonSerializer.Serialize(manifest, _options)); @@ -141,7 +142,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins foreach (var file in files) { - File.Create(Path.Combine(_pluginPath, file)); + FileHelper.CreateEmpty(Path.Combine(_pluginPath, file)); } var metafilePath = Path.Combine(_pluginPath, "meta.json"); diff --git a/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs index 98195a294..62cdd25ae 100644 --- a/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs @@ -1,6 +1,7 @@ using System.IO; using System.Reflection; using System.Threading.Tasks; +using MediaBrowser.Model.IO; using Xunit; using Xunit.Abstractions; @@ -33,7 +34,7 @@ namespace Jellyfin.Server.Integration.Tests // Write out for publishing string outputPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? ".", "openapi.json")); _outputHelper.WriteLine("Writing OpenAPI Spec JSON to '{0}'.", outputPath); - await using var fs = File.Create(outputPath); + await using var fs = AsyncFile.Create(outputPath); await response.Content.CopyToAsync(fs); } } -- cgit v1.2.3 From fe2596dc0e389c0496a384cc1893fddd4742ed37 Mon Sep 17 00:00:00 2001 From: JPVenson Date: Mon, 19 May 2025 03:39:04 +0300 Subject: Add Full system backup feature (#13945) --- .../AppBase/BaseApplicationPaths.cs | 3 + Emby.Server.Implementations/ApplicationHost.cs | 5 + Jellyfin.Api/Controllers/BackupController.cs | 127 ++++++ Jellyfin.Api/Controllers/SystemController.cs | 1 - .../FullSystemBackup/BackupManifest.cs | 19 + .../FullSystemBackup/BackupOptions.cs | 13 + .../FullSystemBackup/BackupService.cs | 463 +++++++++++++++++++++ .../Migrations/JellyfinMigrationService.cs | 43 +- Jellyfin.Server/Program.cs | 14 +- Jellyfin.Server/StartupOptions.cs | 6 + .../Configuration/IApplicationPaths.cs | 6 + MediaBrowser.Controller/IServerApplicationHost.cs | 5 + .../SystemBackupService/BackupManifestDto.cs | 34 ++ .../SystemBackupService/BackupOptionsDto.cs | 24 ++ .../SystemBackupService/BackupRestoreRequestDto.cs | 15 + .../SystemBackupService/IBackupService.cs | 48 +++ .../Entities/TrickplayInfo.cs | 1 - .../Entities/User.cs | 1 - .../IJellyfinDatabaseProvider.cs | 9 + .../SqliteDatabaseProvider.cs | 24 +- .../Controllers/SystemControllerTests.cs | 1 + 21 files changed, 841 insertions(+), 21 deletions(-) create mode 100644 Jellyfin.Api/Controllers/BackupController.cs create mode 100644 Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs create mode 100644 Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs create mode 100644 Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs create mode 100644 MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs create mode 100644 MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs create mode 100644 MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs create mode 100644 MediaBrowser.Controller/SystemBackupService/IBackupService.cs (limited to 'Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs') diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 18ebd628d..e74755ec3 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -78,6 +78,9 @@ namespace Emby.Server.Implementations.AppBase /// public string TrickplayPath => Path.Combine(DataPath, "trickplay"); + /// + public string BackupPath => Path.Combine(DataPath, "backups"); + /// public virtual void MakeSanityCheckOrThrow() { diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index c397a69fb..565d0f0c8 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -40,8 +40,10 @@ using Jellyfin.Drawing; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Manager; using Jellyfin.Networking.Udp; +using Jellyfin.Server.Implementations.FullSystemBackup; using Jellyfin.Server.Implementations.Item; using Jellyfin.Server.Implementations.MediaSegments; +using Jellyfin.Server.Implementations.SystemBackupService; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; @@ -268,6 +270,8 @@ namespace Emby.Server.Implementations ? Environment.MachineName : ConfigurationManager.Configuration.ServerName; + public string RestoreBackupPath { get; set; } + public string ExpandVirtualPath(string path) { if (path is null) @@ -472,6 +476,7 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(this); serviceCollection.AddSingleton(_pluginManager); serviceCollection.AddSingleton(ApplicationPaths); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); diff --git a/Jellyfin.Api/Controllers/BackupController.cs b/Jellyfin.Api/Controllers/BackupController.cs new file mode 100644 index 000000000..aa908ee30 --- /dev/null +++ b/Jellyfin.Api/Controllers/BackupController.cs @@ -0,0 +1,127 @@ +using System.IO; +using System.Threading.Tasks; +using Jellyfin.Server.Implementations.SystemBackupService; +using MediaBrowser.Common.Api; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.SystemBackupService; +using Microsoft.AspNetCore.Authentication.OAuth.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Jellyfin.Api.Controllers; + +/// +/// The backup controller. +/// +[Authorize(Policy = Policies.RequiresElevation)] +public class BackupController : BaseJellyfinApiController +{ + private readonly IBackupService _backupService; + private readonly IApplicationPaths _applicationPaths; + + /// + /// Initializes a new instance of the class. + /// + /// Instance of the interface. + /// Instance of the interface. + public BackupController(IBackupService backupService, IApplicationPaths applicationPaths) + { + _backupService = backupService; + _applicationPaths = applicationPaths; + } + + /// + /// Creates a new Backup. + /// + /// The backup options. + /// Backup created. + /// User does not have permission to retrieve information. + /// The created backup manifest. + [HttpPost("Create")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> CreateBackup([FromBody] BackupOptionsDto backupOptions) + { + return Ok(await _backupService.CreateBackupAsync(backupOptions ?? new()).ConfigureAwait(false)); + } + + /// + /// Restores to a backup by restarting the server and applying the backup. + /// + /// The data to start a restore process. + /// Backup restore started. + /// User does not have permission to retrieve information. + /// No-Content. + [HttpPost("Restore")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public IActionResult StartRestoreBackup([FromBody, BindRequired] BackupRestoreRequestDto archiveRestoreDto) + { + var archivePath = SanitizePath(archiveRestoreDto.ArchiveFileName); + if (!System.IO.File.Exists(archivePath)) + { + return NotFound(); + } + + _backupService.ScheduleRestoreAndRestartServer(archivePath); + return NoContent(); + } + + /// + /// Gets a list of all currently present backups in the backup directory. + /// + /// Backups available. + /// User does not have permission to retrieve information. + /// The list of backups. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> ListBackups() + { + return Ok(await _backupService.EnumerateBackups().ConfigureAwait(false)); + } + + /// + /// Gets the descriptor from an existing archive is present. + /// + /// The data to start a restore process. + /// Backup archive manifest. + /// Not a valid jellyfin Archive. + /// Not a valid path. + /// User does not have permission to retrieve information. + /// The backup manifest. + [HttpGet("Manifest")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> GetBackup([BindRequired] string path) + { + var backupPath = SanitizePath(path); + + if (!System.IO.File.Exists(backupPath)) + { + return NotFound(); + } + + var manifest = await _backupService.GetBackupManifest(backupPath).ConfigureAwait(false); + if (manifest is null) + { + return NoContent(); + } + + return Ok(manifest); + } + + [NonAction] + private string SanitizePath(string path) + { + // sanitize path + var archiveRestorePath = Path.GetFileName(Path.GetFullPath(path)); + var archivePath = Path.Combine(_applicationPaths.BackupPath, archiveRestorePath); + return archivePath; + } +} diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 07a1f7650..450225c37 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -5,7 +5,6 @@ using System.IO; using System.Linq; using System.Net.Mime; using Jellyfin.Api.Attributes; -using Jellyfin.Api.Constants; using Jellyfin.Api.Models.SystemInfoDtos; using MediaBrowser.Common.Api; using MediaBrowser.Common.Configuration; diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs new file mode 100644 index 000000000..77a49b2b5 --- /dev/null +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupManifest.cs @@ -0,0 +1,19 @@ +using System; + +namespace Jellyfin.Server.Implementations.FullSystemBackup; + +/// +/// Manifest type for backups internal structure. +/// +internal class BackupManifest +{ + public required Version ServerVersion { get; set; } + + public required Version BackupEngineVersion { get; set; } + + public required DateTimeOffset DateCreated { get; set; } + + public required string[] DatabaseTables { get; set; } + + public required BackupOptions Options { get; set; } +} diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs new file mode 100644 index 000000000..706f009ac --- /dev/null +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupOptions.cs @@ -0,0 +1,13 @@ +namespace Jellyfin.Server.Implementations.FullSystemBackup; + +/// +/// Defines the optional contents of the backup archive. +/// +internal class BackupOptions +{ + public bool Metadata { get; set; } + + public bool Trickplay { get; set; } + + public bool Subtitles { get; set; } +} diff --git a/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs new file mode 100644 index 000000000..c3f5b0103 --- /dev/null +++ b/Jellyfin.Server.Implementations/FullSystemBackup/BackupService.cs @@ -0,0 +1,463 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.StorageHelpers; +using Jellyfin.Server.Implementations.SystemBackupService; +using MediaBrowser.Controller; +using MediaBrowser.Controller.SystemBackupService; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Implementations.FullSystemBackup; + +/// +/// Contains methods for creating and restoring backups. +/// +public class BackupService : IBackupService +{ + private const string ManifestEntryName = "manifest.json"; + private readonly ILogger _logger; + private readonly IDbContextFactory _dbProvider; + private readonly IServerApplicationHost _applicationHost; + private readonly IServerApplicationPaths _applicationPaths; + private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider; + private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General) + { + AllowTrailingCommas = true, + ReferenceHandler = ReferenceHandler.IgnoreCycles, + }; + + private readonly Version _backupEngineVersion = Version.Parse("0.1.0"); + + /// + /// Initializes a new instance of the class. + /// + /// A logger. + /// A Database Factory. + /// The Application host. + /// The application paths. + /// The Jellyfin database Provider in use. + public BackupService( + ILogger logger, + IDbContextFactory dbProvider, + IServerApplicationHost applicationHost, + IServerApplicationPaths applicationPaths, + IJellyfinDatabaseProvider jellyfinDatabaseProvider) + { + _logger = logger; + _dbProvider = dbProvider; + _applicationHost = applicationHost; + _applicationPaths = applicationPaths; + _jellyfinDatabaseProvider = jellyfinDatabaseProvider; + } + + /// + public void ScheduleRestoreAndRestartServer(string archivePath) + { + _applicationHost.RestoreBackupPath = archivePath; + _applicationHost.ShouldRestart = true; + _applicationHost.NotifyPendingRestart(); + } + + /// + public async Task RestoreBackupAsync(string archivePath) + { + _logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it + if (!File.Exists(archivePath)) + { + throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist."); + } + + StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger); + + var fileStream = File.OpenRead(archivePath); + await using (fileStream.ConfigureAwait(false)) + { + using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false); + var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName); + + if (zipArchiveEntry is null) + { + throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin backup as its missing the '{ManifestEntryName}'."); + } + + BackupManifest? manifest; + var manifestStream = zipArchiveEntry.Open(); + await using (manifestStream.ConfigureAwait(false)) + { + manifest = await JsonSerializer.DeserializeAsync(manifestStream, _serializerSettings).ConfigureAwait(false); + } + + if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be able to load older versions as we have migrations. + { + throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version."); + } + + if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion)) + { + throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version."); + } + + void CopyDirectory(string source, string target) + { + source = Path.GetFullPath(source); + Directory.CreateDirectory(source); + + foreach (var item in zipArchive.Entries) + { + var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar); + if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal)) + { + continue; + } + + var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/')); + _logger.LogInformation("Restore and override {File}", targetPath); + item.ExtractToFile(targetPath); + } + } + + CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/"); + CopyDirectory(_applicationPaths.DataPath, "Data/"); + CopyDirectory(_applicationPaths.RootFolderPath, "Root/"); + + _logger.LogInformation("Begin restoring Database"); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) + .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable)) + .ToArray(); + + var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!); + _logger.LogInformation("Begin purging database"); + await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false); + _logger.LogInformation("Database Purged"); + + foreach (var entityType in entityTypes) + { + _logger.LogInformation("Read backup of {Table}", entityType.Type.Name); + + var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json"); + if (zipEntry is null) + { + _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); + continue; + } + + var zipEntryStream = zipEntry.Open(); + await using (zipEntryStream.ConfigureAwait(false)) + { + _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name); + var records = 0; + await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable(zipEntryStream, _serializerSettings).ConfigureAwait(false)!) + { + var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]); + if (entity is null) + { + throw new InvalidOperationException($"Cannot deserialize entity '{item}'"); + } + + try + { + records++; + dbContext.Add(entity); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item); + } + } + + _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name); + } + } + + _logger.LogInformation("Try restore Database"); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + _logger.LogInformation("Restored database."); + } + + _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated); + } + } + + private bool TestBackupVersionCompatibility(Version backupEngineVersion) + { + if (backupEngineVersion == _backupEngineVersion) + { + return true; + } + + return false; + } + + /// + public async Task CreateBackupAsync(BackupOptionsDto backupOptions) + { + var manifest = new BackupManifest() + { + DateCreated = DateTime.UtcNow, + ServerVersion = _applicationHost.ApplicationVersion, + DatabaseTables = null!, + BackupEngineVersion = _backupEngineVersion, + Options = Map(backupOptions) + }; + + await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false); + + var backupFolder = Path.Combine(_applicationPaths.BackupPath); + + if (!Directory.Exists(backupFolder)) + { + Directory.CreateDirectory(backupFolder); + } + + var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath); + + const long FiveGigabyte = 5_368_709_115; + if (backupStorageSpace.FreeSpace < FiveGigabyte) + { + throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at least '{StorageHelper.HumanizeStorageSize(FiveGigabyte)}' free space. Cannot create backup."); + } + + var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip"); + _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath); + var fileStream = File.OpenWrite(backupPath); + await using (fileStream.ConfigureAwait(false)) + using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false)) + { + _logger.LogInformation("Start backup process."); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) + .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable)) + .ToArray(); + manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray(); + var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false); + + await using (transaction.ConfigureAwait(false)) + { + _logger.LogInformation("Begin Database backup"); + static IAsyncEnumerable GetValues(IQueryable dbSet, Type type) + { + var method = dbSet.GetType().GetMethod(nameof(DbSet.AsAsyncEnumerable))!; + var enumerable = method.Invoke(dbSet, null)!; + return (IAsyncEnumerable)enumerable; + } + + foreach (var entityType in entityTypes) + { + _logger.LogInformation("Begin backup of entity {Table}", entityType.Type.Name); + var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.Type.Name}.json"); + var entities = 0; + var zipEntryStream = zipEntry.Open(); + await using (zipEntryStream.ConfigureAwait(false)) + { + var jsonSerializer = new Utf8JsonWriter(zipEntryStream); + await using (jsonSerializer.ConfigureAwait(false)) + { + jsonSerializer.WriteStartArray(); + + var set = GetValues(entityType.Set!, entityType.Type.PropertyType).ConfigureAwait(false); + await foreach (var item in set.ConfigureAwait(false)) + { + entities++; + try + { + JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not load entity {Entity}", item); + throw; + } + } + + jsonSerializer.WriteEndArray(); + } + } + + _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities); + } + } + } + + _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath); + foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly) + .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly))) + { + zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item))); + } + + void CopyDirectory(string source, string target, string filter = "*") + { + if (!Directory.Exists(source)) + { + return; + } + + _logger.LogInformation("Backup of folder {Table}", source); + + foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories)) + { + zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\'))); + } + } + + CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users")); + CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks")); + CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root"); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections")); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists")); + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks")); + if (backupOptions.Subtitles) + { + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles")); + } + + if (backupOptions.Trickplay) + { + CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay")); + } + + if (backupOptions.Metadata) + { + CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); + } + + var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); + await using (manifestStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false); + } + } + + _logger.LogInformation("Backup created"); + return Map(manifest, backupPath); + } + + /// + public async Task GetBackupManifest(string archivePath) + { + if (!File.Exists(archivePath)) + { + return null; + } + + BackupManifest? manifest; + try + { + manifest = await GetManifest(archivePath).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath); + return null; + } + + if (manifest is null) + { + return null; + } + + return Map(manifest, archivePath); + } + + /// + public async Task EnumerateBackups() + { + if (!Directory.Exists(_applicationPaths.BackupPath)) + { + return []; + } + + var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip"); + var manifests = new List(); + foreach (var item in archives) + { + try + { + var manifest = await GetManifest(item).ConfigureAwait(false); + + if (manifest is null) + { + continue; + } + + manifests.Add(Map(manifest, item)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not load {BackupArchive} path.", item); + } + } + + return manifests.ToArray(); + } + + private static async ValueTask GetManifest(string archivePath) + { + var archiveStream = File.OpenRead(archivePath); + await using (archiveStream.ConfigureAwait(false)) + { + using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read); + var manifestEntry = zipStream.GetEntry(ManifestEntryName); + if (manifestEntry is null) + { + return null; + } + + var manifestStream = manifestEntry.Open(); + await using (manifestStream.ConfigureAwait(false)) + { + return await JsonSerializer.DeserializeAsync(manifestStream, _serializerSettings).ConfigureAwait(false); + } + } + } + + private static BackupManifestDto Map(BackupManifest manifest, string path) + { + return new BackupManifestDto() + { + BackupEngineVersion = manifest.BackupEngineVersion, + DateCreated = manifest.DateCreated, + ServerVersion = manifest.ServerVersion, + Path = path, + Options = Map(manifest.Options) + }; + } + + private static BackupOptionsDto Map(BackupOptions options) + { + return new BackupOptionsDto() + { + Metadata = options.Metadata, + Subtitles = options.Subtitles, + Trickplay = options.Trickplay + }; + } + + private static BackupOptions Map(BackupOptionsDto options) + { + return new BackupOptions() + { + Metadata = options.Metadata, + Subtitles = options.Subtitles, + Trickplay = options.Trickplay + }; + } +} diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index ebffab7ef..3d6ed73bc 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -7,8 +7,10 @@ using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Serialization; using Jellyfin.Database.Implementations; +using Jellyfin.Server.Implementations.SystemBackupService; using Jellyfin.Server.Migrations.Stages; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.SystemBackupService; using MediaBrowser.Model.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -103,25 +105,33 @@ internal class JellyfinMigrationService if (migrationOptions != null && migrationOptions.Applied.Count > 0) { logger.LogInformation("Old migration style migration.xml detected. Migrate now."); - var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) + try { - var historyRepository = dbContext.GetService(); - var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false); - var oldMigrations = Migrations - .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations. - .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value))) - .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId())) - .ToArray(); - var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion())))); - foreach (var item in startupScripts) + var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name); - await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false); + var historyRepository = dbContext.GetService(); + var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false); + var oldMigrations = Migrations + .SelectMany(e => e.Where(e => e.Metadata.Key is not null)) // only consider migrations that have the key set as its the reference marker for legacy migrations. + .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value))) + .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId())) + .ToArray(); + var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion())))); + foreach (var item in startupScripts) + { + logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name); + await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false); + } + + logger.LogInformation("Rename old migration.xml to migration.xml.backup"); + File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true); } - - logger.LogInformation("Rename old migration.xml to migration.xml.backup"); - File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true); + } + catch (Exception ex) + { + logger.LogCritical(ex, "Failed to apply migrations"); + throw; } } } @@ -155,6 +165,7 @@ internal class JellyfinMigrationService (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + foreach (var item in migrations) { try diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 745f92420..4584b25bd 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -16,7 +16,9 @@ using Jellyfin.Server.Extensions; using Jellyfin.Server.Helpers; using Jellyfin.Server.Implementations.DatabaseConfiguration; using Jellyfin.Server.Implementations.Extensions; +using Jellyfin.Server.Implementations.FullSystemBackup; using Jellyfin.Server.Implementations.StorageHelpers; +using Jellyfin.Server.Implementations.SystemBackupService; using Jellyfin.Server.Migrations; using Jellyfin.Server.ServerSetupApp; using MediaBrowser.Common.Configuration; @@ -58,6 +60,7 @@ namespace Jellyfin.Server private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; + private static string? _restoreFromBackup; /// /// The entry point of the application. @@ -79,6 +82,7 @@ namespace Jellyfin.Server private static async Task StartApp(StartupOptions options) { + _restoreFromBackup = options.RestoreArchive; _startTimestamp = Stopwatch.GetTimestamp(); ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options); appPaths.MakeSanityCheckOrThrow(); @@ -176,9 +180,16 @@ namespace Jellyfin.Server // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. appHost.ServiceProvider = _jellyfinHost.Services; - PrepareDatabaseProvider(appHost.ServiceProvider); + if (!string.IsNullOrWhiteSpace(_restoreFromBackup)) + { + await appHost.ServiceProvider.GetService()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false); + _restoreFromBackup = null; + _restartOnShutdown = true; + return; + } + await ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisaition).ConfigureAwait(false); await appHost.InitializeServices(startupConfig).ConfigureAwait(false); @@ -209,6 +220,7 @@ namespace Jellyfin.Server await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false); _restartOnShutdown = appHost.ShouldRestart; + _restoreFromBackup = appHost.RestoreBackupPath; } catch (Exception ex) { diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs index 91ac827ca..4890ccbb2 100644 --- a/Jellyfin.Server/StartupOptions.cs +++ b/Jellyfin.Server/StartupOptions.cs @@ -73,6 +73,12 @@ namespace Jellyfin.Server [Option("nonetchange", Required = false, HelpText = "Indicates that the server should not detect network status change.")] public bool NoDetectNetworkChange { get; set; } + /// + /// Gets or sets the path to an jellyfin backup archive to restore the application to. + /// + [Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")] + public string? RestoreArchive { get; set; } + /// /// Gets the command line options as a dictionary that can be used in the .NET configuration system. /// diff --git a/MediaBrowser.Common/Configuration/IApplicationPaths.cs b/MediaBrowser.Common/Configuration/IApplicationPaths.cs index fa0d8247b..6d1a72b04 100644 --- a/MediaBrowser.Common/Configuration/IApplicationPaths.cs +++ b/MediaBrowser.Common/Configuration/IApplicationPaths.cs @@ -91,6 +91,12 @@ namespace MediaBrowser.Common.Configuration /// The trickplay path. string TrickplayPath { get; } + /// + /// Gets the path used for storing backup archives. + /// + /// The backup path. + string BackupPath { get; } + /// /// Checks and creates all known base paths. /// diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index e9c4d9e19..b76141db0 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -38,6 +38,11 @@ namespace MediaBrowser.Controller /// The name of the friendly. string FriendlyName { get; } + /// + /// Gets or sets the path to the backup archive used to restore upon restart. + /// + string RestoreBackupPath { get; set; } + /// /// Gets a URL specific for the request. /// diff --git a/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs new file mode 100644 index 000000000..b094ec275 --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/BackupManifestDto.cs @@ -0,0 +1,34 @@ +using System; + +namespace MediaBrowser.Controller.SystemBackupService; + +/// +/// Manifest type for backups internal structure. +/// +public class BackupManifestDto +{ + /// + /// Gets or sets the jellyfin version this backup was created with. + /// + public required Version ServerVersion { get; set; } + + /// + /// Gets or sets the backup engine version this backup was created with. + /// + public required Version BackupEngineVersion { get; set; } + + /// + /// Gets or sets the date this backup was created with. + /// + public required DateTimeOffset DateCreated { get; set; } + + /// + /// Gets or sets the path to the backup on the system. + /// + public required string Path { get; set; } + + /// + /// Gets or sets the contents of the backup archive. + /// + public required BackupOptionsDto Options { get; set; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs new file mode 100644 index 000000000..228839a1d --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/BackupOptionsDto.cs @@ -0,0 +1,24 @@ +using System; + +namespace MediaBrowser.Controller.SystemBackupService; + +/// +/// Defines the optional contents of the backup archive. +/// +public class BackupOptionsDto +{ + /// + /// Gets or sets a value indicating whether the archive contains the Metadata contents. + /// + public bool Metadata { get; set; } + + /// + /// Gets or sets a value indicating whether the archive contains the Trickplay contents. + /// + public bool Trickplay { get; set; } + + /// + /// Gets or sets a value indicating whether the archive contains the Subtitle contents. + /// + public bool Subtitles { get; set; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs b/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs new file mode 100644 index 000000000..263fa00c8 --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/BackupRestoreRequestDto.cs @@ -0,0 +1,15 @@ +using System; +using MediaBrowser.Common.Configuration; + +namespace MediaBrowser.Controller.SystemBackupService; + +/// +/// Defines properties used to start a restore process. +/// +public class BackupRestoreRequestDto +{ + /// + /// Gets or Sets the name of the backup archive to restore from. Must be present in . + /// + public required string ArchiveFileName { get; set; } +} diff --git a/MediaBrowser.Controller/SystemBackupService/IBackupService.cs b/MediaBrowser.Controller/SystemBackupService/IBackupService.cs new file mode 100644 index 000000000..0c586d811 --- /dev/null +++ b/MediaBrowser.Controller/SystemBackupService/IBackupService.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using MediaBrowser.Controller.SystemBackupService; + +namespace Jellyfin.Server.Implementations.SystemBackupService; + +/// +/// Defines an interface to restore and backup the jellyfin system. +/// +public interface IBackupService +{ + /// + /// Creates a new Backup zip file containing the current state of the application. + /// + /// The backup options. + /// A task. + Task CreateBackupAsync(BackupOptionsDto backupOptions); + + /// + /// Gets a list of backups that are available to be restored from. + /// + /// A list of backup paths. + Task EnumerateBackups(); + + /// + /// Gets a single backup manifest if the path defines a valid Jellyfin backup archive. + /// + /// The path to be loaded. + /// The containing backup manifest or null if not existing or compatiable. + Task GetBackupManifest(string archivePath); + + /// + /// Restores an backup zip file created by jellyfin. + /// + /// Path to the archive. + /// A Task. + /// Thrown when an invalid or missing file is specified. + /// Thrown when attempt to load an unsupported backup is made. + /// Thrown for errors during the restore. + Task RestoreBackupAsync(string archivePath); + + /// + /// Schedules a Restore and restarts the server. + /// + /// The path to the archive to restore from. + void ScheduleRestoreAndRestartServer(string archivePath); +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs index 06b290e4f..39b449553 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/TrickplayInfo.cs @@ -14,7 +14,6 @@ public class TrickplayInfo /// /// Required. /// - [JsonIgnore] public Guid ItemId { get; set; } /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index 4da7074ec..6c81fa729 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs @@ -61,7 +61,6 @@ namespace Jellyfin.Database.Implementations.Entities /// /// Identity, Indexed, Required. /// - [JsonIgnore] public Guid Id { get; set; } /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs index 34ac7dc83..b0dc98469 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/IJellyfinDatabaseProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -62,4 +63,12 @@ public interface IJellyfinDatabaseProvider /// A cancellation token. /// A representing the result of the asynchronous operation. Task RestoreBackupFast(string key, CancellationToken cancellationToken); + + /// + /// Removes all contents from the database. + /// + /// The Database context. + /// The names of the tables to purge or null for all tables to be purged. + /// A Task. + Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames); } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs index 156d9618e..519584003 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/SqliteDatabaseProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Threading; @@ -82,7 +83,7 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider } // Run before disposing the application - var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); @@ -127,4 +128,25 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider File.Copy(backupFile, path, true); return Task.CompletedTask; } + + /// + public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable? tableNames) + { + ArgumentNullException.ThrowIfNull(tableNames); + + var deleteQueries = new List(); + foreach (var tableName in tableNames) + { + deleteQueries.Add($"DELETE FROM \"{tableName}\";"); + } + + var deleteAllQuery = + $""" + PRAGMA foreign_keys = OFF; + {string.Join('\n', deleteQueries)} + PRAGMA foreign_keys = ON; + """; + + await dbContext.Database.ExecuteSqlRawAsync(deleteAllQuery).ConfigureAwait(false); + } } diff --git a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs index dd84c1a18..8cb3cde2b 100644 --- a/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs +++ b/tests/Jellyfin.Api.Tests/Controllers/SystemControllerTests.cs @@ -1,4 +1,5 @@ using Jellyfin.Api.Controllers; +using Jellyfin.Server.Implementations.SystemBackupService; using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Model.IO; -- cgit v1.2.3