feat: add setup wizard and proper seeder
All checks were successful
Update changelog / changelog (push) Successful in 26s

This commit is contained in:
2025-12-16 04:02:42 +01:00
parent eebc5f1d6d
commit defa00d0d3
7 changed files with 212 additions and 42 deletions

View File

@@ -3,3 +3,4 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
indent_style = tab

View File

@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Shadow.Controllers;
using Shadow.Data;
using Shadow.Entities;
namespace Shadow.Controllers;
public class Seeder
{
private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf;
public Seeder(ApplicationDbContext _db, GeneralUseHelpers _guhf)
{
db = _db;
guhf = _guhf;
}
public void Seed()
{
// TODO: Ensure [unknown album], [unknown artist] exist
// TODO: Force add a new user through CLI if no users exist
Console.WriteLine($"You're running Shadow, commit {ThisAssembly.Git.Commit} of branch {ThisAssembly.Git.Branch} ({ThisAssembly.Git.CommitDate})\n");
}
}

View File

@@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Shadow.Controllers;
using Shadow.Data; using Shadow.Data;
using Shadow.Tools;
using System.Reflection; using System.Reflection;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -66,12 +66,13 @@ builder.Services.AddSwaggerGen(options =>
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename)); options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
}); });
builder.Services.AddHttpLogging();
var app = builder.Build(); var app = builder.Build();
bool shutdown = false;
if (args.FirstOrDefault() is not null) if (args.FirstOrDefault() is not null)
{ {
// Handle CLI if arguments have been passed. // Handle CLI if arguments have been passed.
bool shutdown = false;
using (IServiceScope scope = app.Services.CreateScope()) using (IServiceScope scope = app.Services.CreateScope())
{ {
ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@@ -87,9 +88,9 @@ using (IServiceScope scope = app.Services.CreateScope())
ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); ApplicationDbContext db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>(); GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>();
Seeder seeder = new(db, guhf); Seeder seeder = new(db, guhf);
seeder.Seed(); shutdown = seeder.Seed();
} }
if (shutdown) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -97,6 +98,7 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi(); app.MapOpenApi();
app.UseSwagger(); app.UseSwagger();
app.UseSwaggerUI(); app.UseSwaggerUI();
app.UseHttpLogging();
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();

View File

@@ -23,15 +23,17 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.1">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Npgsql" Version="10.0.0" /> <PackageReference Include="Npgsql" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.15.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.15.1" />
<PackageReference Include="SharpExifTool" Version="13.32.0.1" /> <PackageReference Include="SharpExifTool" Version="13.32.0.1" />
<PackageReference Include="SkiaSharp" Version="3.119.1" /> <PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
@@ -42,6 +44,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="Controllers\" />
<Folder Include="Migrations\" /> <Folder Include="Migrations\" />
<Folder Include="Mapping\" /> <Folder Include="Mapping\" />
<Folder Include="DTOs\" /> <Folder Include="DTOs\" />

View File

@@ -1,14 +1,14 @@
using Shadow.Data; using Shadow.Data;
using Shadow.Entities; using Shadow.Entities;
namespace Shadow.Controllers; namespace Shadow.Tools;
public class Cli public class Cli
{ {
private readonly ApplicationDbContext db; private readonly ApplicationDbContext db;
private readonly GeneralUseHelpers guhf; private readonly GeneralUseHelpers guhf;
private readonly string[] args; private readonly string[] args;
// TODO: Add "changeUser" // TODO: Add "changeUser", "fullRescan", "destructiveRescan"
public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args) public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args)
{ {
@@ -28,12 +28,18 @@ public class Cli
switch (args[0].ToLower()) switch (args[0].ToLower())
{ {
case "adduser": case "adduser":
case "--adduser":
case "--add-user":
AddUser(); AddUser();
break; break;
case "resetpassword": case "resetpassword":
case "--resetpassword":
case "--reset-password":
ResetPassword(); ResetPassword();
break; break;
case "removeuser": case "removeuser":
case "--removeuser":
case "--remove-user":
RemoveUser(); RemoveUser();
break; break;
case "help": case "help":
@@ -45,6 +51,15 @@ public class Cli
case "?": case "?":
ShowHelp(); ShowHelp();
break; break;
case "setupwizard":
case "--setupwizard":
case "--setup-wizard":
case "setup":
case "--setup":
case "configure":
case "--configure":
SetupWizard();
break;
default: default:
Console.WriteLine($"Unknown option: \"{args[0]}\". See \"help\" for available arguments."); Console.WriteLine($"Unknown option: \"{args[0]}\". See \"help\" for available arguments.");
break; break;
@@ -154,7 +169,7 @@ public class Cli
return; return;
} }
string password = ReadPassword(); string password = ReadPassword();
string passwordConfirmation = ReadPassword(" Confirm new password: "); string passwordConfirmation = ReadPassword(" Confirm new password: ");
if (!password.Equals(passwordConfirmation)) if (!password.Equals(passwordConfirmation))
{ {
@@ -242,6 +257,68 @@ public class Cli
} }
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");
Global? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath");
Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath");
if (musicLibraryPath is not null || musicThumbnailPath is not null)
{
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;
// UpdateRange can both Add and Update rows.
db.Globals.UpdateRange(musicLibraryPath, musicThumbnailPath);
db.SaveChanges();
Console.WriteLine("success!");
success = true;
}
return success;
}
public string ReadPassword(string prompt = " Enter password (will not be echoed back): ") public 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/ // https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/
@@ -281,12 +358,14 @@ public class Cli
// user will be asked repeatedly. // user will be asked repeatedly.
bool exit = false; bool exit = false;
bool response = false; bool response = false;
string? input = null;
Console.Write(prompt);
string? input = Console.ReadLine();
while (!exit) while (!exit)
{ {
Console.Write(prompt);
input = Console.ReadLine();
if (input is not null && input.Length > 0) if (input is not null && input.Length > 0)
{ {
if (input.ToLower().StartsWith("y")) if (input.ToLower().StartsWith("y"))
@@ -308,4 +387,25 @@ public class Cli
} }
return response; return response;
} }
public 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;
}
} }

View File

@@ -3,7 +3,7 @@ using Shadow.Data;
using Shadow.Entities; using Shadow.Entities;
using System.Text; using System.Text;
namespace Shadow.Controllers; namespace Shadow.Tools;
public class GeneralUseHelpers(ApplicationDbContext db, IConfiguration appsettings) public class GeneralUseHelpers(ApplicationDbContext db, IConfiguration appsettings)
{ {

93
Tools/Seeder.cs Normal file
View File

@@ -0,0 +1,93 @@
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 if this is a clean, first run. If so, run the setup wizard.
Global? lastVersion = db.Globals.FirstOrDefault(c => c.Key == "lastVersion");
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 };
Global lastVersionDate = new Global() { Key = "lastVersionDate", Value = ThisAssembly.Git.CommitDate };
Global runs = new Global() { Key = "runs", Value = "0" };
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");
if (lastVersionDate is not null && 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!");
}
Console.WriteLine("Upgrade detected. Make sure you're using the most recent migrations.\n" +
"If not, apply them with `dotnet ef database update`.");
}
// 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`.");
int adminCount = db.Users.Count(u => u.IsAdmin());
if (adminCount == 0 && userCount > 0)
Console.WriteLine("[Warn]: No admin accounts exist. Consider creating one with `Shadow addUser`.");
// 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]"
};
// UpdateRange works both as an Add and Update.
db.UpdateRange(unknownAlbum, unknownArtist);
db.SaveChanges();
return shutdown;
}
}