using System.Reflection; using Dapper; using ErsatzTV.Core; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Streaming.Graphics; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Services.RunOnce; public class DatabaseMigratorService : BackgroundService { private readonly ILogger _logger; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly SystemStartup _systemStartup; public DatabaseMigratorService( IServiceScopeFactory serviceScopeFactory, SystemStartup systemStartup, ILogger logger) { _serviceScopeFactory = serviceScopeFactory; _systemStartup = systemStartup; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await Task.Yield(); using IServiceScope scope = _serviceScopeFactory.CreateScope(); await using TvContext dbContext = scope.ServiceProvider.GetRequiredService(); // extract empty database to speed up initial startup if (TvContext.IsSqlite && !File.Exists(FileSystemLayout.DatabasePath)) { _logger.LogInformation("Extracting empty database to {DatabasePath}", FileSystemLayout.DatabasePath); await using Stream resource = typeof(ResourceExtractorService).GetTypeInfo().Assembly .GetManifestResourceStream("ErsatzTV.Resources.empty.sqlite3"); if (resource != null) { await using FileStream fs = File.Create(FileSystemLayout.DatabasePath); await resource.CopyToAsync(fs, stoppingToken); } } _logger.LogInformation("Applying database migrations"); if (TvContext.IsSqlite) { int count = await dbContext.Connection.ExecuteScalarAsync( "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsLock'"); if (count > 0) { count = await dbContext.Connection.ExecuteScalarAsync("SELECT count(*) FROM `__EFMigrationsLock`"); if (count > 0) { _logger.LogWarning( "Cleaning database migrations lock; this is needed when ETV is terminated during a database migration."); // sqlite migrations lock is always stale since mutex ensures single instance of etv await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM `__EFMigrationsLock`", stoppingToken); } } } List pendingMigrations = await dbContext.Database .GetPendingMigrationsAsync(stoppingToken) .Map(l => l.ToList()); if (pendingMigrations.Any(m => m.Contains("Add_MediaFilePathHash", StringComparison.OrdinalIgnoreCase))) { await dbContext.Database.MigrateAsync("Add_MediaFilePathHash", stoppingToken); } List appliedMigrations = await dbContext.Database .GetAppliedMigrationsAsync(stoppingToken) .Map(l => l.ToList()); if (appliedMigrations.Count > 0) { // this can't be part of a migration, so we have to stop here and run some sql await PopulatePathHashes(dbContext); } // then continue migrating await dbContext.Database.MigrateAsync(stoppingToken); _logger.LogInformation("Initializing database"); await DbInitializer.Initialize(dbContext, stoppingToken); var fileSystem = scope.ServiceProvider.GetRequiredService(); await GraphicsElementSeeder.SeedOnNowNext(dbContext, fileSystem, _logger, stoppingToken); await GraphicsElementSeeder.AttachOnNowNextByDefault(dbContext, stoppingToken); _systemStartup.DatabaseIsReady(); _logger.LogInformation("Done applying database migrations"); } private async Task PopulatePathHashes(TvContext dbContext) { if (await dbContext.Connection.ExecuteScalarAsync( "SELECT COUNT(*) FROM `MediaFile` WHERE `PathHash` IS NULL OR `PathHash` = ''") == 0) { return; } _logger.LogInformation("Populating database path hashes"); if (dbContext.Connection is SqliteConnection sqliteConnection) { sqliteConnection.CreateFunction("HASH_SHA256", (string text) => PathUtils.GetPathHash(text)); await dbContext.Connection.ExecuteAsync("UPDATE `MediaFile` SET `PathHash` = HASH_SHA256(`Path`);"); } else { // mysql await dbContext.Connection.ExecuteAsync("UPDATE `MediaFile` SET `PathHash` = sha2(`Path`, 256);"); } } }