feat: add db cleanup command line argument

This commit is contained in:
2026-01-26 04:29:36 +01:00
parent 56273c2e3f
commit 3a68531fb4
3 changed files with 131 additions and 6 deletions

View File

@@ -20,8 +20,8 @@ public class Song
public int Index { get; set; } public int Index { get; set; }
public int? TrackNumber { get; set; } = null; public int? TrackNumber { get; set; } = null;
public int? DiscNumber { get; set; } = null; public int? DiscNumber { get; set; } = null;
required public int AlbumId { get; set; } public int AlbumId { get; set; }
required public int ArtistId { get; set; } public int ArtistId { get; set; }
public int? ImageId { get; set; } = null; public int? ImageId { get; set; } = null;
// Songs without an album entry shall default to "[Unnamed album]". // Songs without an album entry shall default to "[Unnamed album]".
@@ -31,6 +31,7 @@ public class Song
public List<GenreSong> GenreSongPair { get; set; } = []; public List<GenreSong> GenreSongPair { get; set; } = [];
public Image? Image { get; set; } = null; public Image? Image { get; set; } = null;
// TODO: Turn these into an enum.
public bool IsOk() => State == 0; public bool IsOk() => State == 0;
public bool IsOrphaned() => State == 1; public bool IsOrphaned() => State == 1;
public bool IsArchived() => State == 2; public bool IsArchived() => State == 2;

View File

@@ -19,6 +19,22 @@ public partial class _00000000000000_StoredProcedure : Migration
END; END;
$$; $$;
""");
migrationBuilder.Sql("""
CREATE OR REPLACE PROCEDURE force_rescan()
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE "Globals"
SET "Value" = '0'
WHERE "Key" = 'libraryState';
COMMIT;
END;
$$;
"""); """);
} }
@@ -27,6 +43,10 @@ public partial class _00000000000000_StoredProcedure : Migration
migrationBuilder.Sql(""" migrationBuilder.Sql("""
DROP PROCEDURE IF EXISTS song_cleanup(); DROP PROCEDURE IF EXISTS song_cleanup();
"""); """);
migrationBuilder.Sql("""
DROP PROCEDURE IF EXISTS force_rescan();
""");
} }
} }

View File

@@ -1,3 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Shadow.Data; using Shadow.Data;
using Shadow.Entities; using Shadow.Entities;
@@ -8,7 +9,8 @@ public class Cli
private readonly GeneralUseHelpers guhf; private readonly GeneralUseHelpers guhf;
private readonly string[] args; private readonly string[] args;
// TODO: Add "changeUser", "fullRescan", "destructiveRescan" // TODO: Add "changeUser", "fullRescan", "destructiveRescan", "healthcheck"
// TODO: Make this async
public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args) public Cli(ApplicationDbContext _db, GeneralUseHelpers _guhf, string[] _args)
{ {
@@ -65,6 +67,15 @@ public class Cli
if (!Seeder.EnsureMigrations(db)) return true; if (!Seeder.EnsureMigrations(db)) return true;
SetupWizard(); SetupWizard();
break; break;
case "clear":
case "cleanup":
case "cleardb":
case "cleanupdb":
case "--clear":
case "--cleanup":
if (!Seeder.EnsureMigrations(db)) return true;
CleanUpDb();
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;
@@ -88,9 +99,10 @@ public class Cli
$"- removeUser [username] - remove the user COMPLETELY.\n" + $"- removeUser [username] - remove the user COMPLETELY.\n" +
$"\n" + $"\n" +
$"=== Server maintenance ===\n" + $"=== Server maintenance ===\n" +
$"- setupWizard - configure library and thumbnail location on disk.\n" + $"- setupWizard - configure library and thumbnail location on disk,\n" +
$"- cleanup - cleanup database from orphaned songs/albums/artists.\n" +
$"\n" + $"\n" +
$"Username is optional. If not provided, user will be prompted to enter it.\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" + $"Running without specifying a command launches a Kestrel web server.\n" +
$"\n" + $"\n" +
$"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow" $"License: AGPLv3+, Source code: https://gitea.7o7.cx/sherl/Shadow"
@@ -281,6 +293,8 @@ public class Cli
" - Shadow's image thumbnails path, used to provide cover art extracted from media.\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"); " 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? musicLibraryPath = db.Globals.FirstOrDefault(g => g.Key == "musicLibraryPath");
Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath"); Global? musicThumbnailPath = db.Globals.FirstOrDefault(g => g.Key == "musicThumbnailPath");
@@ -299,7 +313,7 @@ public class Cli
Console.WriteLine(" No existing configuration found.\n"); Console.WriteLine(" No existing configuration found.\n");
} }
string newMusicLibraryPath = DefaultPrompt("Please enter your new music library path", musicLibraryPath?.Value).TrimEnd(['\\', '/']); 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(['\\', '/']); 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" + bool confirmed = YesNoPrompt("Are you sure you want:\n" +
@@ -322,6 +336,9 @@ public class Cli
musicLibraryPath.Value = newMusicLibraryPath; musicLibraryPath.Value = newMusicLibraryPath;
musicThumbnailPath.Value = newMusicThumbnailPath; musicThumbnailPath.Value = newMusicThumbnailPath;
System.IO.Directory.CreateDirectory(newMusicLibraryPath);
System.IO.Directory.CreateDirectory(newMusicThumbnailPath);
// UpdateRange can both Add and Update rows. // UpdateRange can both Add and Update rows.
db.Globals.UpdateRange(musicLibraryPath, musicThumbnailPath); db.Globals.UpdateRange(musicLibraryPath, musicThumbnailPath);
db.SaveChanges(); db.SaveChanges();
@@ -338,6 +355,93 @@ public class Cli
return success; 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).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
// ...
}
// Perform cleanup with stored procedure
rowsAffected += db.Database.ExecuteSqlRaw("CALL song_cleanup()");
// rowsAffected += songs.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;
}
}
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): ") 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/ // https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/