feat: add db cleanup command line argument
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
""");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
Tools/Cli.cs
112
Tools/Cli.cs
@@ -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/
|
||||||
|
|||||||
Reference in New Issue
Block a user