using Microsoft.EntityFrameworkCore; using Shadow.Data; using Shadow.Entities; namespace Shadow.Tools; public class Cli { private readonly ApplicationDbContext db; private readonly GeneralUseHelpers guhf; private readonly string[] args; // TODO: Add "changeUser", "fullRescan", "destructiveRescan", "healthcheck" // TODO: Make this async public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args) { db = _db; guhf = _guhf; args = _args; } public bool Parse() { // Returns true if the program can finish execution. bool exit = true; // Check if anything has been passed if (args.Length == 0 || args.FirstOrDefault() is null) { return true; } switch (args[0].ToLower()) { case "adduser": case "--adduser": case "--add-user": if (!Seeder.EnsureMigrations(db)) return true; AddUser(); break; case "resetpassword": case "--resetpassword": case "--reset-password": if (!Seeder.EnsureMigrations(db)) return true; ResetPassword(); break; case "removeuser": case "--removeuser": case "--remove-user": if (!Seeder.EnsureMigrations(db)) return true; RemoveUser(); break; case "help": case "--help": case "h": case "-h": case "/h": case "/?": case "?": // Help should be shown without forcing migrations. ShowHelp(); break; case "setupwizard": case "--setupwizard": case "--setup-wizard": case "setup": case "--setup": case "configure": case "--configure": if (!Seeder.EnsureMigrations(db)) return true; SetupWizard(); break; case "clear": case "cleanup": case "cleardb": case "cleanupdb": case "--clear": case "--cleanup": if (!Seeder.EnsureMigrations(db)) return true; CleanUpDb(); break; default: Console.WriteLine($"Unknown option: \"{args[0]}\". See \"help\" for available arguments."); break; } return exit; } public void ShowHelp() { // Shown when "Shadow --help"/"Shadow help"/... is ran. Console.WriteLine( $"--- Shadow commandline utility ---\n" + $"Shadow version: #{ThisAssembly.Git.Commit} {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n" + $"\n" + $"Available commands:\n" + $"\n" + $"=== User management ===\n" + $"- addUser [username] - create a new user,\n" + $"- resetPassword [username] - reset a user's password,\n" + $"- removeUser [username] - remove the user COMPLETELY.\n" + $"\n" + $"=== Server maintenance ===\n" + $"- setupWizard - configure library and thumbnail location on disk,\n" + $"- cleanup - cleanup database from orphaned songs/albums/artists.\n" + $"\n" + $"[field] means the field is optional. If not provided, user will be prompted to enter it.\n" + $"Running without specifying a command launches a Kestrel web server.\n" + $"\n" + $"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow" ); } public void AddUser() { // Check if any cli arguments have been passed // Both: // Shadow addUser // and // Shadow addUser username // are supported. Console.WriteLine($"[Shadow] Add a new user"); if (args.Length == 2) Console.WriteLine($" You will be promped to enter a password."); else Console.WriteLine($" You will be promped to enter a username and password."); string? username = null; if (args.Length != 2) { while (username is null || username == String.Empty) username = ReadName(" Please enter a username: "); } else username = args[1]; // Check if user by this name exists in DB User? foundUser = db.Users .FirstOrDefault(u => u.NormalizedName == username!.ToLower()); if (foundUser != null) { Console.WriteLine("Error! User with this name already exists in the database!"); return; } string password = ReadPassword(); string passwordConfirmation = ReadPassword(" Confirm password: "); if (!password.Equals(passwordConfirmation)) { Console.WriteLine("Error! Passwords do not match. Please try again."); return; } bool isUserAdmin = YesNoPrompt($" Should \"{username}\" be an administrator? [y/N]: ", false); User newUser = new User { Name = username!, NormalizedName = username!.ToLower(), Password = password, Role = isUserAdmin ? 0 : 1 // 0 = admin }; db.Users.Add(newUser); db.SaveChanges(); } public void ResetPassword() { // Check if any cli arguments have been passed // Both: // Shadow resetPassword // and // Shadow resetPassword username // are supported. Console.WriteLine($"[Shadow] Reset password"); if (args.Length == 2) Console.WriteLine($" You will be promped to enter a new password."); else Console.WriteLine($" You will be promped to enter a username and a new password."); string? username = null; if (args.Length != 2) { while (username is null || username == String.Empty) username = ReadName(" Please enter a username: "); } else username = args[1]; // Check if user by this name exists in DB User? foundUser = db.Users .FirstOrDefault(u => u.NormalizedName == username!.ToLower()); if (foundUser == null) { Console.WriteLine("Error! User with this name does not exist in the database!"); return; } string password = ReadPassword(); string passwordConfirmation = ReadPassword(" Confirm new password: "); if (!password.Equals(passwordConfirmation)) { Console.WriteLine("Error! Passwords do not match. Please try again."); return; } foundUser.Password = password; db.SaveChanges(); } public void RemoveUser() { // Check if any cli arguments have been passed // Both: // Shadow removeUser // and // Shadow removeUser username // are supported. Console.WriteLine($"[Shadow] Remove user"); if (args.Length == 2) Console.WriteLine($" You will be prompted to enter the password."); else Console.WriteLine($" You will be prompted to enter the username and password."); string? username = null; if (args.Length != 2) { while (username is null || username == String.Empty) username = ReadName(" Please enter the username: "); } else username = args[1]; // Check if user by this name exists in DB User? foundUser = db.Users .FirstOrDefault(u => u.NormalizedName == username!.ToLower()); if (foundUser == null) { Console.WriteLine("Error! User with this name does not exist in the database!"); return; } string password = ReadPassword(); string passwordConfirmation = ReadPassword(" Confirm password: "); if (!password.Equals(passwordConfirmation)) { Console.WriteLine("Error! Passwords do not match. Please try again."); return; } if (foundUser.Password != password) { Console.WriteLine($"Error! Entered password does not match that of \"{username}\"!"); return; } bool userDeletionConfirmation = YesNoPrompt($" Do you want to remove \"{username}\" completely?\n" + $" This cannot be undone! [y/N]: ", false); if (userDeletionConfirmation) { // All playlists, interactions should be deleted as well. List albumInteractions = db.AlbumInteractions .Where(ai => ai.User == foundUser) .ToList(); List songInteractions = db.SongInteractions .Where(si => si.User == foundUser) .ToList(); foreach(AlbumInteraction ai in albumInteractions) db.AlbumInteractions.Remove(ai); foreach (SongInteraction si in songInteractions) db.SongInteractions.Remove(si); db.Users.Remove(foundUser); db.SaveChanges(); Console.WriteLine($"User \"{username}\" and all their data deleted successfully."); } else Console.WriteLine("User not removed."); } public bool SetupWizard() { // Returns true if setup successful. Otherwise false. bool success = false; Console.WriteLine("[Shadow] Setup wizard\n" + " Welcome to Shadow's setup wizard. Here, you can customize:\n" + " - Shadow's music library path, used to scan for audio files,\n" + " - Shadow's image thumbnails path, used to provide cover art extracted from media.\n" + " Finally, you will be asked if you want your changes saved.\n"); // TODO: do a healthcheck here Global? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath"); Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath"); bool configurationExists = musicLibraryPath is not null || musicThumbnailPath is not null; if (configurationExists) { Console.WriteLine(" Found existing configuration:"); if (musicLibraryPath is not null) Console.WriteLine($" - for music library: {musicLibraryPath.Value}"); if (musicThumbnailPath is not null) Console.WriteLine($" - for image thumbnails: {musicThumbnailPath.Value}"); Console.WriteLine(" They will be OVERWRITTEN if you'll commit your changes.\n"); } else { Console.WriteLine(" No existing configuration found.\n"); } string newMusicLibraryPath = DefaultPrompt("Please enter your new music library path", musicLibraryPath?.Value).TrimEnd(['\\', '/']); string newMusicThumbnailPath = DefaultPrompt("Please enter your new image thumbnail path", musicThumbnailPath?.Value ?? Path.Combine(newMusicLibraryPath, ".shadow")).TrimEnd(['\\', '/']); bool confirmed = YesNoPrompt("Are you sure you want:\n" + $"- \"{newMusicLibraryPath}\" as your new music library path,\n" + $"- \"{newMusicThumbnailPath}\" as your new image thumbnail path?\n" + $"Please answer yes or no [y/N]: ", false); if (!confirmed) { Console.WriteLine("Changes not saved."); success = false; } else { Console.Write("Saving changes... "); musicLibraryPath ??= new Global() { Key = "musicLibraryPath" }; musicThumbnailPath ??= new Global() { Key = "musicThumbnailPath" }; musicLibraryPath.Value = newMusicLibraryPath; musicThumbnailPath.Value = newMusicThumbnailPath; System.IO.Directory.CreateDirectory(newMusicLibraryPath); System.IO.Directory.CreateDirectory(newMusicThumbnailPath); // Try to find the Assets directory automatically string currentPath = AppContext.BaseDirectory; for (int i = 0; i < 5; i++) { if (Directory.GetFiles(currentPath, "Shadow.slnx") .FirstOrDefault() != null) { File.Copy( Path.Combine(currentPath, "Assets", "vinyl.png"), Path.Combine(newMusicThumbnailPath, "default.png"), true ); break; } currentPath = Path.Combine(currentPath, ".."); if (i == 5) Console.WriteLine("\n" + "[Error] Could not determine content root path. \n" + "Please place Assets/vinyl.png in your thumbnail path directory manually\n" + "and rename it to default.png."); } // UpdateRange can both Add and Update rows. db.Globals.UpdateRange(musicLibraryPath, musicThumbnailPath); db.SaveChanges(); Console.WriteLine("success!"); success = true; Console.WriteLine("\n" + "Tip: On your next run of Shadow, a web server will start.\n" + "If you ever need to enter setup wizard again, use `Shadow setupWizard`.\n" + "For a complete list of available commands use `Shadow help`.\n"); } return success; } public void CleanUpDb() { // Ask user here whether he/she wants to // clear the DB from dangling records (orphaned songs/albums/artists). int rowsAffected = 0; // Retrieve orphaned songs List orphanedSongs = db.Songs .Where(s => s.State == 1) .Include(s => s.Album) .ToList(); // Ask if it's alright to remove them // and related listening data permanently if (orphanedSongs.Count != 0) { bool songConsent = false; songConsent = YesNoPrompt($"Found {orphanedSongs.Count} orphaned songs. Remove them? [y/N]: ", false); if (songConsent) { foreach (Song s in orphanedSongs) { // Remove song interactions List interactions = db.SongInteractions.Where(si => si.Song == s).ToList(); db.SongInteractions.RemoveRange(interactions); // Remove song from playlists List playlists = db.PlaylistSongs.Where(ps => ps.Song == s).ToList(); db.PlaylistSongs.RemoveRange(playlists); // Remove song from albums Album album = s.Album; album.Songs.Remove(s); if (album.Songs.Count == 0) album.State = 1; // Set album state to orphaned. // TODO: Remove song images if not used by any other resource // ... db.SaveChanges(); } // Perform cleanup with stored procedure db.Database.ExecuteSqlRaw("CALL song_cleanup()"); rowsAffected += orphanedSongs.Count; } } else Console.WriteLine("No orphaned songs found."); // Rinse and repeat // Retrieve orphaned albums List orphanedAlbums = db.Albums.Where(a => a.State == 1).ToList(); if (orphanedAlbums.Count != 0) { bool albumConsent = false; albumConsent = YesNoPrompt($"Found {orphanedAlbums.Count} orphaned albums. Remove them? [y/N]: ", false); if (albumConsent) { db.Albums.RemoveRange(orphanedAlbums); rowsAffected += orphanedAlbums.Count; db.SaveChanges(); } } else Console.WriteLine("No orphaned albums found."); // Retrieve orphaned artists (artists with no songs AND albums) List orphanedArtists = db.Artists .Where(a => a.Songs.Count == 0 && a.Albums.Count == 0) .ToList(); Artist? unknownArtist = db.Artists .FirstOrDefault(a => a.NormalizedName == "[unknown artist]"); // Account for the [Unknown Artist], // which is a meta-artist and shall // not be slated for removal. // Can be null only if this command // is ran before seeding. if (unknownArtist != null) orphanedArtists.Remove(unknownArtist); if (orphanedArtists.Count != 0) { bool artistConsent = false; artistConsent = YesNoPrompt($"Found {orphanedArtists.Count} orphaned artists. Remove them? [y/N]: ", false); if (artistConsent) { db.Artists.RemoveRange(orphanedArtists); rowsAffected += orphanedArtists.Count; } } else Console.WriteLine("No orphaned artists found."); Console.WriteLine($"{rowsAffected} entries affected."); db.SaveChanges(); } public static string ReadPassword(string prompt = " Enter password (will not be echoed back): ") { // https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/ string password = String.Empty; ConsoleKeyInfo key; bool exit = false; Console.Write(prompt); while (!exit) { key = Console.ReadKey(true); // Exit on enter if (key.Key == ConsoleKey.Enter) exit = true; // Clear last character on backspace else if (key.Key == ConsoleKey.Backspace && password.Length > 0) password = password.Substring(0, (password.Length - 1)); // Append any other character else password += key.KeyChar; } Console.WriteLine(); return password; } public static string? ReadName(string prompt = " Enter username: ") { Console.Write(prompt); string? input = Console.ReadLine(); return input; } public static bool YesNoPrompt(string prompt, bool? default_value = null) { // Checks if user input starts with "y", and if it does, returns true. // Otherwise checks for "n". If both checks fail, and default_value is null, // user will be asked repeatedly. bool exit = false; bool response = false; string? input = null; while (!exit) { Console.Write(prompt); input = Console.ReadLine(); if (input is not null && input.Length > 0) { if (input.ToLower().StartsWith("y")) { response = true; exit = true; } else if (input.ToLower().StartsWith("n")) { response = false; exit = true; } } else if (default_value is not null) { response = (bool)default_value; exit = true; } } return response; } public static string DefaultPrompt(string prompt, string? default_value = null) { // Prompt the user repeatedly for answer. If default value // is specified, an enter will be treated as if the user // entered the default value. string response = String.Empty; string? input; while (response.Trim() == String.Empty) { Console.Write($"{prompt}" + (default_value is not null ? $" [{default_value}]" : "") + ": "); input = Console.ReadLine(); if (input is not null && input.Length > 0) response = input; else if (default_value is not null) response = default_value; } return response; } }