From 3a68531fb4587cf8b6812bc43793dab949eb54ea Mon Sep 17 00:00:00 2001 From: sherl Date: Mon, 26 Jan 2026 04:29:36 +0100 Subject: [PATCH] feat: add db cleanup command line argument --- Entities/Song.cs | 5 +- Migrations/00000000000000_StoredProcedure.cs | 20 ++++ Tools/Cli.cs | 112 ++++++++++++++++++- 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/Entities/Song.cs b/Entities/Song.cs index 25e4834..32c43c1 100644 --- a/Entities/Song.cs +++ b/Entities/Song.cs @@ -20,8 +20,8 @@ public class Song public int Index { get; set; } public int? TrackNumber { get; set; } = null; public int? DiscNumber { get; set; } = null; - required public int AlbumId { get; set; } - required public int ArtistId { get; set; } + public int AlbumId { get; set; } + public int ArtistId { get; set; } public int? ImageId { get; set; } = null; // Songs without an album entry shall default to "[Unnamed album]". @@ -31,6 +31,7 @@ public class Song public List GenreSongPair { get; set; } = []; public Image? Image { get; set; } = null; + // TODO: Turn these into an enum. public bool IsOk() => State == 0; public bool IsOrphaned() => State == 1; public bool IsArchived() => State == 2; diff --git a/Migrations/00000000000000_StoredProcedure.cs b/Migrations/00000000000000_StoredProcedure.cs index a1dfb8a..f45eaf3 100644 --- a/Migrations/00000000000000_StoredProcedure.cs +++ b/Migrations/00000000000000_StoredProcedure.cs @@ -19,6 +19,22 @@ public partial class _00000000000000_StoredProcedure : Migration 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(""" DROP PROCEDURE IF EXISTS song_cleanup(); """); + + migrationBuilder.Sql(""" + DROP PROCEDURE IF EXISTS force_rescan(); + """); } } diff --git a/Tools/Cli.cs b/Tools/Cli.cs index 6a09375..d323e2b 100644 --- a/Tools/Cli.cs +++ b/Tools/Cli.cs @@ -1,3 +1,4 @@ +using Microsoft.EntityFrameworkCore; using Shadow.Data; using Shadow.Entities; @@ -8,7 +9,8 @@ public class Cli private readonly GeneralUseHelpers guhf; 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) { @@ -65,6 +67,15 @@ public class Cli 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; @@ -88,9 +99,10 @@ public class Cli $"- removeUser [username] - remove the user COMPLETELY.\n" + $"\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" + - $"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" + $"\n" + $"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" + " 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"); @@ -299,7 +313,7 @@ public class Cli 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(['\\', '/']); bool confirmed = YesNoPrompt("Are you sure you want:\n" + @@ -322,6 +336,9 @@ public class Cli musicLibraryPath.Value = newMusicLibraryPath; musicThumbnailPath.Value = newMusicThumbnailPath; + System.IO.Directory.CreateDirectory(newMusicLibraryPath); + System.IO.Directory.CreateDirectory(newMusicThumbnailPath); + // UpdateRange can both Add and Update rows. db.Globals.UpdateRange(musicLibraryPath, musicThumbnailPath); db.SaveChanges(); @@ -338,6 +355,93 @@ public class Cli 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).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 + // ... + } + + // 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 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 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/