568 lines
16 KiB
C#
568 lines
16 KiB
C#
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<AlbumInteraction> albumInteractions = db.AlbumInteractions
|
|
.Where(ai => ai.User == foundUser)
|
|
.ToList();
|
|
|
|
List<SongInteraction> 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<Song> 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<SongInteraction> interactions = db.SongInteractions.Where(si => si.Song == s).ToList();
|
|
db.SongInteractions.RemoveRange(interactions);
|
|
|
|
// Remove song from playlists
|
|
List<PlaylistSong> 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<Album> 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<Artist> 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;
|
|
}
|
|
}
|