Compare commits
3 Commits
56273c2e3f
...
0083539bac
| Author | SHA1 | Date | |
|---|---|---|---|
| 0083539bac | |||
| d46a2573c4 | |||
| 3a68531fb4 |
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ builder.Services.AddHttpLogging();
|
||||
|
||||
var app = builder.Build();
|
||||
bool shutdown = false;
|
||||
bool scanNeeded = false;
|
||||
if (args.FirstOrDefault() is not null)
|
||||
{
|
||||
// Handle CLI if arguments have been passed.
|
||||
@@ -89,6 +90,7 @@ using (IServiceScope scope = app.Services.CreateScope())
|
||||
GeneralUseHelpers guhf = scope.ServiceProvider.GetRequiredService<GeneralUseHelpers>();
|
||||
Seeder seeder = new(db, guhf);
|
||||
shutdown = seeder.Seed();
|
||||
if (!shutdown) scanNeeded = await seeder.ScanPrefetchAsync();
|
||||
}
|
||||
if (shutdown) return;
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Controllers\" />
|
||||
<Folder Include="Migrations\" />
|
||||
<Folder Include="Mapping\" />
|
||||
<Folder Include="DTOs\" />
|
||||
</ItemGroup>
|
||||
|
||||
112
Tools/Cli.cs
112
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<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/
|
||||
|
||||
@@ -5,10 +5,10 @@ using System.Text;
|
||||
|
||||
namespace Shadow.Tools;
|
||||
|
||||
public class GeneralUseHelpers(ApplicationDbContext db, IConfiguration appsettings)
|
||||
public class GeneralUseHelpers(ApplicationDbContext? db = null, IConfiguration? appsettings = null)
|
||||
{
|
||||
private readonly ApplicationDbContext _db = db;
|
||||
private readonly IConfiguration _appsettings = appsettings;
|
||||
private readonly ApplicationDbContext? _db = db;
|
||||
private readonly IConfiguration? _appsettings = appsettings;
|
||||
|
||||
|
||||
//async public Task<User?> GetUserFromEmail(string email)
|
||||
@@ -27,4 +27,23 @@ public class GeneralUseHelpers(ApplicationDbContext db, IConfiguration appsettin
|
||||
// }
|
||||
//}
|
||||
|
||||
/// <summary>
|
||||
/// Quick and dirty Dictionary<string, string> to JSON serializer
|
||||
/// </summary>
|
||||
/// <param name="dict">Dictionary with keypair of two strings</param>
|
||||
/// <returns>Minified JSON</returns>
|
||||
public static string DictAsJson(Dictionary<string, string> dict)
|
||||
{
|
||||
string resultJson = String.Empty;
|
||||
|
||||
foreach (string key in dict.Keys)
|
||||
{
|
||||
string cleanKey = key.Replace("\"", "\\\""); // "a"b" -> "a\"b"
|
||||
string cleanValue = dict[key].Replace("\"", "\\\"");
|
||||
resultJson += $"\"{cleanKey}\": \"{cleanValue}\", " // "key": "val",<space>
|
||||
.Replace(@"\", @"\\"); // a\b -> a\\b
|
||||
}
|
||||
|
||||
return "{" + resultJson[..^2] + "}";
|
||||
}
|
||||
}
|
||||
|
||||
91
Tools/LibraryWatcher.cs
Normal file
91
Tools/LibraryWatcher.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Shadow.Data;
|
||||
using Shadow.Entities;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace Shadow.Tools;
|
||||
public class LibraryWatcher(string watchPath, string[] excludedPaths, ApplicationDbContext dbContext)
|
||||
{
|
||||
private readonly string libraryPath = watchPath;
|
||||
private readonly string[] excludedPaths = excludedPaths;
|
||||
private readonly ApplicationDbContext db = dbContext;
|
||||
private readonly GeneralUseHelpers guhf = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a sorted list of paths to all files in a directory, recursively.
|
||||
/// </summary>
|
||||
/// <param name="directory">Path to directory</param>
|
||||
/// <returns>Sorted list of filepaths</returns>
|
||||
public async Task<List<string>> GetFilesRecursivelyAsync(string directory)
|
||||
{
|
||||
string[] allowedExtensions = [".flac", ".m4a", ".mp3", ".ogg", ".wav"];
|
||||
try
|
||||
{
|
||||
List<string> files =
|
||||
Directory.GetFiles(directory, "*", SearchOption.AllDirectories)
|
||||
.Where(file => allowedExtensions.Any(file.ToLower().EndsWith))
|
||||
.ToList();
|
||||
|
||||
files.Sort();
|
||||
return files;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
Console.WriteLine($"[Error] Directory \"{directory}\" does not exist!\n" +
|
||||
" Please create it manually, or use `Shadow setupWizard`.");
|
||||
throw new DirectoryNotFoundException();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return all multimedia content inside of library
|
||||
/// </summary>
|
||||
/// <returns>List of multimedia filepaths</returns>
|
||||
public async Task<List<string>> GetAllMultimediaAsync()
|
||||
{
|
||||
|
||||
// List files in cache
|
||||
// Note: currently, the only excluded path from scanning is the thumbnail cache.
|
||||
// This might change in the future.
|
||||
List<string> cacheFiles = await GetFilesRecursivelyAsync(excludedPaths[0]);
|
||||
|
||||
// List files in library excluding cache
|
||||
List<string> libraryContent = await GetFilesRecursivelyAsync(libraryPath);
|
||||
List<string> libraryMultimedia = libraryContent.Except(cacheFiles).ToList();
|
||||
|
||||
return libraryMultimedia;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scan the library in its entirety
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
public async Task<List<string>> PerformFullScanAsync()
|
||||
{
|
||||
Console.WriteLine("Performing full library scan...");
|
||||
|
||||
List<string> multimedia = await GetAllMultimediaAsync();
|
||||
foreach (string filepath in multimedia)
|
||||
{
|
||||
Console.WriteLine(filepath);
|
||||
Dictionary<string, string> fileInfo = await MetadataExtractor.ExtractAsync(filepath);
|
||||
|
||||
// Pretend we are doing parsing here...
|
||||
Console.WriteLine(GeneralUseHelpers.DictAsJson(fileInfo));
|
||||
MediaParser.CreateSong(db, fileInfo);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Full scan complete! Processed {multimedia.Count} files.");
|
||||
|
||||
// Update state inside of DB
|
||||
string currentLibraryState = MetadataExtractor.GetStringMD5(string.Join("\n", multimedia));
|
||||
Global lastLibraryState = db.Globals.FirstOrDefault(g => g.Key == "libraryState")
|
||||
?? new() { Key = "libraryState"};
|
||||
lastLibraryState.Value = currentLibraryState;
|
||||
db.Update(lastLibraryState);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return multimedia;
|
||||
}
|
||||
}
|
||||
126
Tools/MediaParser.cs
Normal file
126
Tools/MediaParser.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using Microsoft.VisualBasic.FileIO;
|
||||
using Shadow.Data;
|
||||
using Shadow.Entities;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Shadow.Tools;
|
||||
|
||||
public static class MediaParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Generate a random hex string (with length 32 by default)
|
||||
/// </summary>
|
||||
/// <param name="length">Optional: hexstring length</param>
|
||||
/// <returns>A hexstring of given length</returns>
|
||||
public static string HexStr(int length = 32)
|
||||
{
|
||||
return RandomNumberGenerator.GetHexString(length).ToLower();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get metadata content opportunistically
|
||||
/// </summary>
|
||||
/// <param name="metadata">Dictionary to search in</param>
|
||||
/// <param name="searchStrings">Keywords to search for</param>
|
||||
/// <returns>Retrieved value (string) on success, otherwise null</returns>
|
||||
public static string? GetAny(Dictionary<string, string> metadata, List<string> searchStrings)
|
||||
{
|
||||
foreach (string searchString in searchStrings)
|
||||
{
|
||||
if (metadata.TryGetValue(searchString, out string? value) && !string.IsNullOrEmpty(value))
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map exiftool metadata to Song entity.
|
||||
/// </summary>
|
||||
/// <param name="db">Database context</param>
|
||||
/// <param name="exif">ExifTool metadata</param>
|
||||
/// <returns>New Song entity, or null if song already exists in db</returns>
|
||||
public static Song? CreateSong(ApplicationDbContext db, Dictionary<string, string> exif)
|
||||
{
|
||||
|
||||
// First of all, check if song already exists in db
|
||||
string uri = GetAny(exif, ["_shadow:fileHash"])
|
||||
?? throw new Exception("Fatal error: could not get file hash!");
|
||||
if (db.Songs.FirstOrDefault(s => s.Uri == uri) != null) return null;
|
||||
|
||||
// If not, extract exif data
|
||||
string title = GetAny(exif, [
|
||||
"ItemList:Title", // iTunes m4a
|
||||
"ItemList:SortName", // iTunes m4a
|
||||
"Vorbis:Title", // Bandcamp ogg
|
||||
"ID3v2_3:Title", // Generic mp3/wav ID3 v2.3.0
|
||||
])
|
||||
?? Path.Combine(exif["System:Directory"], exif["System:FileName"]);
|
||||
string filepath = Path.GetFullPath(
|
||||
Path.Combine(exif["System:Directory"], exif["System:FileName"])
|
||||
); // TODO: bulletproof this
|
||||
string filetype = exif["File:FileType"].ToLower();
|
||||
|
||||
// Album/artist related
|
||||
string artistName = GetAny(exif, [
|
||||
"ItemList:Artist", // iTunes m4a
|
||||
"ItemList:AlbumArtist", // iTunes m4a
|
||||
"Vorbis:Artist", // Bandcamp m4a
|
||||
"Vorbis:Albumartist", // Bandcamp m4a
|
||||
"ID3v2_3:Artist", // Generic mp3/wav ID3 v2.3.0
|
||||
]) ?? "[Unknown Artist]"; // this is a weak line of defense against deliberately crafted
|
||||
|
||||
string albumName = GetAny(exif, [
|
||||
"ItemList:Album", // iTunes m4a
|
||||
"Vorbis:Album", // Bandcamp m4a
|
||||
"ID3v2_3:Album", // Generic mp3/wav ID3 v2.3.0
|
||||
]) ?? "[Unknown Album]"; // again, weak line of defense
|
||||
|
||||
// Try to find relevant artists and albums
|
||||
Artist artist = db.Artists
|
||||
.FirstOrDefault(a => a.NormalizedName == artistName.ToLower())
|
||||
?? new Artist
|
||||
{
|
||||
Name = artistName,
|
||||
NormalizedName = artistName.ToLower()
|
||||
};
|
||||
|
||||
Album album = db.Albums
|
||||
.FirstOrDefault(a => a.Name == albumName && a.Artist == artist)
|
||||
?? new Album
|
||||
{
|
||||
Name = albumName,
|
||||
Uri = HexStr(),
|
||||
Artist = artist
|
||||
};
|
||||
|
||||
Song song = new()
|
||||
{
|
||||
Title = title,
|
||||
Uri = uri,
|
||||
Filepath = filepath,
|
||||
Filetype = filetype,
|
||||
Album = album,
|
||||
Artist = artist
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
// Is Update() safe here?
|
||||
db.Artists.Update(artist);
|
||||
db.Albums.Update(album);
|
||||
db.Songs.Update(song);
|
||||
artist.Albums.Add(album);
|
||||
artist.Songs.Add(song);
|
||||
db.SaveChanges();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine("[Error: MediaParser] Failed to extract metadata from {filepath}:\n" +
|
||||
$"{e}");
|
||||
}
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
67
Tools/MetadataExtractor.cs
Normal file
67
Tools/MetadataExtractor.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using SharpExifTool;
|
||||
using System.Security.Cryptography;
|
||||
namespace Shadow.Tools;
|
||||
|
||||
public static class MetadataExtractor
|
||||
{
|
||||
private readonly static ExifTool exifTool = new();
|
||||
private readonly static GeneralUseHelpers guhf = new();
|
||||
public async static Task<Dictionary<string, string>> ExtractAsync(string fullPath)
|
||||
{
|
||||
// Get all relevant metadata
|
||||
Dictionary<string, string> fileMetadata = new(await
|
||||
exifTool.ExtractAllMetadataAsync(fullPath));
|
||||
|
||||
// Add in a MD5 hint
|
||||
string md5sum = await GetFileMD5Async(fullPath);
|
||||
fileMetadata?.Add("_shadow:fileHash", md5sum);
|
||||
|
||||
return fileMetadata ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute MD5 checksum of a file
|
||||
/// </summary>
|
||||
/// <param name="fullPath">Input file absolute path</param>
|
||||
/// <returns>MD5 hexstring</returns>
|
||||
public static async Task<string> GetFileMD5Async(string fullPath)
|
||||
{
|
||||
string fallbackValue = String.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(fullPath))
|
||||
using (MD5 md5 = MD5.Create())
|
||||
{
|
||||
using (FileStream stream = File.OpenRead(fullPath))
|
||||
{
|
||||
byte[] hashBytes = await md5.ComputeHashAsync(stream);
|
||||
string hashString = Convert.ToHexStringLower(hashBytes);
|
||||
return hashString;
|
||||
}
|
||||
}
|
||||
else return fallbackValue;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compute MD5 checksum of a string
|
||||
/// </summary>
|
||||
/// <param name="input">Input string for the MD5 hash</param>
|
||||
/// <returns>MD5 hexstring</returns>
|
||||
public static string GetStringMD5(string input)
|
||||
{
|
||||
// https://stackoverflow.com/a/24031467
|
||||
byte[] inputBytes = System.Text.Encoding.ASCII.GetBytes(input);
|
||||
byte[] hashBytes = MD5.HashData(inputBytes);
|
||||
string hashString = Convert.ToHexStringLower(hashBytes);
|
||||
|
||||
return hashString;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -84,25 +84,34 @@ public class Seeder
|
||||
if (adminCount == 0 && userCount > 0)
|
||||
Console.WriteLine("[Warn]: No admin accounts exist. Consider creating one with `Shadow addUser`.\n");
|
||||
|
||||
// Ensure [Unknown Album], [Unknown Artist] exist
|
||||
Album unknownAlbum = db.Albums.FirstOrDefault(a => a.Name == "[Unknown Album]") ?? new Album()
|
||||
{
|
||||
Name = "[Unknown Album]",
|
||||
Uri = "00000000000000000000000000000000"
|
||||
};
|
||||
|
||||
// Ensure [Unknown Artist], [Unknown Album] exist
|
||||
Artist unknownArtist = db.Artists.FirstOrDefault(a => a.Name == "[Unknown Artist]") ?? new Artist()
|
||||
{
|
||||
Name = "[Unknown Artist]",
|
||||
NormalizedName = "[unknown artist]"
|
||||
};
|
||||
|
||||
// Update works both as an Add and Update.
|
||||
db.Update(unknownArtist);
|
||||
db.SaveChanges();
|
||||
|
||||
Album unknownAlbum = db.Albums.FirstOrDefault(a => a.Name == "[Unknown Album]") ?? new Album()
|
||||
{
|
||||
Name = "[Unknown Album]",
|
||||
Uri = "00000000000000000000000000000000",
|
||||
Artist = unknownArtist
|
||||
};
|
||||
db.Update(unknownAlbum);
|
||||
|
||||
// Add [Unknown Album] to [Unknown Artist]
|
||||
unknownArtist.Albums.Add(unknownAlbum);
|
||||
db.Update(unknownArtist);
|
||||
|
||||
runs ??= new Global() { Key = "runs", Value = "0" };
|
||||
if (int.TryParse(runs.Value, out int runsInt))
|
||||
runs.Value = $"{runsInt + 1}";
|
||||
|
||||
// UpdateRange works both as an Add and Update.
|
||||
db.UpdateRange(unknownAlbum, unknownArtist, runs);
|
||||
db.Update(runs);
|
||||
db.SaveChanges();
|
||||
|
||||
return shutdown;
|
||||
@@ -118,10 +127,9 @@ public class Seeder
|
||||
List<string> appliedMigrations = dbContext.Database.GetAppliedMigrations().ToList();
|
||||
|
||||
bool hasMissingMigrations = availableMigrations.Count > appliedMigrations.Count;
|
||||
bool doMigrationsMatch = availableMigrations.SequenceEqual(appliedMigrations);
|
||||
if (hasMissingMigrations)
|
||||
{
|
||||
bool userMigrationConsent = false;
|
||||
bool userMigrationConsent = true; // apply migrations by default
|
||||
Console.WriteLine("\n" +
|
||||
"========================================\n" +
|
||||
"[Warn] Database migrations missing!\n" +
|
||||
@@ -129,19 +137,30 @@ public class Seeder
|
||||
if (appliedMigrations.Count == 0)
|
||||
{
|
||||
Console.WriteLine( "Empty database detected. Applying migrations is recommended, and required if this is the first run of Shadow.");
|
||||
userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [Y/n]: ", true);
|
||||
// userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [Y/n]: ", true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Detected existing {appliedMigrations.Count} migrations. Backing up the database before applying migrations is recommended.");
|
||||
userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [y/N]: ", false);
|
||||
Console.WriteLine($"Detected existing {appliedMigrations.Count} migrations. Backing up the database before applying migrations is recommended.\n" +
|
||||
"Waiting 15 seconds.");
|
||||
Thread.Sleep(15_000);
|
||||
// userMigrationConsent = Cli.YesNoPrompt("Do you want to apply migrations automatically? [y/N]: ", false);
|
||||
}
|
||||
|
||||
// Do we have user permission to perform migration?
|
||||
if (userMigrationConsent)
|
||||
{
|
||||
dbContext.Database.Migrate();
|
||||
dbContext.SaveChanges();
|
||||
try
|
||||
{
|
||||
dbContext.Database.Migrate();
|
||||
dbContext.SaveChanges();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine("Error! Unable to process migration automatically.\n" +
|
||||
$"Error message was: {e.Message}\n\n" +
|
||||
$"Consider ");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -177,4 +196,41 @@ public class Seeder
|
||||
return migrationSuccess;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if the library needs a full rescan
|
||||
/// </summary>
|
||||
/// <returns>True if full rescan is needed</returns>
|
||||
/// <exception cref="MissingFieldException">Thrown when either last library state, path to music library or cache is unknown</exception>
|
||||
public async Task<bool> ScanPrefetchAsync()
|
||||
{
|
||||
bool scanNecessary = false;
|
||||
Global? lastLibraryState = await db.Globals.FirstOrDefaultAsync(g => g.Key == "libraryState");
|
||||
Global? libraryPath = await db.Globals.FirstOrDefaultAsync(g => g.Key == "musicLibraryPath");
|
||||
Global? cachePath = await db.Globals.FirstOrDefaultAsync(g => g.Key == "musicThumbnailPath");
|
||||
|
||||
if (libraryPath is null || cachePath is null || lastLibraryState is null)
|
||||
{
|
||||
throw new MissingFieldException("[Error] Missing libraryState, musicLibraryPath, musicThumbnailPath. Please rerun the setup wizard with `Shadow setupWizard`.");
|
||||
}
|
||||
LibraryWatcher lw = new(libraryPath.Value!, [cachePath.Value!], db);
|
||||
|
||||
// Get library contents
|
||||
List<string> currentLibraryStateList = await lw.GetAllMultimediaAsync();
|
||||
// Compute their hash
|
||||
string currentLibraryStateString = string.Join("\n", currentLibraryStateList);
|
||||
string currentLibraryState = MetadataExtractor.GetStringMD5(currentLibraryStateString);
|
||||
|
||||
// Compare against last known library state
|
||||
if (currentLibraryState != lastLibraryState.Value)
|
||||
scanNecessary = true;
|
||||
|
||||
// The contents changed? Initiate a full rescan, then call LibraryWatcher.
|
||||
if (scanNecessary)
|
||||
await lw.PerformFullScanAsync();
|
||||
// State seems identical? Launch just the LibraryWatcher.
|
||||
// TODO: lw.Watch()...
|
||||
|
||||
return scanNecessary;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user