From 43891ad3b60971661c2effbac9721d28ad9989e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Gel=C3=B3czi?= Date: Mon, 3 Jan 2022 13:37:14 +0100 Subject: [PATCH] Improve cover file handling SHA256 hash is calculated from Artist.Name and Album.Name to get deterministic cover file name --- README.md | 4 +- SynAudio/DAL/AlbumModel.cs | 53 +++++------- SynAudio/DAL/ArtistModel.cs | 8 +- SynAudio/DAL/ResourceState.cs | 9 -- SynAudio/Library/AudioLibrary.covers.cs | 108 ++++++++++++++++-------- SynAudio/SynAudio.csproj | 5 +- SynAudio/Utils/Md5Hash.cs | 8 +- SynAudio/Utils/Sha256Hash.cs | 30 +++++++ 8 files changed, 136 insertions(+), 89 deletions(-) delete mode 100644 SynAudio/DAL/ResourceState.cs create mode 100644 SynAudio/Utils/Sha256Hash.cs diff --git a/README.md b/README.md index a4f363d..472158a 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ AudioStation like **desktop application for Windows to stream music from your Sy ## Screenshots -![plot](./assets/light.png) - ![plot](./assets/dark.png) +![plot](./assets/light.png) + ## What's working * Streaming from Synology NAS using the Audio Station library (shared + personal libraries) diff --git a/SynAudio/DAL/AlbumModel.cs b/SynAudio/DAL/AlbumModel.cs index 435ad5d..57e7cb8 100644 --- a/SynAudio/DAL/AlbumModel.cs +++ b/SynAudio/DAL/AlbumModel.cs @@ -1,6 +1,7 @@ using System; using System.IO; using SQLite; +using SynAudio.Utils; using SynAudio.ViewModels; using SynologyDotNet.AudioStation.Model; @@ -9,7 +10,7 @@ namespace SynAudio.DAL [Table("Album")] public class AlbumModel : ViewModelBase { - public static readonly string CoversDirectory = Path.Combine(App.UserDataFolder, "albumcovers"); + public static readonly string CoversDirectory = Path.Combine(App.UserDataFolder, "covers"); [Column(nameof(Id))] [PrimaryKey] @@ -36,12 +37,9 @@ public class AlbumModel : ViewModelBase [NotNull] public int Year { get; set; } - /// - /// Cover file state - /// - [Column(nameof(CoverFile))] - [NotNull] - public ResourceState CoverFile { get; set; } + [Column(nameof(CoverFileName))] + [MaxLength(64)] + public string CoverFileName { get; set; } public override string ToString() => Name ?? base.ToString(); @@ -52,42 +50,29 @@ public void LoadFromDto(Album dto) Year = dto.Year; Artist = GetFirstCleanString(dto.DisplayArtist, dto.AlbumArtist, dto.Artist); } + public string DisplayName => string.IsNullOrEmpty(Name) ? "Unknown" : TruncateString(Name, 26, ".."); - public string Cover => CoverFile == ResourceState.Exists ? GetCoverFileFullPath() : null; - - public string GetCoverFileFullPath() => GetCoverFileFullPath(Id); + public string Cover => GetCoverFileFullPath(CoverFileName); - public bool TryToFindCoverFile() + public static string GetCoverFileFullPath(string fileName) { - var file = GetCoverFileFullPath(); - CoverFile = File.Exists(file) ? ResourceState.Exists : ResourceState.DoesNotExist; - return CoverFile == ResourceState.Exists; + if (string.IsNullOrEmpty(fileName)) + return null; + return Path.Combine(CoversDirectory, fileName); } - public string DisplayName => string.IsNullOrEmpty(Name) ? "Unknown" : TruncateString(Name, 26, ".."); - - public static string ConstructSimpleKey(AlbumModel album) => ConstructSimpleKey(album.Artist, album.Name, album.Year); - public static string ConstructSimpleKey(string artistName, string albumName, int? albumYear) => string.Join("\u000B", artistName, albumName, albumYear.HasValue ? albumYear.Value : 0); - public static string GetCoverFileFullPath(int albumId) => Path.Combine(CoversDirectory, $"{albumId}.dat"); - - public void SaveCover(byte[] bytes) + public static string GetCoverFileNameFromArtistAndAlbum(string artist, string album) { - File.WriteAllBytes(GetCoverFileFullPath(), bytes); - CoverFile = ResourceState.Exists; + string hash = Sha256Hash.FromObject(new { artist, album }); + if (hash.Length > 64) + throw new Exception($"Hash value must not exceed 64 characters! It was {hash.Length}."); + return hash; } - public void DeleteCover() + public static void SaveCoverFile(string fileName, byte[] data) { - var path = GetCoverFileFullPath(); - if (File.Exists(path)) - { - try - { - File.Delete(path); - } - catch { } - CoverFile = ResourceState.DoesNotExist; - } + string path = GetCoverFileFullPath(fileName); + File.WriteAllBytes(path, data); } } } diff --git a/SynAudio/DAL/ArtistModel.cs b/SynAudio/DAL/ArtistModel.cs index 5926940..7d781c7 100644 --- a/SynAudio/DAL/ArtistModel.cs +++ b/SynAudio/DAL/ArtistModel.cs @@ -12,11 +12,13 @@ public class ArtistModel : ViewModelBase [MaxLength(255)] public string Name { get; set; } - [Column(nameof(CoverAlbumId))] - public int? CoverAlbumId { get; set; } + [Column(nameof(CoverFileName))] + [MaxLength(64)] + public string CoverFileName { get; set; } + + public string Cover => AlbumModel.GetCoverFileFullPath(CoverFileName); public string DisplayName => string.IsNullOrEmpty(Name) ? "(Unknown)" : TruncateString(Name, 26, ".."); - public string Cover => CoverAlbumId.HasValue ? AlbumModel.GetCoverFileFullPath(CoverAlbumId.Value) : null; public override string ToString() => Name ?? base.ToString(); diff --git a/SynAudio/DAL/ResourceState.cs b/SynAudio/DAL/ResourceState.cs deleted file mode 100644 index d66c85e..0000000 --- a/SynAudio/DAL/ResourceState.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace SynAudio.DAL -{ - public enum ResourceState - { - NotSet = 0, - Exists = 1, - DoesNotExist = 2 - } -} diff --git a/SynAudio/Library/AudioLibrary.covers.cs b/SynAudio/Library/AudioLibrary.covers.cs index 04a8b86..fe2457c 100644 --- a/SynAudio/Library/AudioLibrary.covers.cs +++ b/SynAudio/Library/AudioLibrary.covers.cs @@ -19,16 +19,15 @@ private async Task SyncCoversAsync(CancellationToken token, bool force) albums = new List(); foreach (var album in Db.Table()) { - if (album.CoverFile != ResourceState.Exists) + if (string.IsNullOrEmpty(album.CoverFileName)) { - // Albums without cover (with retry download) + // Albums without cover albums.Add(album); } else { // If the cover file does not exist - var path = album.GetCoverFileFullPath(); - if (!File.Exists(path)) + if (!File.Exists(album.Cover)) albums.Add(album); } } @@ -36,7 +35,7 @@ private async Task SyncCoversAsync(CancellationToken token, bool force) else { // Get albums without cover (haven't tried to download yet) - albums = Db.Table().Where(x => x.CoverFile == ResourceState.NotSet).ToList(); + albums = Db.Table().Where(x => x.CoverFileName == null).ToList(); } // Sort albums @@ -47,57 +46,98 @@ private async Task SyncCoversAsync(CancellationToken token, bool force) await DownloadAndSaveAlbumCover(album); AlbumCoverUpdated.FireAsync(this, album); }, 4); - foreach (var album in albums) { - if (album.TryToFindCoverFile()) - { - // Cover file re-link - Db.Update(album); - AlbumCoverUpdated.FireAsync(this, album); - } - else - { - // Has to be downloaded - processor.Enqueue(album); - } + processor.Enqueue(album); } await processor.WaitAsync(); - // Generate Artist covers from Album covers - // The artist cover will be the newest album's cover inside that artist - var artists = Db.Table().ToArray(); - foreach (var artist in artists) + // Set Artist covers from Album covers + // The artist cover will be it's newest album's cover + Db.RunInTransaction(() => { - var latestAlbumByYear = Db.Table().Where(x => x.Artist == artist.Name && x.CoverFile == ResourceState.Exists).OrderByDescending(x => x.Year).FirstOrDefault(); - if (latestAlbumByYear is null) + var artists = Db.Table().ToArray(); + foreach (var artist in artists) { - artist.CoverAlbumId = null; + var latestAlbumByYear = Db.Table() + .Where(x => x.Artist == artist.Name && !string.IsNullOrEmpty(x.CoverFileName)) + .OrderByDescending(x => x.Year) + .FirstOrDefault(); + artist.CoverFileName = latestAlbumByYear?.CoverFileName; + Db.Update(artist); } - else + }); + + if (force) + DeleteOrphanedCoverFiles(); + + ArtistsUpdated.FireAsync(this, EventArgs.Empty); + } + + private void DeleteOrphanedCoverFiles() + { + var dbCoversHashSet = new HashSet(); + foreach (var cover in Db.Table() + .Where(x => !string.IsNullOrEmpty(x.CoverFileName)) + .Select(x => x.CoverFileName)) + dbCoversHashSet.Add(cover); + var coverFiles = Directory.GetFiles(AlbumModel.CoversDirectory, "*", SearchOption.TopDirectoryOnly); + foreach (var fullPath in coverFiles) + { + var fileName = Path.GetFileName(fullPath); + if (!dbCoversHashSet.Contains(fileName)) { - artist.CoverAlbumId = latestAlbumByYear.Id; + try + { + File.Delete(fullPath); + } + catch { } } - Db.Update(artist); } - - ArtistsUpdated.FireAsync(this, EventArgs.Empty); } private async Task DownloadAndSaveAlbumCover(AlbumModel album) { try { - var data = await _audioStation.GetAlbumCoverAsync(album.Artist, album.Name); - if (data?.Data?.Length > 0) - album.SaveCover(data.Data); + string coverFileName = AlbumModel.GetCoverFileNameFromArtistAndAlbum(album.Artist, album.Name); + string coverFilePath = AlbumModel.GetCoverFileFullPath(coverFileName); + if (File.Exists(coverFilePath)) + { + // File already exists, just re-link + album.CoverFileName = coverFileName; + } else - album.DeleteCover(); + { + // Download file + var data = await _audioStation.GetAlbumCoverAsync(album.Artist, album.Name); + if (data?.Data?.Length > 0) + { + AlbumModel.SaveCoverFile(coverFileName, data.Data); + album.CoverFileName = coverFileName; + } + else + { + album.CoverFileName = string.Empty; + } + } Db.Update(album); } + catch (SynologyDotNet.Core.Exceptions.SynoHttpException synoHttpException) + { + if (synoHttpException.StatusCode == System.Net.HttpStatusCode.NotFound) + { + album.CoverFileName = string.Empty; + Db.Update(album); + } + else + { + _log.Error(synoHttpException, $"Unexpected error while downloading cover for: \"{album.Artist}\", \"{album.Name}\""); + } + } catch (Exception ex) { - _log.Error(ex, $"Failed to save Album cover: \"{album.Artist}\", \"{album.Name}\""); + _log.Error(ex, $"Unexpected error while downloading cover for: \"{album.Artist}\", \"{album.Name}\""); } } diff --git a/SynAudio/SynAudio.csproj b/SynAudio/SynAudio.csproj index fb76e88..0c147e3 100644 --- a/SynAudio/SynAudio.csproj +++ b/SynAudio/SynAudio.csproj @@ -3,7 +3,6 @@ net6.0-windows WinExe - 8.0 true true appicon.ico @@ -15,11 +14,11 @@ SynAudio - 0.4.0 + 0.5.0 Stream music from your Synology NAS. https://github.com/geloczigeri/synologydotnet-audiostation-wpf Gergő Gelóczi - Copyright © Gergő Gelóczi 2021 + Copyright © Gergő Gelóczi 2022 diff --git a/SynAudio/Utils/Md5Hash.cs b/SynAudio/Utils/Md5Hash.cs index f6dc489..16c2eec 100644 --- a/SynAudio/Utils/Md5Hash.cs +++ b/SynAudio/Utils/Md5Hash.cs @@ -8,7 +8,7 @@ public static class Md5Hash /// Computes MD5 hash from object using System.Text.Json.JsonSerializer. /// /// The object to compute the hash from. - /// 32 character long MD5 string + /// 32 characters public static string FromObject(object o) { byte[] serialized = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(o); @@ -20,11 +20,11 @@ public static string FromObject(object o) /// Computes MD5 hash from byte array. /// /// The bytes to compute the hash from. - /// 32 character long MD5 string + /// 32 characters public static string FromByteArray(byte[] bytes) { - using (var md5 = System.Security.Cryptography.MD5.Create()) - return BitConverter.ToString(md5.ComputeHash(bytes)).Replace("-", string.Empty); + using (var hash = System.Security.Cryptography.MD5.Create()) + return BitConverter.ToString(hash.ComputeHash(bytes)).Replace("-", string.Empty); } } } diff --git a/SynAudio/Utils/Sha256Hash.cs b/SynAudio/Utils/Sha256Hash.cs new file mode 100644 index 0000000..6378b77 --- /dev/null +++ b/SynAudio/Utils/Sha256Hash.cs @@ -0,0 +1,30 @@ +using System; + +namespace SynAudio.Utils +{ + public static class Sha256Hash + { + /// + /// Computes SHA256 hash from object using System.Text.Json.JsonSerializer. + /// + /// The object to compute the hash from. + /// 64 characters + public static string FromObject(object o) + { + byte[] serialized = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(o); + using (var hash = System.Security.Cryptography.SHA256.Create()) + return BitConverter.ToString(hash.ComputeHash(serialized)).Replace("-", string.Empty); + } + + /// + /// Computes SHA256 hash from byte array. + /// + /// The bytes to compute the hash from. + /// 64 characters + public static string FromByteArray(byte[] bytes) + { + using (var hash = System.Security.Cryptography.SHA256.Create()) + return BitConverter.ToString(hash.ComputeHash(bytes)).Replace("-", string.Empty); + } + } +}