using Microsoft.EntityFrameworkCore; using Shadow.Data; using Shadow.Entities; namespace Shadow.Tools; public class Seeder { private readonly ApplicationDbContext db; private readonly GeneralUseHelpers guhf; public Seeder(ApplicationDbContext _db, GeneralUseHelpers _guhf) { db = _db; guhf = _guhf; } public bool Seed() { // Will return true if application should be shut down. // Otherwise returns false. bool shutdown = false; Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n"); // Check migration status. If we can't proceed, exit now. bool migrationSucceeded = EnsureMigrations(db); if (!migrationSucceeded) return true; // Check if this is a clean, first run. If so, run the setup wizard. Global? lastVersion = db.Globals.FirstOrDefault(c => c.Key == "lastVersion"); Global? runs = db.Globals.FirstOrDefault(g => g.Key == "runs"); if (lastVersion is null) { shutdown = true; bool setupSuccessfull = false; Console.WriteLine("This seems to be a fresh install. Running setup wizard for you.\n"); Cli cli = new(db, guhf, []); setupSuccessfull = cli.SetupWizard(); if (setupSuccessfull) { // Cli.SetupWizard() takes care of musicLibraryPath and musicThumbnailPath lastVersion = new Global() { Key = "lastVersion", Value = ThisAssembly.Git.Commit }; runs = new Global() { Key = "runs", Value = "0" }; Global lastVersionDate = new Global() { Key = "lastVersionDate", Value = ThisAssembly.Git.CommitDate }; Global libraryState = new Global() { Key = "libraryState", Value = "00000000000000000000000000000000" }; db.Globals.AddRange(lastVersion, lastVersionDate, runs, libraryState); } else Console.WriteLine("Setup wizard failed. Please try running it again using \"Shadow setupWizard\"."); db.SaveChanges(); return shutdown; } // Check if running a newer (different) version if (lastVersion.Value != ThisAssembly.Git.Commit) { Global lastVersionDate = db.Globals.FirstOrDefault(c => c.Key == "lastVersionDate") ?? new Global() { Key = "lastVersionDate", Value = ThisAssembly.Git.CommitDate }; if (String.Compare(lastVersionDate.Value, ThisAssembly.Git.CommitDate) > 0) { // User is running an earlier version of the application. Console.WriteLine("Downgrade detected! Waiting 30 seconds.\n" + "Please consider stopping Shadow in order to avoid accidental data loss!\n"); Thread.Sleep(30_000); } else Console.WriteLine("Upgrade detected. Make sure you're using the most recent migrations.\n" + "If not, apply them automatically or with `dotnet ef database update`.\n"); lastVersion.Value = ThisAssembly.Git.Commit; lastVersionDate.Value = ThisAssembly.Git.CommitDate; db.UpdateRange(lastVersion, lastVersionDate); } // Check if any user/admin exist. Display appropriate warnings if not. int userCount = db.Users.Count(); if (userCount == 0) Console.WriteLine("[Warn]: No user accounts found. Running a server no one can access! Consider creating an account with `Shadow addUser`.\n"); int adminCount = db.Users.Count(u => u.Role == 0); // equivalent to u.IsAdmin() if (adminCount == 0 && userCount > 0) Console.WriteLine("[Warn]: No admin accounts exist. Consider creating one with `Shadow addUser`.\n"); // Ensure [Unknown Album], [Unknown Artist] exist Album unknownAlbum = db.Albums.FirstOrDefault(a => a.Name == "[Unknown Album]") ?? new Album() { Name = "[Unknown Album]", Uri = "00000000000000000000000000000000" }; Artist unknownArtist = db.Artists.FirstOrDefault(a => a.Name == "[Unknown Artist]") ?? new Artist() { Name = "[Unknown Artist]", NormalizedName = "[unknown artist]" }; runs ??= new Global() { Key = "runs", Value = "0" }; if (int.TryParse(runs.Value, out int runsInt)) runs.Value = $"{runsInt + 1}"; // UpdateRange works both as an Add and Update. db.UpdateRange(unknownAlbum, unknownArtist, runs); db.SaveChanges(); return shutdown; } public static bool EnsureMigrations(ApplicationDbContext dbContext) { // Returns true if migrations have been successfully applied. // Otherwise returns false. bool migrationSuccess = false; List availableMigrations = dbContext.Database.GetMigrations().ToList(); List appliedMigrations = dbContext.Database.GetAppliedMigrations().ToList(); bool hasMissingMigrations = availableMigrations.Count > appliedMigrations.Count; bool doMigrationsMatch = availableMigrations.SequenceEqual(appliedMigrations); if (hasMissingMigrations) { bool userMigrationConsent = false; Console.WriteLine("\n" + "========================================\n" + "[Warn] Database migrations missing!\n" + "========================================"); if (appliedMigrations.Count == 0) { Console.WriteLine( "Empty database detected. Applying migrations is recommended, and required if this is the first run of Shadow."); userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [Y/n]: ", true); } else { Console.WriteLine($"Detected existing {appliedMigrations.Count} migrations. Backing up the database before applying migrations is recommended."); userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [y/N]: ", false); } // Do we have user permission to perform migration? if (userMigrationConsent) { dbContext.Database.Migrate(); dbContext.SaveChanges(); } else { Console.WriteLine("Exiting..."); return migrationSuccess; } // Check if migrations match afterwards List newlyAppliedMigrations = dbContext.Database.GetAppliedMigrations().ToList(); bool doNewlyAppliedMigrationsMatch = availableMigrations.SequenceEqual(newlyAppliedMigrations); if (doNewlyAppliedMigrationsMatch) { Console.WriteLine("\n" + "==============================\n" + "Migration success!\n" + "==============================\n"); migrationSuccess = true; } else { // An error ocurred when applying migrations automatically Console.WriteLine("\n" + "==================================================\n" + "[Error] Failed applying migrations automatically!\n" + "==================================================\n"); Console.WriteLine("The list of newly applied migrations does not match the list of planned migrations.\n" + "Shadow cannot operate with missing migrations. Please manually revise the database structure and create backups before altering it.\n"); migrationSuccess = false; } } else migrationSuccess = true; return migrationSuccess; } /// /// Check if the library needs a full rescan /// /// True if full rescan is needed /// Thrown when either last library state, path to music library or cache is unknown public async Task ScanPrefetchAsync() { bool scanNecessary = false; Global? lastLibraryState = await db.Globals.FirstOrDefaultAsync(g => g.Key == "libraryState"); Global? libraryPath = await db.Globals.FirstOrDefaultAsync(g => g.Key == "musicLibraryPath"); Global? cachePath = await db.Globals.FirstOrDefaultAsync(g => g.Key == "musicThumbnailPath"); if (libraryPath is null || cachePath is null || lastLibraryState is null) { throw new MissingFieldException("[Error] Missing libraryState, musicLibraryPath, musicThumbnailPath. Please rerun the setup wizard with `Shadow setupWizard`."); } LibraryWatcher lw = new(libraryPath.Value!, [cachePath.Value!], db); // Get library contents List currentLibraryStateList = await lw.GetAllMultimediaAsync(); // Compute their hash string currentLibraryStateString = string.Join("\n", currentLibraryStateList); string currentLibraryState = MetadataExtractor.GetStringMD5(currentLibraryStateString); // Compare against last known library state if (currentLibraryState != lastLibraryState.Value) scanNecessary = true; // The contents changed? Initiate a full rescan, then call LibraryWatcher. if (scanNecessary) await lw.PerformFullScanAsync(); // State seems identical? Launch just the LibraryWatcher. // TODO: lw.Watch()... return scanNecessary; } }