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? 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<GenreSong> 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;

View File

@@ -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();
""");
}
}

View File

@@ -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");
@@ -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<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): ")
{
// https://www.silicloud.com/blog/how-to-hide-content-in-the-console-using-c/