BackupService.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.IO.Compression;
  5. using System.Linq;
  6. using System.Text.Json;
  7. using System.Text.Json.Nodes;
  8. using System.Text.Json.Serialization;
  9. using System.Threading;
  10. using System.Threading.Tasks;
  11. using Jellyfin.Database.Implementations;
  12. using Jellyfin.Server.Implementations.StorageHelpers;
  13. using Jellyfin.Server.Implementations.SystemBackupService;
  14. using MediaBrowser.Controller;
  15. using MediaBrowser.Controller.SystemBackupService;
  16. using Microsoft.EntityFrameworkCore;
  17. using Microsoft.EntityFrameworkCore.Infrastructure;
  18. using Microsoft.EntityFrameworkCore.Migrations;
  19. using Microsoft.Extensions.Logging;
  20. namespace Jellyfin.Server.Implementations.FullSystemBackup;
  21. /// <summary>
  22. /// Contains methods for creating and restoring backups.
  23. /// </summary>
  24. public class BackupService : IBackupService
  25. {
  26. private const string ManifestEntryName = "manifest.json";
  27. private readonly ILogger<BackupService> _logger;
  28. private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
  29. private readonly IServerApplicationHost _applicationHost;
  30. private readonly IServerApplicationPaths _applicationPaths;
  31. private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
  32. private readonly ISystemManager _systemManager;
  33. private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
  34. {
  35. AllowTrailingCommas = true,
  36. ReferenceHandler = ReferenceHandler.IgnoreCycles,
  37. };
  38. private readonly Version _backupEngineVersion = Version.Parse("0.1.0");
  39. /// <summary>
  40. /// Initializes a new instance of the <see cref="BackupService"/> class.
  41. /// </summary>
  42. /// <param name="logger">A logger.</param>
  43. /// <param name="dbProvider">A Database Factory.</param>
  44. /// <param name="applicationHost">The Application host.</param>
  45. /// <param name="applicationPaths">The application paths.</param>
  46. /// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
  47. /// <param name="systemManager">The SystemManager.</param>
  48. public BackupService(
  49. ILogger<BackupService> logger,
  50. IDbContextFactory<JellyfinDbContext> dbProvider,
  51. IServerApplicationHost applicationHost,
  52. IServerApplicationPaths applicationPaths,
  53. IJellyfinDatabaseProvider jellyfinDatabaseProvider,
  54. ISystemManager systemManager)
  55. {
  56. _logger = logger;
  57. _dbProvider = dbProvider;
  58. _applicationHost = applicationHost;
  59. _applicationPaths = applicationPaths;
  60. _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
  61. _systemManager = systemManager;
  62. }
  63. /// <inheritdoc/>
  64. public void ScheduleRestoreAndRestartServer(string archivePath)
  65. {
  66. _applicationHost.RestoreBackupPath = archivePath;
  67. _applicationHost.ShouldRestart = true;
  68. _applicationHost.NotifyPendingRestart();
  69. _systemManager.Restart();
  70. }
  71. /// <inheritdoc/>
  72. public async Task RestoreBackupAsync(string archivePath)
  73. {
  74. _logger.LogWarning("Begin restoring system to {BackupArchive}", archivePath); // Info isn't cutting it
  75. if (!File.Exists(archivePath))
  76. {
  77. throw new FileNotFoundException($"Requested backup file '{archivePath}' does not exist.");
  78. }
  79. StorageHelper.TestCommonPathsForStorageCapacity(_applicationPaths, _logger);
  80. var fileStream = File.OpenRead(archivePath);
  81. await using (fileStream.ConfigureAwait(false))
  82. {
  83. using var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Read, false);
  84. var zipArchiveEntry = zipArchive.GetEntry(ManifestEntryName);
  85. if (zipArchiveEntry is null)
  86. {
  87. throw new NotSupportedException($"The loaded archive '{archivePath}' does not appear to be a Jellyfin backup as its missing the '{ManifestEntryName}'.");
  88. }
  89. BackupManifest? manifest;
  90. var manifestStream = zipArchiveEntry.Open();
  91. await using (manifestStream.ConfigureAwait(false))
  92. {
  93. manifest = await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
  94. }
  95. if (manifest!.ServerVersion > _applicationHost.ApplicationVersion) // newer versions of Jellyfin should be able to load older versions as we have migrations.
  96. {
  97. throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
  98. }
  99. if (!TestBackupVersionCompatibility(manifest.BackupEngineVersion))
  100. {
  101. throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
  102. }
  103. void CopyDirectory(string source, string target)
  104. {
  105. source = Path.GetFullPath(source);
  106. Directory.CreateDirectory(source);
  107. foreach (var item in zipArchive.Entries)
  108. {
  109. var sanitizedSourcePath = Path.GetFullPath(item.FullName.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar);
  110. if (!sanitizedSourcePath.StartsWith(target, StringComparison.Ordinal))
  111. {
  112. continue;
  113. }
  114. var targetPath = Path.Combine(source, sanitizedSourcePath[target.Length..].Trim('/'));
  115. _logger.LogInformation("Restore and override {File}", targetPath);
  116. item.ExtractToFile(targetPath);
  117. }
  118. }
  119. CopyDirectory(_applicationPaths.ConfigurationDirectoryPath, "Config/");
  120. CopyDirectory(_applicationPaths.DataPath, "Data/");
  121. CopyDirectory(_applicationPaths.RootFolderPath, "Root/");
  122. _logger.LogInformation("Begin restoring Database");
  123. var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
  124. await using (dbContext.ConfigureAwait(false))
  125. {
  126. // restore migration history manually
  127. var historyEntry = zipArchive.GetEntry($"Database\\{nameof(HistoryRow)}.json");
  128. if (historyEntry is null)
  129. {
  130. _logger.LogInformation("No backup of the history table in archive. This is required for Jellyfin operation");
  131. throw new InvalidOperationException("Cannot restore backup that has no History data.");
  132. }
  133. HistoryRow[] historyEntries;
  134. var historyArchive = historyEntry.Open();
  135. await using (historyArchive.ConfigureAwait(false))
  136. {
  137. historyEntries = await JsonSerializer.DeserializeAsync<HistoryRow[]>(historyArchive).ConfigureAwait(false) ??
  138. throw new InvalidOperationException("Cannot restore backup that has no History data.");
  139. }
  140. var historyRepository = dbContext.GetService<IHistoryRepository>();
  141. await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
  142. foreach (var item in historyEntries)
  143. {
  144. var insertScript = historyRepository.GetInsertScript(item);
  145. await dbContext.Database.ExecuteSqlRawAsync(insertScript).ConfigureAwait(false);
  146. }
  147. dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
  148. var entityTypes = typeof(JellyfinDbContext).GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
  149. .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
  150. .Select(e => (Type: e, Set: e.GetValue(dbContext) as IQueryable))
  151. .ToArray();
  152. var tableNames = entityTypes.Select(f => dbContext.Model.FindEntityType(f.Type.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!);
  153. _logger.LogInformation("Begin purging database");
  154. await _jellyfinDatabaseProvider.PurgeDatabase(dbContext, tableNames).ConfigureAwait(false);
  155. _logger.LogInformation("Database Purged");
  156. foreach (var entityType in entityTypes)
  157. {
  158. _logger.LogInformation("Read backup of {Table}", entityType.Type.Name);
  159. var zipEntry = zipArchive.GetEntry($"Database\\{entityType.Type.Name}.json");
  160. if (zipEntry is null)
  161. {
  162. _logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
  163. continue;
  164. }
  165. var zipEntryStream = zipEntry.Open();
  166. await using (zipEntryStream.ConfigureAwait(false))
  167. {
  168. _logger.LogInformation("Restore backup of {Table}", entityType.Type.Name);
  169. var records = 0;
  170. await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<JsonObject>(zipEntryStream, _serializerSettings).ConfigureAwait(false)!)
  171. {
  172. var entity = item.Deserialize(entityType.Type.PropertyType.GetGenericArguments()[0]);
  173. if (entity is null)
  174. {
  175. throw new InvalidOperationException($"Cannot deserialize entity '{item}'");
  176. }
  177. try
  178. {
  179. records++;
  180. dbContext.Add(entity);
  181. }
  182. catch (Exception ex)
  183. {
  184. _logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
  185. }
  186. }
  187. _logger.LogInformation("Prepared to restore {Number} entries for {Table}", records, entityType.Type.Name);
  188. }
  189. }
  190. _logger.LogInformation("Try restore Database");
  191. await dbContext.SaveChangesAsync().ConfigureAwait(false);
  192. _logger.LogInformation("Restored database.");
  193. }
  194. _logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
  195. }
  196. }
  197. private bool TestBackupVersionCompatibility(Version backupEngineVersion)
  198. {
  199. if (backupEngineVersion == _backupEngineVersion)
  200. {
  201. return true;
  202. }
  203. return false;
  204. }
  205. /// <inheritdoc/>
  206. public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
  207. {
  208. var manifest = new BackupManifest()
  209. {
  210. DateCreated = DateTime.UtcNow,
  211. ServerVersion = _applicationHost.ApplicationVersion,
  212. DatabaseTables = null!,
  213. BackupEngineVersion = _backupEngineVersion,
  214. Options = Map(backupOptions)
  215. };
  216. await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
  217. var backupFolder = Path.Combine(_applicationPaths.BackupPath);
  218. if (!Directory.Exists(backupFolder))
  219. {
  220. Directory.CreateDirectory(backupFolder);
  221. }
  222. var backupStorageSpace = StorageHelper.GetFreeSpaceOf(_applicationPaths.BackupPath);
  223. const long FiveGigabyte = 5_368_709_115;
  224. if (backupStorageSpace.FreeSpace < FiveGigabyte)
  225. {
  226. throw new InvalidOperationException($"The backup directory '{backupStorageSpace.Path}' does not have at least '{StorageHelper.HumanizeStorageSize(FiveGigabyte)}' free space. Cannot create backup.");
  227. }
  228. var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
  229. _logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
  230. var fileStream = File.OpenWrite(backupPath);
  231. await using (fileStream.ConfigureAwait(false))
  232. using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
  233. {
  234. _logger.LogInformation("Start backup process.");
  235. var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
  236. await using (dbContext.ConfigureAwait(false))
  237. {
  238. dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
  239. static IAsyncEnumerable<object> GetValues(IQueryable dbSet, Type type)
  240. {
  241. var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
  242. var enumerable = method.Invoke(dbSet, null)!;
  243. return (IAsyncEnumerable<object>)enumerable;
  244. }
  245. // include the migration history as well
  246. var historyRepository = dbContext.GetService<IHistoryRepository>();
  247. var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
  248. ICollection<(Type Type, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
  249. .. typeof(JellyfinDbContext)
  250. .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
  251. .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
  252. .Select(e => (Type: e.PropertyType, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!, e.PropertyType)))),
  253. (Type: typeof(HistoryRow), ValueFactory: new Func<IAsyncEnumerable<object>>(() => migrations.ToAsyncEnumerable()))
  254. ];
  255. manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
  256. var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
  257. await using (transaction.ConfigureAwait(false))
  258. {
  259. _logger.LogInformation("Begin Database backup");
  260. foreach (var entityType in entityTypes)
  261. {
  262. _logger.LogInformation("Begin backup of entity {Table}", entityType.Type.Name);
  263. var zipEntry = zipArchive.CreateEntry($"Database\\{entityType.Type.Name}.json");
  264. var entities = 0;
  265. var zipEntryStream = zipEntry.Open();
  266. await using (zipEntryStream.ConfigureAwait(false))
  267. {
  268. var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
  269. await using (jsonSerializer.ConfigureAwait(false))
  270. {
  271. jsonSerializer.WriteStartArray();
  272. var set = entityType.ValueFactory().ConfigureAwait(false);
  273. await foreach (var item in set.ConfigureAwait(false))
  274. {
  275. entities++;
  276. try
  277. {
  278. JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer);
  279. }
  280. catch (Exception ex)
  281. {
  282. _logger.LogError(ex, "Could not load entity {Entity}", item);
  283. throw;
  284. }
  285. }
  286. jsonSerializer.WriteEndArray();
  287. }
  288. }
  289. _logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities);
  290. }
  291. }
  292. }
  293. _logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
  294. foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
  295. .Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
  296. {
  297. zipArchive.CreateEntryFromFile(item, Path.Combine("Config", Path.GetFileName(item)));
  298. }
  299. void CopyDirectory(string source, string target, string filter = "*")
  300. {
  301. if (!Directory.Exists(source))
  302. {
  303. return;
  304. }
  305. _logger.LogInformation("Backup of folder {Table}", source);
  306. foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
  307. {
  308. zipArchive.CreateEntryFromFile(item, Path.Combine(target, item[..source.Length].Trim('\\')));
  309. }
  310. }
  311. CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
  312. CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
  313. CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
  314. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
  315. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
  316. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
  317. if (backupOptions.Subtitles)
  318. {
  319. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
  320. }
  321. if (backupOptions.Trickplay)
  322. {
  323. CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
  324. }
  325. if (backupOptions.Metadata)
  326. {
  327. CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
  328. }
  329. var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
  330. await using (manifestStream.ConfigureAwait(false))
  331. {
  332. await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
  333. }
  334. }
  335. _logger.LogInformation("Backup created");
  336. return Map(manifest, backupPath);
  337. }
  338. /// <inheritdoc/>
  339. public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
  340. {
  341. if (!File.Exists(archivePath))
  342. {
  343. return null;
  344. }
  345. BackupManifest? manifest;
  346. try
  347. {
  348. manifest = await GetManifest(archivePath).ConfigureAwait(false);
  349. }
  350. catch (Exception ex)
  351. {
  352. _logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
  353. return null;
  354. }
  355. if (manifest is null)
  356. {
  357. return null;
  358. }
  359. return Map(manifest, archivePath);
  360. }
  361. /// <inheritdoc/>
  362. public async Task<BackupManifestDto[]> EnumerateBackups()
  363. {
  364. if (!Directory.Exists(_applicationPaths.BackupPath))
  365. {
  366. return [];
  367. }
  368. var archives = Directory.EnumerateFiles(_applicationPaths.BackupPath, "*.zip");
  369. var manifests = new List<BackupManifestDto>();
  370. foreach (var item in archives)
  371. {
  372. try
  373. {
  374. var manifest = await GetManifest(item).ConfigureAwait(false);
  375. if (manifest is null)
  376. {
  377. continue;
  378. }
  379. manifests.Add(Map(manifest, item));
  380. }
  381. catch (Exception ex)
  382. {
  383. _logger.LogError(ex, "Could not load {BackupArchive} path.", item);
  384. }
  385. }
  386. return manifests.ToArray();
  387. }
  388. private static async ValueTask<BackupManifest?> GetManifest(string archivePath)
  389. {
  390. var archiveStream = File.OpenRead(archivePath);
  391. await using (archiveStream.ConfigureAwait(false))
  392. {
  393. using var zipStream = new ZipArchive(archiveStream, ZipArchiveMode.Read);
  394. var manifestEntry = zipStream.GetEntry(ManifestEntryName);
  395. if (manifestEntry is null)
  396. {
  397. return null;
  398. }
  399. var manifestStream = manifestEntry.Open();
  400. await using (manifestStream.ConfigureAwait(false))
  401. {
  402. return await JsonSerializer.DeserializeAsync<BackupManifest>(manifestStream, _serializerSettings).ConfigureAwait(false);
  403. }
  404. }
  405. }
  406. private static BackupManifestDto Map(BackupManifest manifest, string path)
  407. {
  408. return new BackupManifestDto()
  409. {
  410. BackupEngineVersion = manifest.BackupEngineVersion,
  411. DateCreated = manifest.DateCreated,
  412. ServerVersion = manifest.ServerVersion,
  413. Path = path,
  414. Options = Map(manifest.Options)
  415. };
  416. }
  417. private static BackupOptionsDto Map(BackupOptions options)
  418. {
  419. return new BackupOptionsDto()
  420. {
  421. Metadata = options.Metadata,
  422. Subtitles = options.Subtitles,
  423. Trickplay = options.Trickplay
  424. };
  425. }
  426. private static BackupOptions Map(BackupOptionsDto options)
  427. {
  428. return new BackupOptions()
  429. {
  430. Metadata = options.Metadata,
  431. Subtitles = options.Subtitles,
  432. Trickplay = options.Trickplay
  433. };
  434. }
  435. }