2
0

JellyfinMigrationService.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Globalization;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Reflection;
  7. using System.Threading;
  8. using System.Threading.Tasks;
  9. using Emby.Server.Implementations.Serialization;
  10. using Jellyfin.Database.Implementations;
  11. using Jellyfin.Server.Implementations.SystemBackupService;
  12. using Jellyfin.Server.Migrations.Stages;
  13. using Jellyfin.Server.ServerSetupApp;
  14. using MediaBrowser.Common.Configuration;
  15. using MediaBrowser.Controller.SystemBackupService;
  16. using MediaBrowser.Model.Configuration;
  17. using Microsoft.EntityFrameworkCore;
  18. using Microsoft.EntityFrameworkCore.Infrastructure;
  19. using Microsoft.EntityFrameworkCore.Migrations;
  20. using Microsoft.Extensions.Logging;
  21. namespace Jellyfin.Server.Migrations;
  22. /// <summary>
  23. /// Handles Migration of the Jellyfin data structure.
  24. /// </summary>
  25. internal class JellyfinMigrationService
  26. {
  27. private const string DbFilename = "library.db";
  28. private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
  29. private readonly ILoggerFactory _loggerFactory;
  30. private readonly IStartupLogger _startupLogger;
  31. private readonly IBackupService? _backupService;
  32. private readonly IJellyfinDatabaseProvider? _jellyfinDatabaseProvider;
  33. private readonly IApplicationPaths _applicationPaths;
  34. private (string? LibraryDb, string? JellyfinDb, BackupManifestDto? FullBackup) _backupKey;
  35. /// <summary>
  36. /// Initializes a new instance of the <see cref="JellyfinMigrationService"/> class.
  37. /// </summary>
  38. /// <param name="dbContextFactory">Provides access to the jellyfin database.</param>
  39. /// <param name="loggerFactory">The logger factory.</param>
  40. /// <param name="startupLogger">The startup logger for Startup UI intigration.</param>
  41. /// <param name="applicationPaths">Application paths for library.db backup.</param>
  42. /// <param name="backupService">The jellyfin backup service.</param>
  43. /// <param name="jellyfinDatabaseProvider">The jellyfin database provider.</param>
  44. public JellyfinMigrationService(
  45. IDbContextFactory<JellyfinDbContext> dbContextFactory,
  46. ILoggerFactory loggerFactory,
  47. IStartupLogger startupLogger,
  48. IApplicationPaths applicationPaths,
  49. IBackupService? backupService = null,
  50. IJellyfinDatabaseProvider? jellyfinDatabaseProvider = null)
  51. {
  52. _dbContextFactory = dbContextFactory;
  53. _loggerFactory = loggerFactory;
  54. _startupLogger = startupLogger;
  55. _backupService = backupService;
  56. _jellyfinDatabaseProvider = jellyfinDatabaseProvider;
  57. _applicationPaths = applicationPaths;
  58. #pragma warning disable CS0618 // Type or member is obsolete
  59. Migrations = [.. typeof(IMigrationRoutine).Assembly.GetTypes().Where(e => typeof(IMigrationRoutine).IsAssignableFrom(e) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(e))
  60. .Select(e => (Type: e, Metadata: e.GetCustomAttribute<JellyfinMigrationAttribute>(), Backup: e.GetCustomAttributes<JellyfinMigrationBackupAttribute>()))
  61. .Where(e => e.Metadata != null)
  62. .GroupBy(e => e.Metadata!.Stage)
  63. .Select(f =>
  64. {
  65. var stage = new MigrationStage(f.Key);
  66. foreach (var item in f)
  67. {
  68. JellyfinMigrationBackupAttribute? backupMetadata = null;
  69. if (item.Backup?.Any() == true)
  70. {
  71. backupMetadata = item.Backup.Aggregate(MergeBackupAttributes);
  72. }
  73. stage.Add(new(item.Type, item.Metadata!, backupMetadata));
  74. }
  75. return stage;
  76. })];
  77. #pragma warning restore CS0618 // Type or member is obsolete
  78. }
  79. private interface IInternalMigration
  80. {
  81. Task PerformAsync(IStartupLogger logger);
  82. }
  83. private HashSet<MigrationStage> Migrations { get; set; }
  84. public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
  85. {
  86. var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
  87. logger.LogInformation("Initialise Migration service.");
  88. var xmlSerializer = new MyXmlSerializer();
  89. var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
  90. ? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)!
  91. : new ServerConfiguration();
  92. if (!serverConfig.IsStartupWizardCompleted)
  93. {
  94. logger.LogInformation("System initialisation detected. Seed data.");
  95. var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray();
  96. var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
  97. await using (dbContext.ConfigureAwait(false))
  98. {
  99. var historyRepository = dbContext.GetService<IHistoryRepository>();
  100. await historyRepository.CreateIfNotExistsAsync().ConfigureAwait(false);
  101. var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
  102. var startupScripts = flatApplyMigrations
  103. .Where(e => !appliedMigrations.Any(f => f != e.BuildCodeMigrationId()))
  104. .Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))))
  105. .ToArray();
  106. foreach (var item in startupScripts)
  107. {
  108. logger.LogInformation("Seed migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
  109. await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
  110. }
  111. }
  112. logger.LogInformation("Migration system initialisation completed.");
  113. }
  114. else
  115. {
  116. // migrate any existing migration.xml files
  117. var migrationConfigPath = Path.Join(appPaths.ConfigurationDirectoryPath, "migrations.xml");
  118. var migrationOptions = File.Exists(migrationConfigPath)
  119. ? (MigrationOptions)xmlSerializer.DeserializeFromFile(typeof(MigrationOptions), migrationConfigPath)!
  120. : null;
  121. if (migrationOptions != null && migrationOptions.Applied.Count > 0)
  122. {
  123. logger.LogInformation("Old migration style migration.xml detected. Migrate now.");
  124. try
  125. {
  126. var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
  127. await using (dbContext.ConfigureAwait(false))
  128. {
  129. var historyRepository = dbContext.GetService<IHistoryRepository>();
  130. var appliedMigrations = await dbContext.Database.GetAppliedMigrationsAsync().ConfigureAwait(false);
  131. var lastOldAppliedMigration = Migrations
  132. .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.
  133. .Where(e => migrationOptions.Applied.Any(f => f.Id.Equals(e.Metadata.Key!.Value)))
  134. .Where(e => !appliedMigrations.Contains(e.BuildCodeMigrationId()))
  135. .OrderBy(e => e.BuildCodeMigrationId())
  136. .Last(); // this is the latest migration applied in the old migration.xml
  137. IReadOnlyList<CodeMigration> oldMigrations = [
  138. .. Migrations
  139. .SelectMany(e => e)
  140. .OrderBy(e => e.BuildCodeMigrationId())
  141. .TakeWhile(e => e.BuildCodeMigrationId() != lastOldAppliedMigration.BuildCodeMigrationId()),
  142. lastOldAppliedMigration
  143. ];
  144. // those are all migrations that had to run in the old migration system, even if not noted in the migration.xml file.
  145. var startupScripts = oldMigrations.Select(e => (Migration: e.Metadata, Script: historyRepository.GetInsertScript(new HistoryRow(e.BuildCodeMigrationId(), GetJellyfinVersion()))));
  146. foreach (var item in startupScripts)
  147. {
  148. logger.LogInformation("Migrate migration {Key}-{Name}.", item.Migration.Key, item.Migration.Name);
  149. await dbContext.Database.ExecuteSqlRawAsync(item.Script).ConfigureAwait(false);
  150. }
  151. logger.LogInformation("Rename old migration.xml to migration.xml.backup");
  152. File.Move(migrationConfigPath, Path.ChangeExtension(migrationConfigPath, ".xml.backup"), true);
  153. }
  154. }
  155. catch (Exception ex)
  156. {
  157. logger.LogCritical(ex, "Failed to apply migrations");
  158. throw;
  159. }
  160. }
  161. }
  162. }
  163. public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
  164. {
  165. var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
  166. ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
  167. var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
  168. await using (dbContext.ConfigureAwait(false))
  169. {
  170. var historyRepository = dbContext.GetService<IHistoryRepository>();
  171. var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
  172. var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
  173. var pendingCodeMigrations = migrationStage
  174. .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
  175. .Select(e => (Key: e.BuildCodeMigrationId(), Migration: new InternalCodeMigration(e, serviceProvider, dbContext)))
  176. .ToArray();
  177. (string Key, InternalDatabaseMigration Migration)[] pendingDatabaseMigrations = [];
  178. if (stage is JellyfinMigrationStageTypes.CoreInitialisation)
  179. {
  180. pendingDatabaseMigrations = migrationsAssembly.Migrations.Where(f => appliedMigrations.All(e => e.MigrationId != f.Key))
  181. .Select(e => (Key: e.Key, Migration: new InternalDatabaseMigration(e, dbContext)))
  182. .ToArray();
  183. }
  184. (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
  185. logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
  186. var migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
  187. foreach (var item in migrations)
  188. {
  189. var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
  190. try
  191. {
  192. migrationLogger.LogInformation("Perform migration {Name}", item.Key);
  193. await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
  194. migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
  195. }
  196. catch (Exception ex)
  197. {
  198. migrationLogger.LogCritical("Error: {Error}", ex.Message);
  199. migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
  200. if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
  201. {
  202. if (_backupKey.LibraryDb is not null)
  203. {
  204. migrationLogger.LogInformation("Attempt to rollback librarydb.");
  205. try
  206. {
  207. var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
  208. File.Move(_backupKey.LibraryDb, libraryDbPath, true);
  209. }
  210. catch (Exception inner)
  211. {
  212. migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
  213. }
  214. }
  215. if (_backupKey.JellyfinDb is not null)
  216. {
  217. migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
  218. try
  219. {
  220. await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
  221. }
  222. catch (Exception inner)
  223. {
  224. migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
  225. }
  226. }
  227. if (_backupKey.FullBackup is not null)
  228. {
  229. migrationLogger.LogInformation("Attempt to rollback from backup.");
  230. try
  231. {
  232. await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
  233. }
  234. catch (Exception inner)
  235. {
  236. migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
  237. }
  238. }
  239. }
  240. throw;
  241. }
  242. }
  243. }
  244. }
  245. private static string GetJellyfinVersion()
  246. {
  247. return Assembly.GetEntryAssembly()!.GetName().Version!.ToString();
  248. }
  249. public async Task CleanupSystemAfterMigration(ILogger logger)
  250. {
  251. if (_backupKey != default)
  252. {
  253. if (_backupKey.LibraryDb is not null)
  254. {
  255. logger.LogInformation("Attempt to cleanup librarydb backup.");
  256. try
  257. {
  258. File.Delete(_backupKey.LibraryDb);
  259. }
  260. catch (Exception inner)
  261. {
  262. logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.LibraryDb);
  263. }
  264. }
  265. if (_backupKey.JellyfinDb is not null && _jellyfinDatabaseProvider is not null)
  266. {
  267. logger.LogInformation("Attempt to cleanup JellyfinDb backup.");
  268. try
  269. {
  270. await _jellyfinDatabaseProvider.DeleteBackup(_backupKey.JellyfinDb).ConfigureAwait(false);
  271. }
  272. catch (Exception inner)
  273. {
  274. logger.LogCritical(inner, "Could not cleanup {LibraryPath}.", _backupKey.JellyfinDb);
  275. }
  276. }
  277. if (_backupKey.FullBackup is not null)
  278. {
  279. logger.LogInformation("Attempt to cleanup from migration backup.");
  280. try
  281. {
  282. File.Delete(_backupKey.FullBackup.Path);
  283. }
  284. catch (Exception inner)
  285. {
  286. logger.LogCritical(inner, "Could not cleanup backup {Backup}.", _backupKey.FullBackup.Path);
  287. }
  288. }
  289. }
  290. }
  291. public async Task PrepareSystemForMigration(ILogger logger)
  292. {
  293. logger.LogInformation("Prepare system for possible migrations");
  294. JellyfinMigrationBackupAttribute backupInstruction;
  295. IReadOnlyList<HistoryRow> appliedMigrations;
  296. var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
  297. await using (dbContext.ConfigureAwait(false))
  298. {
  299. var historyRepository = dbContext.GetService<IHistoryRepository>();
  300. var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
  301. appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
  302. backupInstruction = new JellyfinMigrationBackupAttribute()
  303. {
  304. JellyfinDb = migrationsAssembly.Migrations.Any(f => appliedMigrations.All(e => e.MigrationId != f.Key))
  305. };
  306. }
  307. backupInstruction = Migrations.SelectMany(e => e)
  308. .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
  309. .Select(e => e.BackupRequirements)
  310. .Where(e => e is not null)
  311. .Aggregate(backupInstruction, MergeBackupAttributes!);
  312. if (backupInstruction.LegacyLibraryDb)
  313. {
  314. logger.LogInformation("A migration will attempt to modify the library.db, will attempt to backup the file now.");
  315. // for legacy migrations that still operates on the library.db
  316. var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
  317. if (File.Exists(libraryDbPath))
  318. {
  319. for (int i = 1; ; i++)
  320. {
  321. var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", libraryDbPath, i);
  322. if (!File.Exists(bakPath))
  323. {
  324. try
  325. {
  326. logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
  327. File.Copy(libraryDbPath, bakPath);
  328. _backupKey = (bakPath, _backupKey.JellyfinDb, _backupKey.FullBackup);
  329. logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
  330. break;
  331. }
  332. catch (Exception ex)
  333. {
  334. logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
  335. throw;
  336. }
  337. }
  338. }
  339. logger.LogInformation("{Library} has been backed up as {BackupPath}", DbFilename, _backupKey.LibraryDb);
  340. }
  341. else
  342. {
  343. logger.LogError("Cannot make a backup of {Library} at path {BackupPath} because file could not be found at {LibraryPath}", DbFilename, libraryDbPath, _applicationPaths.DataPath);
  344. }
  345. }
  346. if (backupInstruction.JellyfinDb && _jellyfinDatabaseProvider != null)
  347. {
  348. logger.LogInformation("A migration will attempt to modify the jellyfin.db, will attempt to backup the file now.");
  349. _backupKey = (_backupKey.LibraryDb, await _jellyfinDatabaseProvider.MigrationBackupFast(CancellationToken.None).ConfigureAwait(false), _backupKey.FullBackup);
  350. logger.LogInformation("Jellyfin database has been backed up as {BackupPath}", _backupKey.JellyfinDb);
  351. }
  352. if (_backupService is not null && (backupInstruction.Metadata || backupInstruction.Subtitles || backupInstruction.Trickplay))
  353. {
  354. logger.LogInformation("A migration will attempt to modify system resources. Will attempt to create backup now.");
  355. _backupKey = (_backupKey.LibraryDb, _backupKey.JellyfinDb, await _backupService.CreateBackupAsync(new BackupOptionsDto()
  356. {
  357. Metadata = backupInstruction.Metadata,
  358. Subtitles = backupInstruction.Subtitles,
  359. Trickplay = backupInstruction.Trickplay,
  360. Database = false // database backups are explicitly handled by the provider itself as the backup service requires parity with the current model
  361. }).ConfigureAwait(false));
  362. logger.LogInformation("Pre-Migration backup successfully created as {BackupKey}", _backupKey.FullBackup.Path);
  363. }
  364. }
  365. private static JellyfinMigrationBackupAttribute MergeBackupAttributes(JellyfinMigrationBackupAttribute left, JellyfinMigrationBackupAttribute right)
  366. {
  367. return new JellyfinMigrationBackupAttribute()
  368. {
  369. JellyfinDb = left!.JellyfinDb || right!.JellyfinDb,
  370. LegacyLibraryDb = left.LegacyLibraryDb || right!.LegacyLibraryDb,
  371. Metadata = left.Metadata || right!.Metadata,
  372. Subtitles = left.Subtitles || right!.Subtitles,
  373. Trickplay = left.Trickplay || right!.Trickplay
  374. };
  375. }
  376. private class InternalCodeMigration : IInternalMigration
  377. {
  378. private readonly CodeMigration _codeMigration;
  379. private readonly IServiceProvider? _serviceProvider;
  380. private JellyfinDbContext _dbContext;
  381. public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext)
  382. {
  383. _codeMigration = codeMigration;
  384. _serviceProvider = serviceProvider;
  385. _dbContext = dbContext;
  386. }
  387. public async Task PerformAsync(IStartupLogger logger)
  388. {
  389. await _codeMigration.Perform(_serviceProvider, logger, CancellationToken.None).ConfigureAwait(false);
  390. var historyRepository = _dbContext.GetService<IHistoryRepository>();
  391. var createScript = historyRepository.GetInsertScript(new HistoryRow(_codeMigration.BuildCodeMigrationId(), GetJellyfinVersion()));
  392. await _dbContext.Database.ExecuteSqlRawAsync(createScript).ConfigureAwait(false);
  393. }
  394. }
  395. private class InternalDatabaseMigration : IInternalMigration
  396. {
  397. private readonly JellyfinDbContext _jellyfinDbContext;
  398. private KeyValuePair<string, TypeInfo> _databaseMigrationInfo;
  399. public InternalDatabaseMigration(KeyValuePair<string, TypeInfo> databaseMigrationInfo, JellyfinDbContext jellyfinDbContext)
  400. {
  401. _databaseMigrationInfo = databaseMigrationInfo;
  402. _jellyfinDbContext = jellyfinDbContext;
  403. }
  404. public async Task PerformAsync(IStartupLogger logger)
  405. {
  406. var migrator = _jellyfinDbContext.GetService<IMigrator>();
  407. await migrator.MigrateAsync(_databaseMigrationInfo.Key).ConfigureAwait(false);
  408. }
  409. }
  410. }