Skip to content

Commit

Permalink
Improve cover file handling
Browse files Browse the repository at this point in the history
SHA256 hash is calculated from Artist.Name and Album.Name to get deterministic cover file name
  • Loading branch information
geloczi committed Jan 3, 2022
1 parent 7f4f2a0 commit 43891ad
Show file tree
Hide file tree
Showing 8 changed files with 136 additions and 89 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
53 changes: 19 additions & 34 deletions SynAudio/DAL/AlbumModel.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.IO;
using SQLite;
using SynAudio.Utils;
using SynAudio.ViewModels;
using SynologyDotNet.AudioStation.Model;

Expand All @@ -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]
Expand All @@ -36,12 +37,9 @@ public class AlbumModel : ViewModelBase
[NotNull]
public int Year { get; set; }

/// <summary>
/// Cover file state
/// </summary>
[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();

Expand All @@ -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);
}
}
}
8 changes: 5 additions & 3 deletions SynAudio/DAL/ArtistModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
9 changes: 0 additions & 9 deletions SynAudio/DAL/ResourceState.cs

This file was deleted.

108 changes: 74 additions & 34 deletions SynAudio/Library/AudioLibrary.covers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,23 @@ private async Task SyncCoversAsync(CancellationToken token, bool force)
albums = new List<AlbumModel>();
foreach (var album in Db.Table<AlbumModel>())
{
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);
}
}
}
else
{
// Get albums without cover (haven't tried to download yet)
albums = Db.Table<AlbumModel>().Where(x => x.CoverFile == ResourceState.NotSet).ToList();
albums = Db.Table<AlbumModel>().Where(x => x.CoverFileName == null).ToList();
}

// Sort albums
Expand All @@ -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<ArtistModel>().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<AlbumModel>().Where(x => x.Artist == artist.Name && x.CoverFile == ResourceState.Exists).OrderByDescending(x => x.Year).FirstOrDefault();
if (latestAlbumByYear is null)
var artists = Db.Table<ArtistModel>().ToArray();
foreach (var artist in artists)
{
artist.CoverAlbumId = null;
var latestAlbumByYear = Db.Table<AlbumModel>()
.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<string>();
foreach (var cover in Db.Table<AlbumModel>()
.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}\"");
}
}

Expand Down
5 changes: 2 additions & 3 deletions SynAudio/SynAudio.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<OutputType>WinExe</OutputType>
<LangVersion>8.0</LangVersion>
<UseWPF>true</UseWPF>
<ImportWindowsDesktopTargets>true</ImportWindowsDesktopTargets>
<ApplicationIcon>appicon.ico</ApplicationIcon>
Expand All @@ -15,11 +14,11 @@
<!-- General -->
<PropertyGroup>
<Product>SynAudio</Product>
<Version>0.4.0</Version>
<Version>0.5.0</Version>
<Description>Stream music from your Synology NAS.</Description>
<RepositoryUrl>https://github.com/geloczigeri/synologydotnet-audiostation-wpf</RepositoryUrl>
<Authors>Gergő Gelóczi</Authors>
<Copyright>Copyright © Gergő Gelóczi 2021</Copyright>
<Copyright>Copyright © Gergő Gelóczi 2022</Copyright>
</PropertyGroup>

<!-- Release mode -->
Expand Down
8 changes: 4 additions & 4 deletions SynAudio/Utils/Md5Hash.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public static class Md5Hash
/// Computes MD5 hash from object using System.Text.Json.JsonSerializer.
/// </summary>
/// <param name="o">The object to compute the hash from.</param>
/// <returns>32 character long MD5 string</returns>
/// <returns>32 characters</returns>
public static string FromObject(object o)
{
byte[] serialized = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(o);
Expand All @@ -20,11 +20,11 @@ public static string FromObject(object o)
/// Computes MD5 hash from byte array.
/// </summary>
/// <param name="bytes">The bytes to compute the hash from.</param>
/// <returns>32 character long MD5 string</returns>
/// <returns>32 characters</returns>
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);
}
}
}
30 changes: 30 additions & 0 deletions SynAudio/Utils/Sha256Hash.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;

namespace SynAudio.Utils
{
public static class Sha256Hash
{
/// <summary>
/// Computes SHA256 hash from object using System.Text.Json.JsonSerializer.
/// </summary>
/// <param name="o">The object to compute the hash from.</param>
/// <returns>64 characters</returns>
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);
}

/// <summary>
/// Computes SHA256 hash from byte array.
/// </summary>
/// <param name="bytes">The bytes to compute the hash from.</param>
/// <returns>64 characters</returns>
public static string FromByteArray(byte[] bytes)
{
using (var hash = System.Security.Cryptography.SHA256.Create())
return BitConverter.ToString(hash.ComputeHash(bytes)).Replace("-", string.Empty);
}
}
}

0 comments on commit 43891ad

Please sign in to comment.