Merge pull request #1308 from Mbucari/master

Refactors, bug fixes, and performance improvements.
This commit is contained in:
rmcrackan 2025-07-22 07:22:35 -04:00 committed by GitHub
commit 64fb2ccf7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 1007 additions and 1117 deletions

View File

@ -25,9 +25,8 @@ namespace AaxDecrypter
public override async Task CancelAsync() public override async Task CancelAsync()
{ {
IsCanceled = true; await base.CancelAsync();
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask); await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload();
} }
private Mp4File Open() private Mp4File Open()

View File

@ -120,7 +120,12 @@ namespace AaxDecrypter
} }
} }
public abstract Task CancelAsync(); public virtual Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync(); protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt) { } public virtual void SetCoverArt(byte[] coverArt) { }

View File

@ -17,13 +17,6 @@ namespace AaxDecrypter
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync; AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
} }
public override Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync() protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{ {
await InputFileStream.DownloadTask; await InputFileStream.DownloadTask;

View File

@ -7,10 +7,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" /> <PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks --> <!-- Do not remove unused Serilog.Sinks -->
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. --> <!-- Only File sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" /> <ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />

View File

@ -115,11 +115,22 @@ namespace AppScaffolding
{ {
if (config.GetObject("Serilog") is JObject serilog) if (config.GetObject("Serilog") is JObject serilog)
{ {
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink) bool fileChanged = false;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
{ {
fileSink["Name"] = "ZipFile"; zipFileSink["Name"] = "File";
config.SetNonString(serilog.DeepClone(), "Serilog"); fileChanged = true;
} }
var hooks = $"{nameof(LibationFileManager)}.{nameof(FileSinkHook)}, {nameof(LibationFileManager)}";
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
{
fileSinkArgs["hooks"] = hooks;
fileChanged = true;
}
if (fileChanged)
config.SetNonString(serilog.DeepClone(), "Serilog");
return; return;
} }
@ -129,17 +140,17 @@ namespace AppScaffolding
{ "WriteTo", new JArray { "WriteTo", new JArray
{ {
// ABOUT SINKS // ABOUT SINKS
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. // Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved // new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
new JObject new JObject
{ {
{ "Name", "ZipFile" }, { "Name", "File" },
{ "Args", { "Args",
new JObject new JObject
{ {
// for this sink to work, a path must be provided. we override this below // for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "_Log.log") }, { "path", Path.Combine(config.LibationFiles, "Log.log") },
{ "rollingInterval", "Month" }, { "rollingInterval", "Month" },
// Serilog template formatting examples // Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}" // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@ -274,7 +285,7 @@ namespace AppScaffolding
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value), disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
}); });
if (InteropFactory.InteropFunctionsType is null) if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null"); Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
} }

View File

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AaxDecrypter; using AaxDecrypter;
using ApplicationServices; using ApplicationServices;
@ -18,10 +19,15 @@ namespace FileLiberator
{ {
public override string Name => "Download & Decrypt"; public override string Name => "Download & Decrypt";
private AudiobookDownloadBase abDownloader; private AudiobookDownloadBase abDownloader;
private readonly CancellationTokenSource cancellationTokenSource = new();
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override async Task CancelAsync()
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask; {
cancellationTokenSource.Cancel();
if (abDownloader is not null)
await abDownloader.CancelAsync();
}
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{ {
@ -41,8 +47,9 @@ namespace FileLiberator
} }
OnBegin(libraryBook); OnBegin(libraryBook);
var cancellationToken = cancellationTokenSource.Token;
try try
{ {
if (libraryBook.Book.Audio_Exists()) if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
@ -50,7 +57,7 @@ namespace FileLiberator
downloadValidation(libraryBook); downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync(); var api = await libraryBook.GetApiAsync();
var config = Configuration.Instance; var config = Configuration.Instance;
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook); using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook, cancellationToken);
bool success = false; bool success = false;
try try
@ -74,38 +81,32 @@ namespace FileLiberator
.Where(f => f.FileType != FileType.AAXC) .Where(f => f.FileType != FileType.AAXC)
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path)))); .Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
return cancellationToken.ThrowIfCancellationRequested();
abDownloader?.IsCanceled is true return new StatusHandler { "Decrypt failed" };
? new StatusHandler { "Cancelled" }
: new StatusHandler { "Decrypt failed" };
} }
var finalStorageDir = getDestinationDirectory(libraryBook); var finalStorageDir = getDestinationDirectory(libraryBook);
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries)); var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken));
Task[] finalTasks = Task[] finalTasks =
[ [
Task.Run(() => downloadCoverArt(downloadOptions)), Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)),
moveFilesTask, moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir)) Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
]; ];
try try
{ {
await Task.WhenAll(finalTasks); await Task.WhenAll(finalTasks);
} }
catch catch when (!moveFilesTask.IsFaulted)
{ {
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions. //Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
//Only fail if the downloaded audio files failed to move to Books directory //Only fail if the downloaded audio files failed to move to Books directory
if (moveFilesTask.IsFaulted) }
{ finally
throw;
}
}
finally
{ {
if (moveFilesTask.IsCompletedSuccessfully) if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
{ {
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)); await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
@ -114,8 +115,12 @@ namespace FileLiberator
} }
return new StatusHandler(); return new StatusHandler();
} }
finally catch when (cancellationToken.IsCancellationRequested)
{
return new StatusHandler { "Cancelled" };
}
finally
{ {
OnCompleted(libraryBook); OnCompleted(libraryBook);
} }
@ -257,16 +262,17 @@ namespace FileLiberator
/// <summary>Move new files to 'Books' directory</summary> /// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns> /// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries) private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries, CancellationToken cancellationToken)
{ {
// create final directory. move each file into it // create final directory. move each file into it
var destinationDir = getDestinationDirectory(libraryBook); var destinationDir = getDestinationDirectory(libraryBook);
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < entries.Count; i++) for (var i = 0; i < entries.Count; i++)
{ {
var entry = entries[i]; var entry = entries[i];
var realDest var realDest
= FileUtility.SaferMoveToValidPath( = FileUtility.SaferMoveToValidPath(
entry.Path, entry.Path,
Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
@ -278,7 +284,8 @@ namespace FileLiberator
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop) // propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
entries[i] = entry with { Path = realDest }; entries[i] = entry with { Path = realDest };
} cancellationToken.ThrowIfCancellationRequested();
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue); var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default) if (cue != default)
@ -287,7 +294,8 @@ namespace FileLiberator
SetFileTime(libraryBook, cue.Path); SetFileTime(libraryBook, cue.Path);
} }
AudibleFileStorage.Audio.Refresh(); cancellationToken.ThrowIfCancellationRequested();
AudibleFileStorage.Audio.Refresh();
} }
private static string getDestinationDirectory(LibraryBook libraryBook) private static string getDestinationDirectory(LibraryBook libraryBook)
@ -301,7 +309,7 @@ namespace FileLiberator
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries) private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio); => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private static void downloadCoverArt(DownloadOptions options) private static void downloadCoverArt(DownloadOptions options, CancellationToken cancellationToken)
{ {
if (!Configuration.Instance.DownloadCoverArt) return; if (!Configuration.Instance.DownloadCoverArt) return;
@ -316,7 +324,7 @@ namespace FileLiberator
if (File.Exists(coverPath)) if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath); FileUtility.SaferDelete(coverPath);
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native)); var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken);
if (picBytes.Length > 0) if (picBytes.Length > 0)
{ {
File.WriteAllBytes(coverPath, picBytes); File.WriteAllBytes(coverPath, picBytes);
@ -327,6 +335,7 @@ namespace FileLiberator
{ {
//Failure to download cover art should not be considered a failure to download the book //Failure to download cover art should not be considered a failure to download the book
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product."); Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
throw;
} }
} }
} }

View File

@ -11,6 +11,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable #nullable enable
@ -24,9 +25,10 @@ public partial class DownloadOptions
/// <summary> /// <summary>
/// Initiate an audiobook download from the audible api. /// Initiate an audiobook download from the audible api.
/// </summary> /// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook) public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
{ {
var license = await ChooseContent(api, libraryBook, config); var license = await ChooseContent(api, libraryBook, config, token);
token.ThrowIfCancellationRequested();
//Some audiobooks will have incorrect chapters in the metadata returned from the license request, //Some audiobooks will have incorrect chapters in the metadata returned from the license request,
//but the metadata returned by the content metadata endpoint will be correct. Call the content //but the metadata returned by the content metadata endpoint will be correct. Call the content
@ -36,9 +38,8 @@ public partial class DownloadOptions
if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs) if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs)
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo; license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
var options = BuildDownloadOptions(libraryBook, config, license); token.ThrowIfCancellationRequested();
return BuildDownloadOptions(libraryBook, config, license);
return options;
} }
private class LicenseInfo private class LicenseInfo
@ -57,16 +58,18 @@ public partial class DownloadOptions
=> voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)]; => voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
} }
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config) private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
{ {
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
{ {
token.ThrowIfCancellationRequested();
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
return new LicenseInfo(license); return new LicenseInfo(license);
} }
token.ThrowIfCancellationRequested();
try try
{ {
//try to request a widevine content license using the user's spatial audio settings //try to request a widevine content license using the user's spatial audio settings
@ -85,8 +88,8 @@ public partial class DownloadOptions
return new LicenseInfo(contentLic); return new LicenseInfo(contentLic);
using var client = new HttpClient(); using var client = new HttpClient();
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse); using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream()); var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri)) if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
throw new InvalidDataException("Failed to get mpeg-dash content download url."); throw new InvalidDataException("Failed to get mpeg-dash content download url.");

View File

@ -9,7 +9,7 @@ namespace LibationAvalonia.Controls
{ {
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary. //Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
ele.IsThreeState = dataItem is ISeriesEntry; ele.IsThreeState = dataItem is SeriesEntry;
return ele; return ele;
} }
} }

View File

@ -34,11 +34,11 @@ namespace LibationAvalonia.Controls
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e) private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
{ {
if (sender is DataGridCell cell && if (sender is DataGridCell cell &&
cell.DataContext is IGridEntry clickedEntry && cell.DataContext is GridEntry clickedEntry &&
OwningColumnProperty.GetValue(cell) is DataGridColumn column && OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
OwningGridProperty.GetValue(column) is DataGrid grid) OwningGridProperty.GetValue(column) is DataGrid grid)
{ {
var allSelected = grid.SelectedItems.OfType<IGridEntry>().ToArray(); var allSelected = grid.SelectedItems.OfType<GridEntry>().ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry); var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1) if (clickedIndex == -1)
{ {
@ -101,7 +101,7 @@ namespace LibationAvalonia.Controls
private static string RemoveLineBreaks(string text) private static string RemoveLineBreaks(string text)
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' '); => text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
private string GetRowClipboardContents(IGridEntry gridEntry) private string GetRowClipboardContents(GridEntry gridEntry)
{ {
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray(); var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
return string.Join("\t", contents); return string.Join("\t", contents);
@ -109,7 +109,7 @@ namespace LibationAvalonia.Controls
public required DataGrid Grid { get; init; } public required DataGrid Grid { get; init; }
public required DataGridColumn Column { get; init; } public required DataGridColumn Column { get; init; }
public required IGridEntry[] GridEntries { get; init; } public required GridEntry[] GridEntries { get; init; }
public required ContextMenu ContextMenu { get; init; } public required ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.ItemsSource as AvaloniaList<Control>; => ContextMenu.ItemsSource as AvaloniaList<Control>;

View File

@ -58,10 +58,5 @@ namespace LibationAvalonia.Controls.Settings
} }
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged; ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
} }
public void OpenLogFolderButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
}
} }
} }

View File

@ -36,11 +36,11 @@ public partial class ThemePreviewControl : UserControl
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray()); PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
} }
QueuedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Queued }; QueuedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Queued };
WorkingBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Working }; WorkingBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Working };
CompletedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Completed }; CompletedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Completed };
CancelledBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Cancelled }; CancelledBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Cancelled };
FailedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Failed }; FailedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Failed };
//Set the current processable so that the empty queue doesn't try to advance. //Set the current processable so that the empty queue doesn't try to advance.
QueuedBook.AddDownloadPdf(); QueuedBook.AddDownloadPdf();

View File

@ -1,5 +1,6 @@
using AudibleApi; using AudibleApi;
using AudibleUtilities; using AudibleUtilities;
using Avalonia.Threading;
using LibationUiBase.Forms; using LibationUiBase.Forms;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -17,42 +18,46 @@ namespace LibationAvalonia.Dialogs.Login
} }
public async Task<string> Get2faCodeAsync(string prompt) public async Task<string> Get2faCodeAsync(string prompt)
{ => await Dispatcher.UIThread.InvokeAsync(async () =>
var dialog = new _2faCodeDialog(prompt); {
if (await dialog.ShowDialogAsync() is DialogResult.OK) var dialog = new _2faCodeDialog(prompt);
return dialog.Code; if (await dialog.ShowDialogAsync() is DialogResult.OK)
return dialog.Code;
return null; return null;
} });
public async Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage) public async Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
{ => await Dispatcher.UIThread.InvokeAsync(async () =>
var dialog = new CaptchaDialog(password, captchaImage); {
if (await dialog.ShowDialogAsync() is DialogResult.OK) var dialog = new CaptchaDialog(password, captchaImage);
return (dialog.Password, dialog.Answer); if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (null, null); return (dialog.Password, dialog.Answer);
} return (null, null);
});
public async Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig) public async Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
{ => await Dispatcher.UIThread.InvokeAsync(async () =>
var dialog = new MfaDialog(mfaConfig); {
if (await dialog.ShowDialogAsync() is DialogResult.OK) var dialog = new MfaDialog(mfaConfig);
return (dialog.SelectedName, dialog.SelectedValue); if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (null, null); return (dialog.SelectedName, dialog.SelectedValue);
} return (null, null);
});
public async Task<(string email, string password)> GetLoginAsync() public async Task<(string email, string password)> GetLoginAsync()
{ => await Dispatcher.UIThread.InvokeAsync(async () =>
var dialog = new LoginCallbackDialog(_account); {
if (await dialog.ShowDialogAsync() is DialogResult.OK) var dialog = new LoginCallbackDialog(_account);
return (_account.AccountId, dialog.Password); if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (null, null); return (_account.AccountId, dialog.Password);
} return (null, null);
});
public async Task ShowApprovalNeededAsync() public async Task ShowApprovalNeededAsync()
{ => await Dispatcher.UIThread.InvokeAsync(async () =>
var dialog = new ApprovalNeededDialog(); {
await dialog.ShowDialogAsync(); var dialog = new ApprovalNeededDialog();
} await dialog.ShowDialogAsync();
});
} }
} }

View File

@ -1,5 +1,6 @@
using AudibleApi; using AudibleApi;
using AudibleUtilities; using AudibleUtilities;
using Avalonia.Threading;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms; using LibationUiBase.Forms;
using System; using System;
@ -21,6 +22,9 @@ namespace LibationAvalonia.Dialogs.Login
} }
public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn) public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
=> await Dispatcher.UIThread.InvokeAsync(() => StartAsyncInternal(choiceIn));
private async Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn)
{ {
if (Configuration.IsWindows && Environment.OSVersion.Version.Major >= 10) if (Configuration.IsWindows && Environment.OSVersion.Version.Major >= 10)
{ {

View File

@ -1,20 +0,0 @@
using Avalonia.Media.Imaging;
using DataLayer;
using LibationUiBase.GridView;
using System;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable
{
private AvaloniaEntryStatus(LibraryBook libraryBook) : base(libraryBook) { }
public static EntryStatus Create(LibraryBook libraryBook) => new AvaloniaEntryStatus(libraryBook);
protected override Bitmap LoadImage(byte[] picture)
=> AvaloniaUtils.TryLoadImageOrDefault(picture, LibationFileManager.PictureSize._80x80);
//Button icons are handled by LiberateStatusButton
protected override Bitmap? GetResourceImage(string rescName) => null;
}
}

View File

@ -202,7 +202,7 @@ namespace LibationAvalonia.ViewModels
{ {
try try
{ {
var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(accounts); var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
if (Configuration.Instance.ShowImportedStats && newAdded > 0) if (Configuration.Instance.ShowImportedStats && newAdded > 0)

View File

@ -57,7 +57,7 @@ namespace LibationAvalonia.ViewModels
} }
} }
public void LiberateSeriesClicked(ISeriesEntry series) public void LiberateSeriesClicked(SeriesEntry series)
{ {
try try
{ {

View File

@ -5,6 +5,7 @@ using LibationFileManager;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
#nullable enable #nullable enable
namespace LibationAvalonia.ViewModels namespace LibationAvalonia.ViewModels
@ -27,7 +28,7 @@ namespace LibationAvalonia.ViewModels
// in autoScan, new books SHALL NOT show dialog // in autoScan, new books SHALL NOT show dialog
try try
{ {
await LibraryCommands.ImportAccountAsync(accounts); await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@ -1,5 +1,6 @@
using LibationFileManager; using LibationFileManager;
using LibationUiBase; using LibationUiBase;
using System;
using System.IO; using System.IO;
#nullable enable #nullable enable
@ -7,7 +8,7 @@ namespace LibationAvalonia.ViewModels
{ {
partial class MainVM partial class MainVM
{ {
private void Configure_NonUI() public static void Configure_NonUI()
{ {
using var ms1 = new MemoryStream(); using var ms1 = new MemoryStream();
App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1); App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1);
@ -23,6 +24,20 @@ namespace LibationAvalonia.ViewModels
PictureStorage.SetDefaultImage(PictureSize.Native, ms3.ToArray()); PictureStorage.SetDefaultImage(PictureSize.Native, ms3.ToArray());
BaseUtil.SetLoadImageDelegate(AvaloniaUtils.TryLoadImageOrDefault); BaseUtil.SetLoadImageDelegate(AvaloniaUtils.TryLoadImageOrDefault);
BaseUtil.SetLoadResourceImageDelegate(LoadResourceImage);
}
private static Avalonia.Media.Imaging.Bitmap? LoadResourceImage(string resourceName)
{
try
{
using var stream = App.OpenAsset(resourceName);
return new Avalonia.Media.Imaging.Bitmap(stream);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to load resource image: {ResourceName}", resourceName);
return null;
}
} }
} }
} }

View File

@ -2,6 +2,7 @@
using DataLayer; using DataLayer;
using LibationAvalonia.Views; using LibationAvalonia.Views;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.ProcessQueue;
using ReactiveUI; using ReactiveUI;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@ -1,17 +0,0 @@
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.ProcessQueue;
#nullable enable
namespace LibationAvalonia.ViewModels;
public class ProcessBookViewModel : ProcessBookViewModelBase
{
public ProcessBookViewModel(LibraryBook libraryBook, LogMe logme) : base(libraryBook, logme) { }
protected override object? LoadImageFromBytes(byte[] bytes, PictureSize pictureSize)
=> AvaloniaUtils.TryLoadImageOrDefault(bytes, pictureSize);
}

View File

@ -1,74 +0,0 @@
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using LibationFileManager;
using LibationUiBase.ProcessQueue;
using System;
using System.Collections.ObjectModel;
#nullable enable
namespace LibationAvalonia.ViewModels;
public record LogEntry(DateTime LogDate, string? LogMessage)
{
public string LogDateString => LogDate.ToShortTimeString();
}
public class ProcessQueueViewModel : ProcessQueueViewModelBase
{
public ProcessQueueViewModel() : base(CreateEmptyList())
{
Items = Queue.UnderlyingList as AvaloniaList<ProcessBookViewModelBase>
?? throw new ArgumentNullException(nameof(Queue.UnderlyingList));
SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
}
private decimal _speedLimit;
public decimal SpeedLimitIncrement { get; private set; }
public ObservableCollection<LogEntry> LogEntries { get; } = new();
public AvaloniaList<ProcessBookViewModelBase> Items { get; }
public decimal SpeedLimit
{
get
{
return _speedLimit;
}
set
{
var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024));
var config = Configuration.Instance;
config.DownloadSpeedLimit = newValue;
_speedLimit
= config.DownloadSpeedLimit <= newValue ? value
: value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024
: 0;
config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024);
SpeedLimitIncrement = _speedLimit > 100 ? 10
: _speedLimit > 10 ? 1
: _speedLimit > 1 ? 0.1m
: 0.01m;
RaisePropertyChanged(nameof(SpeedLimitIncrement));
RaisePropertyChanged(nameof(SpeedLimit));
}
}
public override void WriteLine(string text)
=> Dispatcher.UIThread.Invoke(() => LogEntries.Add(new(DateTime.Now, text.Trim())));
protected override ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook)
=> new ProcessBookViewModel(libraryBook, Logger);
private static AvaloniaList<ProcessBookViewModelBase> CreateEmptyList()
{
if (Design.IsDesignMode)
_ = Configuration.Instance.LibationFiles;
return new AvaloniaList<ProcessBookViewModelBase>();
}
}

View File

@ -26,9 +26,9 @@ namespace LibationAvalonia.ViewModels
public event EventHandler<int>? RemovableCountChanged; public event EventHandler<int>? RemovableCountChanged;
/// <summary>Backing list of all grid entries</summary> /// <summary>Backing list of all grid entries</summary>
private readonly AvaloniaList<IGridEntry> SOURCE = new(); private readonly AvaloniaList<GridEntry> SOURCE = new();
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary> /// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private HashSet<IGridEntry>? FilteredInGridEntries; private HashSet<GridEntry>? FilteredInGridEntries;
public string? FilterString { get; private set; } public string? FilterString { get; private set; }
private DataGridCollectionView? _gridEntries; private DataGridCollectionView? _gridEntries;
@ -43,15 +43,15 @@ namespace LibationAvalonia.ViewModels
public List<LibraryBook> GetVisibleBookEntries() public List<LibraryBook> GetVisibleBookEntries()
=> FilteredInGridEntries? => FilteredInGridEntries?
.OfType<ILibraryBookEntry>() .OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook) .Select(lbe => lbe.LibraryBook)
.ToList() .ToList()
?? SOURCE ?? SOURCE
.OfType<ILibraryBookEntry>() .OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook) .Select(lbe => lbe.LibraryBook)
.ToList(); .ToList();
private IEnumerable<ILibraryBookEntry> GetAllBookEntries() private IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> SOURCE => SOURCE
.BookEntries(); .BookEntries();
@ -112,8 +112,8 @@ namespace LibationAvalonia.ViewModels
var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current); var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current);
AvaloniaSynchronizationContext.SetSynchronizationContext(sc); AvaloniaSynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks); var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks); var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks);
//Add all IGridEntries to the SOURCE list. Note that SOURCE has not yet been linked to the UI via //Add all IGridEntries to the SOURCE list. Note that SOURCE has not yet been linked to the UI via
//the GridEntries property, so adding items to SOURCE will not trigger any refreshes or UI action. //the GridEntries property, so adding items to SOURCE will not trigger any refreshes or UI action.
@ -147,8 +147,8 @@ namespace LibationAvalonia.ViewModels
private void GridEntries_CollectionChanged(object? sender = null, EventArgs? e = null) private void GridEntries_CollectionChanged(object? sender = null, EventArgs? e = null)
{ {
var count var count
= FilteredInGridEntries?.OfType<ILibraryBookEntry>().Count() = FilteredInGridEntries?.OfType<LibraryBookEntry>().Count()
?? SOURCE.OfType<ILibraryBookEntry>().Count(); ?? SOURCE.OfType<LibraryBookEntry>().Count();
VisibleCountChanged?.Invoke(this, count); VisibleCountChanged?.Invoke(this, count);
} }
@ -223,9 +223,9 @@ namespace LibationAvalonia.ViewModels
GridEntries_CollectionChanged(); GridEntries_CollectionChanged();
} }
private void RemoveBooks(IEnumerable<ILibraryBookEntry> removedBooks, IEnumerable<ISeriesEntry> removedSeries) private void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks, IEnumerable<SeriesEntry> removedSeries)
{ {
foreach (var removed in removedBooks.Cast<IGridEntry>().Concat(removedSeries).Where(b => b is not null).ToList()) foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
{ {
if (GridEntries?.PassesFilter(removed) ?? false) if (GridEntries?.PassesFilter(removed) ?? false)
GridEntries.Remove(removed); GridEntries.Remove(removed);
@ -238,21 +238,21 @@ namespace LibationAvalonia.ViewModels
} }
} }
private void UpsertBook(LibraryBook book, ILibraryBookEntry? existingBookEntry) private void UpsertBook(LibraryBook book, LibraryBookEntry? existingBookEntry)
{ {
if (existingBookEntry is null) if (existingBookEntry is null)
// Add the new product to top // Add the new product to top
SOURCE.Insert(0, new LibraryBookEntry<AvaloniaEntryStatus>(book)); SOURCE.Insert(0, new LibraryBookEntry(book));
else else
// update existing // update existing
existingBookEntry.UpdateLibraryBook(book); existingBookEntry.UpdateLibraryBook(book);
} }
private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry? existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks) private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry? existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{ {
if (existingEpisodeEntry is null) if (existingEpisodeEntry is null)
{ {
ILibraryBookEntry episodeEntry; LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
@ -270,7 +270,7 @@ namespace LibationAvalonia.ViewModels
return; return;
} }
seriesEntry = new SeriesEntry<AvaloniaEntryStatus>(seriesBook, episodeBook); seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry); seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0]; episodeEntry = seriesEntry.Children[0];
@ -280,7 +280,7 @@ namespace LibationAvalonia.ViewModels
else else
{ {
//Series exists. Create and add episode child then update the SeriesEntry //Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new LibraryBookEntry<AvaloniaEntryStatus>(episodeBook, seriesEntry); episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry); seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex)); seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
@ -307,7 +307,7 @@ namespace LibationAvalonia.ViewModels
} }
} }
public async Task ToggleSeriesExpanded(ISeriesEntry seriesEntry) public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
{ {
seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded; seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded;
@ -332,7 +332,7 @@ namespace LibationAvalonia.ViewModels
private bool CollectionFilter(object item) private bool CollectionFilter(object item)
{ {
if (item is ILibraryBookEntry lbe if (item is LibraryBookEntry lbe
&& lbe.Liberate.IsEpisode && lbe.Liberate.IsEpisode
&& lbe.Parent?.Liberate?.Expanded != true) && lbe.Parent?.Liberate?.Expanded != true)
return false; return false;
@ -454,7 +454,7 @@ namespace LibationAvalonia.ViewModels
private void GridEntry_PropertyChanged(object? sender, PropertyChangedEventArgs? e) private void GridEntry_PropertyChanged(object? sender, PropertyChangedEventArgs? e)
{ {
if (e?.PropertyName == nameof(IGridEntry.Remove) && sender is ILibraryBookEntry) if (e?.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry)
{ {
int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true); int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true);
RemovableCountChanged?.Invoke(this, removeCount); RemovableCountChanged?.Invoke(this, removeCount);

View File

@ -17,7 +17,7 @@ namespace LibationAvalonia.ViewModels
public RowComparer(DataGridColumn? column) public RowComparer(DataGridColumn? column)
{ {
Column = column; Column = column;
PropertyName = Column?.SortMemberPath ?? nameof(IGridEntry.DateAdded); PropertyName = Column?.SortMemberPath ?? nameof(GridEntry.DateAdded);
} }
//Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection

View File

@ -57,7 +57,13 @@ namespace LibationAvalonia.ViewModels.Settings
config.GridFontScaleFactor = linearRangeToScaleFactor(GridFontScaleFactor); config.GridFontScaleFactor = linearRangeToScaleFactor(GridFontScaleFactor);
config.GridScaleFactor = linearRangeToScaleFactor(GridScaleFactor); config.GridScaleFactor = linearRangeToScaleFactor(GridScaleFactor);
} }
public void OpenLogFolderButton() => Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName); public void OpenLogFolderButton()
{
if (System.IO.File.Exists(LogFileFilter.LogFilePath))
Go.To.File(LogFileFilter.LogFilePath);
else
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
}
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new() public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
{ {

View File

@ -53,8 +53,8 @@ namespace LibationAvalonia.Views
private void LiberateStatusButton_DataContextChanged(object sender, EventArgs e) private void LiberateStatusButton_DataContextChanged(object sender, EventArgs e)
{ {
//Force book status recheck when an entry is scrolled into view. //Force book status recheck when an entry is scrolled into view.
//This will force a recheck for a paprtially downloaded file. //This will force a recheck for a partially downloaded file.
var status = DataContext as ILibraryBookEntry; var status = DataContext as LibraryBookEntry;
status?.Liberate.Invalidate(nameof(status.Liberate.BookStatus)); status?.Liberate.Invalidate(nameof(status.Liberate.BookStatus));
} }

View File

@ -22,7 +22,7 @@ namespace LibationAvalonia.Views
public MainWindow() public MainWindow()
{ {
DataContext = new MainVM(this); DataContext = new MainVM(this);
ApiExtended.LoginChoiceFactory = account => new Dialogs.Login.AvaloniaLoginChoiceEager(account); ApiExtended.LoginChoiceFactory = account => Dispatcher.UIThread.Invoke(() => new Dialogs.Login.AvaloniaLoginChoiceEager(account));
AudibleApiStorage.LoadError += AudibleApiStorage_LoadError; AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
InitializeComponent(); InitializeComponent();
@ -137,7 +137,7 @@ namespace LibationAvalonia.Views
} }
public void ProductsDisplay_LiberateClicked(object _, LibraryBook[] libraryBook) => ViewModel.LiberateClicked(libraryBook); public void ProductsDisplay_LiberateClicked(object _, LibraryBook[] libraryBook) => ViewModel.LiberateClicked(libraryBook);
public void ProductsDisplay_LiberateSeriesClicked(object _, ISeriesEntry series) => ViewModel.LiberateSeriesClicked(series); public void ProductsDisplay_LiberateSeriesClicked(object _, SeriesEntry series) => ViewModel.LiberateSeriesClicked(series);
public void ProductsDisplay_ConvertToMp3Clicked(object _, LibraryBook[] libraryBook) => ViewModel.ConvertToMp3Clicked(libraryBook); public void ProductsDisplay_ConvertToMp3Clicked(object _, LibraryBook[] libraryBook) => ViewModel.ConvertToMp3Clicked(libraryBook);
BookDetailsDialog bookDetailsForm; BookDetailsDialog bookDetailsForm;

View File

@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels" xmlns:vm="clr-namespace:LibationUiBase.ProcessQueue;assembly=LibationUiBase"
xmlns:views="clr-namespace:LibationAvalonia.Views" xmlns:views="clr-namespace:LibationAvalonia.Views"
x:DataType="vm:ProcessBookViewModel" x:DataType="vm:ProcessBookViewModel"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="87" MaxHeight="87" MinHeight="87" MinWidth="300" mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="87" MaxHeight="87" MinHeight="87" MinWidth="300"

View File

@ -2,7 +2,6 @@ using ApplicationServices;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using DataLayer; using DataLayer;
using LibationAvalonia.ViewModels;
using LibationUiBase; using LibationUiBase;
using LibationUiBase.ProcessQueue; using LibationUiBase.ProcessQueue;
@ -31,10 +30,8 @@ namespace LibationAvalonia.Views
if (Design.IsDesignMode) if (Design.IsDesignMode)
{ {
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
DataContext = new ProcessBookViewModel( ViewModels.MainVM.Configure_NonUI();
context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), DataContext = new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"));
LogMe.RegisterForm(default(ILogForm))
);
return; return;
} }
} }
@ -44,7 +41,7 @@ namespace LibationAvalonia.Views
public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> CancelButtonClicked?.Invoke(DataItem); => CancelButtonClicked?.Invoke(DataItem);
public void MoveFirst_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) public void MoveFirst_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> PositionButtonClicked?.Invoke(DataItem, QueuePosition.Fisrt); => PositionButtonClicked?.Invoke(DataItem, QueuePosition.First);
public void MoveUp_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) public void MoveUp_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> PositionButtonClicked?.Invoke(DataItem, QueuePosition.OneUp); => PositionButtonClicked?.Invoke(DataItem, QueuePosition.OneUp);
public void MoveDown_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) public void MoveDown_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)

View File

@ -34,7 +34,7 @@
HorizontalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
AllowAutoHide="False"> AllowAutoHide="False">
<ItemsControl ItemsSource="{Binding Items}"> <ItemsControl ItemsSource="{Binding Queue}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate> <ItemsPanelTemplate>
<VirtualizingStackPanel /> <VirtualizingStackPanel />

View File

@ -1,9 +1,7 @@
using ApplicationServices; using ApplicationServices;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Data.Converters; using Avalonia.Data.Converters;
using DataLayer; using DataLayer;
using LibationAvalonia.ViewModels;
using LibationUiBase; using LibationUiBase;
using LibationUiBase.ProcessQueue; using LibationUiBase.ProcessQueue;
using System; using System;
@ -17,7 +15,7 @@ namespace LibationAvalonia.Views
{ {
public partial class ProcessQueueControl : UserControl public partial class ProcessQueueControl : UserControl
{ {
private TrackedQueue<ProcessBookViewModelBase>? Queue => _viewModel?.Queue; private TrackedQueue<ProcessBookViewModel>? Queue => _viewModel?.Queue;
private ProcessQueueViewModel? _viewModel => DataContext as ProcessQueueViewModel; private ProcessQueueViewModel? _viewModel => DataContext as ProcessQueueViewModel;
public ProcessQueueControl() public ProcessQueueControl()
@ -31,48 +29,49 @@ namespace LibationAvalonia.Views
#if DEBUG #if DEBUG
if (Design.IsDesignMode) if (Design.IsDesignMode)
{ {
_ = LibationFileManager.Configuration.Instance.LibationFiles;
ViewModels.MainVM.Configure_NonUI();
var vm = new ProcessQueueViewModel(); var vm = new ProcessQueueViewModel();
var Logger = LogMe.RegisterForm(vm);
DataContext = vm; DataContext = vm;
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
List<ProcessBookViewModel> testList = new() List<ProcessBookViewModel> testList = new()
{ {
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"))
{ {
Result = ProcessBookResult.FailedAbort, Result = ProcessBookResult.FailedAbort,
Status = ProcessBookStatus.Failed, Status = ProcessBookStatus.Failed,
}, },
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"))
{ {
Result = ProcessBookResult.FailedSkip, Result = ProcessBookResult.FailedSkip,
Status = ProcessBookStatus.Failed, Status = ProcessBookStatus.Failed,
}, },
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"))
{ {
Result = ProcessBookResult.FailedRetry, Result = ProcessBookResult.FailedRetry,
Status = ProcessBookStatus.Failed, Status = ProcessBookStatus.Failed,
}, },
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"))
{ {
Result = ProcessBookResult.ValidationFail, Result = ProcessBookResult.ValidationFail,
Status = ProcessBookStatus.Failed, Status = ProcessBookStatus.Failed,
}, },
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"))
{ {
Result = ProcessBookResult.Cancelled, Result = ProcessBookResult.Cancelled,
Status = ProcessBookStatus.Cancelled, Status = ProcessBookStatus.Cancelled,
}, },
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"))
{ {
Result = ProcessBookResult.Success, Result = ProcessBookResult.Success,
Status = ProcessBookStatus.Completed, Status = ProcessBookStatus.Completed,
}, },
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"))
{ {
Result = ProcessBookResult.None, Result = ProcessBookResult.None,
Status = ProcessBookStatus.Working, Status = ProcessBookStatus.Working,
}, },
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"))
{ {
Result = ProcessBookResult.None, Result = ProcessBookResult.None,
Status = ProcessBookStatus.Queued, Status = ProcessBookStatus.Queued,

View File

@ -59,7 +59,7 @@
Width="75"> Width="75">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<CheckBox <CheckBox
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsThreeState="True" IsThreeState="True"
@ -70,7 +70,7 @@
<controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="True" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}"> <controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="True" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<views:LiberateStatusButton <views:LiberateStatusButton
ToolTip.Tip="{CompiledBinding Liberate.ToolTip}" ToolTip.Tip="{CompiledBinding Liberate.ToolTip}"
BookStatus="{CompiledBinding Liberate.BookStatus}" BookStatus="{CompiledBinding Liberate.BookStatus}"
@ -85,7 +85,7 @@
<controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}"> <controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" /> <Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" />
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
@ -93,7 +93,7 @@
<controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{Binding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}"> <controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{Binding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Classes="h1" Text="{CompiledBinding Title}" /> <TextBlock Classes="h1" Text="{CompiledBinding Title}" />
</Panel> </Panel>
@ -103,7 +103,7 @@
<controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{Binding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}"> <controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{Binding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Authors}" /> <TextBlock Text="{CompiledBinding Authors}" />
</Panel> </Panel>
@ -113,7 +113,7 @@
<controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{Binding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}"> <controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{Binding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Narrators}" /> <TextBlock Text="{CompiledBinding Narrators}" />
</Panel> </Panel>
@ -123,7 +123,7 @@
<controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{Binding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}"> <controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{Binding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Length}" /> <TextBlock Text="{CompiledBinding Length}" />
</Panel> </Panel>
@ -133,7 +133,7 @@
<controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{Binding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}"> <controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{Binding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Series}" /> <TextBlock Text="{CompiledBinding Series}" />
</Panel> </Panel>
@ -143,7 +143,7 @@
<controls:DataGridTemplateColumnExt Header="Series&#xA;Order" MinWidth="10" Width="{Binding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}"> <controls:DataGridTemplateColumnExt Header="Series&#xA;Order" MinWidth="10" Width="{Binding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding SeriesOrder}" HorizontalAlignment="Center" /> <TextBlock Text="{CompiledBinding SeriesOrder}" HorizontalAlignment="Center" />
</Panel> </Panel>
@ -153,7 +153,7 @@
<controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{Binding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}"> <controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{Binding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" > <Panel Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<TextBlock Text="{CompiledBinding Description}" VerticalAlignment="Top" /> <TextBlock Text="{CompiledBinding Description}" VerticalAlignment="Top" />
</Panel> </Panel>
@ -163,7 +163,7 @@
<controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{Binding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}"> <controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{Binding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Category}" /> <TextBlock Text="{CompiledBinding Category}" />
</Panel> </Panel>
@ -172,7 +172,7 @@
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridMyRatingColumn <controls:DataGridMyRatingColumn
x:DataType="uibase:IGridEntry" x:DataType="uibase:GridEntry"
Header="Product&#xA;Rating" Header="Product&#xA;Rating"
IsReadOnly="true" IsReadOnly="true"
MinWidth="10" Width="{Binding ProductRatingWidth, Mode=TwoWay}" MinWidth="10" Width="{Binding ProductRatingWidth, Mode=TwoWay}"
@ -183,7 +183,7 @@
<controls:DataGridTemplateColumnExt Header="Purchase&#xA;Date" MinWidth="10" Width="{Binding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}"> <controls:DataGridTemplateColumnExt Header="Purchase&#xA;Date" MinWidth="10" Width="{Binding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding PurchaseDate}" /> <TextBlock Text="{CompiledBinding PurchaseDate}" />
</Panel> </Panel>
@ -192,7 +192,7 @@
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridMyRatingColumn <controls:DataGridMyRatingColumn
x:DataType="uibase:IGridEntry" x:DataType="uibase:GridEntry"
Header="My Rating" Header="My Rating"
IsReadOnly="false" IsReadOnly="false"
MinWidth="10" Width="{Binding MyRatingWidth, Mode=TwoWay}" MinWidth="10" Width="{Binding MyRatingWidth, Mode=TwoWay}"
@ -203,7 +203,7 @@
<controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{Binding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}"> <controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{Binding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Misc}" TextWrapping="WrapWithOverflow" /> <TextBlock Text="{CompiledBinding Misc}" TextWrapping="WrapWithOverflow" />
</Panel> </Panel>
@ -213,7 +213,7 @@
<controls:DataGridTemplateColumnExt Header="Last&#xA;Download" MinWidth="10" Width="{Binding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}"> <controls:DataGridTemplateColumnExt Header="Last&#xA;Download" MinWidth="10" Width="{Binding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
<TextBlock Text="{CompiledBinding LastDownload}" TextWrapping="WrapWithOverflow" /> <TextBlock Text="{CompiledBinding LastDownload}" TextWrapping="WrapWithOverflow" />
</Panel> </Panel>
@ -223,7 +223,7 @@
<controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{Binding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}"> <controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{Binding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:GridEntry">
<Button <Button
IsVisible="{CompiledBinding !Liberate.IsSeries}" IsVisible="{CompiledBinding !Liberate.IsSeries}"
VerticalAlignment="Stretch" VerticalAlignment="Stretch"

View File

@ -26,7 +26,7 @@ namespace LibationAvalonia.Views
public partial class ProductsDisplay : UserControl public partial class ProductsDisplay : UserControl
{ {
public event EventHandler<LibraryBook[]>? LiberateClicked; public event EventHandler<LibraryBook[]>? LiberateClicked;
public event EventHandler<ISeriesEntry>? LiberateSeriesClicked; public event EventHandler<SeriesEntry>? LiberateSeriesClicked;
public event EventHandler<LibraryBook[]>? ConvertToMp3Clicked; public event EventHandler<LibraryBook[]>? ConvertToMp3Clicked;
public event EventHandler<LibraryBook>? TagsButtonClicked; public event EventHandler<LibraryBook>? TagsButtonClicked;
@ -102,7 +102,7 @@ namespace LibationAvalonia.Views
private void ProductsDisplay_LoadingRow(object sender, DataGridRowEventArgs e) private void ProductsDisplay_LoadingRow(object sender, DataGridRowEventArgs e)
{ {
if (e.Row.DataContext is LibraryBookEntry<AvaloniaEntryStatus> entry && entry.Liberate.IsEpisode) if (e.Row.DataContext is LibraryBookEntry entry && entry.Liberate.IsEpisode)
e.Row.DynamicResource(DataGridRow.BackgroundProperty, "SeriesEntryGridBackgroundBrush"); e.Row.DynamicResource(DataGridRow.BackgroundProperty, "SeriesEntryGridBackgroundBrush");
else else
e.Row.DynamicResource(DataGridRow.BackgroundProperty, "SystemRegionColor"); e.Row.DynamicResource(DataGridRow.BackgroundProperty, "SystemRegionColor");
@ -173,10 +173,10 @@ namespace LibationAvalonia.Views
{ {
switch (column.SortMemberPath) switch (column.SortMemberPath)
{ {
case nameof(IGridEntry.Liberate): case nameof(GridEntry.Liberate):
column.Width = new DataGridLength(BaseLiberateWidth * scaleFactor); column.Width = new DataGridLength(BaseLiberateWidth * scaleFactor);
break; break;
case nameof(IGridEntry.Cover): case nameof(GridEntry.Cover):
column.Width = new DataGridLength(BaseCoverWidth * scaleFactor); column.Width = new DataGridLength(BaseCoverWidth * scaleFactor);
break; break;
} }
@ -220,7 +220,7 @@ namespace LibationAvalonia.Views
#region Liberate all Episodes (Single series only) #region Liberate all Episodes (Single series only)
if (entries.Length == 1 && entries[0] is ISeriesEntry seriesEntry) if (entries.Length == 1 && entries[0] is SeriesEntry seriesEntry)
{ {
args.ContextMenuItems.Add(new MenuItem() args.ContextMenuItems.Add(new MenuItem()
{ {
@ -253,7 +253,7 @@ namespace LibationAvalonia.Views
#endregion #endregion
#region Locate file (Single book only) #region Locate file (Single book only)
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry)
{ {
args.ContextMenuItems.Add(new MenuItem args.ContextMenuItems.Add(new MenuItem
{ {
@ -301,7 +301,7 @@ namespace LibationAvalonia.Views
#endregion #endregion
#region Liberate All (multiple books only) #region Liberate All (multiple books only)
if (entries.OfType<ILibraryBookEntry>().Count() > 1) if (entries.OfType<LibraryBookEntry>().Count() > 1)
{ {
args.ContextMenuItems.Add(new MenuItem args.ContextMenuItems.Add(new MenuItem
{ {
@ -325,7 +325,7 @@ namespace LibationAvalonia.Views
#endregion #endregion
#region Force Re-Download (Single book only) #region Force Re-Download (Single book only)
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry4) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry4)
{ {
args.ContextMenuItems.Add(new MenuItem() args.ContextMenuItems.Add(new MenuItem()
{ {
@ -361,7 +361,7 @@ namespace LibationAvalonia.Views
} }
} }
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry2) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry2)
{ {
args.ContextMenuItems.Add(new MenuItem args.ContextMenuItems.Add(new MenuItem
{ {
@ -391,7 +391,7 @@ namespace LibationAvalonia.Views
#endregion #endregion
#region View Bookmarks/Clips (Single book only) #region View Bookmarks/Clips (Single book only)
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry3 && VisualRoot is Window window) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry3 && VisualRoot is Window window)
{ {
args.ContextMenuItems.Add(new MenuItem args.ContextMenuItems.Add(new MenuItem
{ {
@ -447,7 +447,7 @@ namespace LibationAvalonia.Views
{ {
var itemName = column.SortMemberPath; var itemName = column.SortMemberPath;
if (itemName == nameof(IGridEntry.Remove)) if (itemName == nameof(GridEntry.Remove))
continue; continue;
menuItems.Add menuItems.Add
@ -536,7 +536,7 @@ namespace LibationAvalonia.Views
if (sender is not LiberateStatusButton button) if (sender is not LiberateStatusButton button)
return; return;
if (button.DataContext is ISeriesEntry sEntry && _viewModel is not null) if (button.DataContext is SeriesEntry sEntry && _viewModel is not null)
{ {
await _viewModel.ToggleSeriesExpanded(sEntry); await _viewModel.ToggleSeriesExpanded(sEntry);
@ -544,7 +544,7 @@ namespace LibationAvalonia.Views
//to the topright cell. Reset focus onto the clicked button's cell. //to the topright cell. Reset focus onto the clicked button's cell.
button.Focus(); button.Focus();
} }
else if (button.DataContext is ILibraryBookEntry lbEntry) else if (button.DataContext is LibraryBookEntry lbEntry)
{ {
LiberateClicked?.Invoke(this, [lbEntry.LibraryBook]); LiberateClicked?.Invoke(this, [lbEntry.LibraryBook]);
} }
@ -558,13 +558,13 @@ namespace LibationAvalonia.Views
public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args) public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args)
{ {
if (sender is Control panel && panel.DataContext is ILibraryBookEntry lbe && lbe.LastDownload.IsValid) if (sender is Control panel && panel.DataContext is LibraryBookEntry lbe && lbe.LastDownload.IsValid)
lbe.LastDownload.OpenReleaseUrl(); lbe.LastDownload.OpenReleaseUrl();
} }
public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args)
{ {
if (sender is not Image tblock || tblock.DataContext is not IGridEntry gEntry) if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry)
return; return;
if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible)
@ -605,7 +605,7 @@ namespace LibationAvalonia.Views
public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args) public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args)
{ {
if (sender is Control tblock && tblock.DataContext is IGridEntry gEntry) if (sender is Control tblock && tblock.DataContext is GridEntry gEntry)
{ {
var pt = tblock.PointToScreen(tblock.Bounds.TopRight); var pt = tblock.PointToScreen(tblock.Bounds.TopRight);
var displayWindow = new DescriptionDisplayDialog var displayWindow = new DescriptionDisplayDialog
@ -632,7 +632,7 @@ namespace LibationAvalonia.Views
{ {
var button = args.Source as Button; var button = args.Source as Button;
if (button?.DataContext is ILibraryBookEntry lbEntry) if (button?.DataContext is LibraryBookEntry lbEntry)
{ {
TagsButtonClicked?.Invoke(this, lbEntry.LibraryBook); TagsButtonClicked?.Invoke(this, lbEntry.LibraryBook);
} }

View File

@ -1,6 +1,5 @@
using System; using System;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
using Dinah.Core.Logging; using Dinah.Core.Logging;
using FileManager; using FileManager;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@ -10,7 +9,7 @@ using Serilog.Events;
#nullable enable #nullable enable
namespace LibationFileManager namespace LibationFileManager
{ {
public partial class Configuration public partial class Configuration
{ {
private IConfigurationRoot? configuration; private IConfigurationRoot? configuration;
@ -19,13 +18,14 @@ namespace LibationFileManager
configuration = new ConfigurationBuilder() configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true) .AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build(); .Build();
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration) .ReadFrom.Configuration(configuration)
.Destructure.ByTransforming<LongPath>(lp => lp.Path) .Destructure.ByTransforming<LongPath>(lp => lp.Path)
.CreateLogger(); .Destructure.With<LogFileFilter>()
} .CreateLogger();
}
[Description("The importance of a log event")] [Description("The importance of a log event")]
public LogEventLevel LogLevel public LogEventLevel LogLevel
{ {
get get

View File

@ -0,0 +1,113 @@
using Serilog.Core;
using Serilog.Events;
using Serilog.Sinks.File;
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Text;
#nullable enable
namespace LibationFileManager;
/// <summary>
/// Hooks the file sink to set the log file path for the LogFileFilter.
/// </summary>
public class FileSinkHook : FileLifecycleHooks
{
public override Stream OnFileOpened(string path, Stream underlyingStream, Encoding encoding)
{
LogFileFilter.SetLogFilePath(path);
return base.OnFileOpened(path, underlyingStream, encoding);
}
}
/// <summary>
/// Identify log entries which are to be written to files, and save them to a zip file.
///
/// Files are detected by pattern matching. If the logged type has properties named 'filename' and 'filedata' (case insensitive)
/// with types string and byte[] respectively, the type is destructured and written to the log zip file.
///
/// The zip file's name will be derived from the active log file's name, with "_AdditionalFiles.zip" appended.
/// </summary>
public class LogFileFilter : IDestructuringPolicy
{
private static readonly object lockObj = new();
public static string? ZipFilePath { get; private set; }
public static string? LogFilePath { get; private set; }
public static void SetLogFilePath(string? logFilePath)
{
lock(lockObj)
{
(LogFilePath, ZipFilePath)
= File.Exists(logFilePath) && Path.GetDirectoryName(logFilePath) is string logDir
? (logFilePath, Path.Combine(logDir, $"{Path.GetFileNameWithoutExtension(logFilePath)}_AdditionalFiles.zip"))
: (null, null);
}
}
private static bool TrySaveLogFile(ref string filename, byte[] fileData, CompressionLevel compression)
{
try
{
lock (lockObj)
{
if (string.IsNullOrEmpty(ZipFilePath))
return false;
using var archive = new ZipArchive(File.Open(ZipFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite), ZipArchiveMode.Update, false, Encoding.UTF8);
filename = GetUniqueEntryName(archive, filename);
var entry = archive.CreateEntry(filename, compression);
using var entryStream = entry.Open();
entryStream.Write(fileData);
}
return true;
}
catch
{
return false;
}
}
private static string GetUniqueEntryName(ZipArchive archive, string filename)
{
var entryFileName = filename;
for (int i = 1; archive.Entries.Any(e => e.Name == entryFileName); i++)
{
entryFileName = $"{Path.GetFileNameWithoutExtension(filename)}_({i++}){Path.GetExtension(filename)}";
}
return entryFileName;
}
public bool TryDestructure(object value, ILogEventPropertyValueFactory propertyValueFactory, [NotNullWhen(true)] out LogEventPropertyValue? result)
{
if (value?.GetType().GetProperties() is PropertyInfo[] properties && properties.Length >= 2
&& properties.FirstOrDefault(p => p.Name.Equals("filename", StringComparison.InvariantCultureIgnoreCase)) is PropertyInfo filenameProperty && filenameProperty.PropertyType == typeof(string)
&& properties.FirstOrDefault(p => p.Name.Equals("fileData", StringComparison.InvariantCultureIgnoreCase)) is PropertyInfo fileDataProperty && fileDataProperty.PropertyType == typeof(byte[]))
{
var filename = filenameProperty.GetValue(value) as string;
var fileData = fileDataProperty.GetValue(value) as byte[];
if (filename != null && fileData != null && fileData.Length > 0)
{
var compressionProperty = properties.FirstOrDefault(f => f.PropertyType == typeof(CompressionLevel));
var compression = compressionProperty?.GetValue(value) is CompressionLevel c ? c : CompressionLevel.Fastest;
result
= TrySaveLogFile(ref filename, fileData, compression)
? propertyValueFactory.CreatePropertyValue($"Log file '{filename}' saved in {ZipFilePath}")
: propertyValueFactory.CreatePropertyValue($"Log file '{filename}' could not be saved in {ZipFilePath ?? "<null_path>"}. File Contents = {Convert.ToBase64String(fileData)}");
return true;
}
}
result = null;
return false;
}
}

View File

@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable #nullable enable
@ -78,13 +79,13 @@ namespace LibationFileManager
} }
} }
public static string GetPicturePathSynchronously(PictureDefinition def) public static string GetPicturePathSynchronously(PictureDefinition def, CancellationToken cancellationToken = default)
{ {
GetPictureSynchronously(def); GetPictureSynchronously(def, cancellationToken);
return getPath(def); return getPath(def);
} }
public static byte[] GetPictureSynchronously(PictureDefinition def) public static byte[] GetPictureSynchronously(PictureDefinition def, CancellationToken cancellationToken = default)
{ {
lock (cacheLocker) lock (cacheLocker)
{ {
@ -94,7 +95,7 @@ namespace LibationFileManager
var bytes var bytes
= File.Exists(path) = File.Exists(path)
? File.ReadAllBytes(path) ? File.ReadAllBytes(path)
: downloadBytes(def); : downloadBytes(def, cancellationToken);
cache[def] = bytes; cache[def] = bytes;
} }
return cache[def]; return cache[def];
@ -124,7 +125,7 @@ namespace LibationFileManager
} }
private static HttpClient imageDownloadClient { get; } = new HttpClient(); private static HttpClient imageDownloadClient { get; } = new HttpClient();
private static byte[] downloadBytes(PictureDefinition def) private static byte[] downloadBytes(PictureDefinition def, CancellationToken cancellationToken = default)
{ {
if (def.PictureId is null) if (def.PictureId is null)
return GetDefaultImage(def.Size); return GetDefaultImage(def.Size);
@ -132,7 +133,7 @@ namespace LibationFileManager
try try
{ {
var sizeStr = def.Size == PictureSize.Native ? "" : $"._SL{(int)def.Size}_"; var sizeStr = def.Size == PictureSize.Native ? "" : $"._SL{(int)def.Size}_";
var bytes = imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg").Result; var bytes = imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg", cancellationToken).Result;
// save image file. make sure to not save default image // save image file. make sure to not save default image
var path = getPath(def); var path = getPath(def);

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace LibationFileManager namespace LibationFileManager
@ -10,7 +11,7 @@ namespace LibationFileManager
public static class WindowsDirectory public static class WindowsDirectory
{ {
public static void SetCoverAsFolderIcon(string pictureId, string directory) public static void SetCoverAsFolderIcon(string pictureId, string directory, CancellationToken cancellationToken)
{ {
try try
{ {
@ -19,9 +20,8 @@ namespace LibationFileManager
return; return;
// get path of cover art in Images dir. Download first if not exists // get path of cover art in Images dir. Download first if not exists
var coverArtPath = PictureStorage.GetPicturePathSynchronously(new(pictureId, PictureSize._300x300)); var coverArtPath = PictureStorage.GetPicturePathSynchronously(new(pictureId, PictureSize._300x300), cancellationToken);
InteropFactory.Create().SetFolderIcon(image: coverArtPath, directory: directory);
InteropFactory.Create().SetFolderIcon(image: coverArtPath, directory: directory);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -1,13 +1,35 @@
using LibationFileManager; using LibationFileManager;
using System; using System;
#nullable enable
namespace LibationUiBase namespace LibationUiBase
{ {
public static class BaseUtil public static class BaseUtil
{ {
/// <summary>A delegate that loads image bytes into the the UI framework's image format.</summary> /// <summary>A delegate that loads image bytes into the the UI framework's image format.</summary>
public static Func<byte[], PictureSize, object> LoadImage { get; private set; } public static Func<byte[], PictureSize, object?> LoadImage => s_LoadImage ?? DefaultLoadImageImpl;
public static void SetLoadImageDelegate(Func<byte[], PictureSize, object> tryLoadImage)
=> LoadImage = tryLoadImage; /// <summary>A delegate that loads a named resource into the the UI framework's image format.</summary>
public static Func<string, object?> LoadResourceImage => s_LoadResourceImage ?? DefaultLoadResourceImageImpl;
public static void SetLoadImageDelegate(Func<byte[], PictureSize, object?> tryLoadImage)
=> s_LoadImage = tryLoadImage;
public static void SetLoadResourceImageDelegate(Func<string, object?> tryLoadResourceImage)
=> s_LoadResourceImage = tryLoadResourceImage;
private static Func<byte[], PictureSize, object?>? s_LoadImage;
private static Func<string, object?>? s_LoadResourceImage;
private static object? DefaultLoadImageImpl(byte[] imageBytes, PictureSize size)
{
Serilog.Log.Error("{LoadImage} called without a delegate set. Picture size: {PictureSize}", nameof(LoadImage), size);
return null;
}
private static object? DefaultLoadResourceImageImpl(string resourceName)
{
Serilog.Log.Error("{LoadResourceImage} called without a delegate set. Resource name: {ResourceName}", nameof(LoadResourceImage), resourceName);
return null;
}
} }
} }

View File

@ -1,22 +1,15 @@
using ApplicationServices; using ApplicationServices;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using Dinah.Core.Threading;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
namespace LibationUiBase.GridView namespace LibationUiBase.GridView
{ {
public interface IEntryStatus
{
static abstract EntryStatus Create(LibraryBook libraryBook);
}
//This Class holds all book entry status info to help the grid properly render entries. //This Class holds all book entry status info to help the grid properly render entries.
//The reason this info is in here instead of GridEntry is because all of this info is needed //The reason this info is in here instead of GridEntry is because all of this info is needed
//for the "Liberate" column's display and sorting functions. //for the "Liberate" column's display and sorting functions.
public abstract class EntryStatus : ReactiveObject, IComparable public class EntryStatus : ReactiveObject, IComparable
{ {
public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book); public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book);
public LiberatedStatus BookStatus public LiberatedStatus BookStatus
@ -70,7 +63,7 @@ namespace LibationUiBase.GridView
private readonly bool isAbsent; private readonly bool isAbsent;
private static readonly Dictionary<string, object> iconCache = new(); private static readonly Dictionary<string, object> iconCache = new();
protected EntryStatus(LibraryBook libraryBook) internal EntryStatus(LibraryBook libraryBook)
{ {
Book = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)).Book; Book = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)).Book;
isAbsent = libraryBook.AbsentFromLastScan is true; isAbsent = libraryBook.AbsentFromLastScan is true;
@ -78,9 +71,6 @@ namespace LibationUiBase.GridView
IsSeries = Book.ContentType is ContentType.Parent; IsSeries = Book.ContentType is ContentType.Parent;
} }
internal protected abstract object LoadImage(byte[] picture);
protected abstract object GetResourceImage(string rescName);
/// <summary>Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names.</summary> /// <summary>Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names.</summary>
public void Invalidate(params string[] properties) public void Invalidate(params string[] properties)
{ {
@ -179,7 +169,7 @@ namespace LibationUiBase.GridView
private object GetAndCacheResource(string rescName) private object GetAndCacheResource(string rescName)
{ {
if (!iconCache.ContainsKey(rescName)) if (!iconCache.ContainsKey(rescName))
iconCache[rescName] = GetResourceImage(rescName); iconCache[rescName] = BaseUtil.LoadResourceImage(rescName);
return iconCache[rescName]; return iconCache[rescName];
} }
} }

View File

@ -29,17 +29,17 @@ public class GridContextMenu
public string ViewBookmarksText => $"View {Accelerator}Bookmarks/Clips"; public string ViewBookmarksText => $"View {Accelerator}Bookmarks/Clips";
public string ViewSeriesText => GridEntries[0].Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; public string ViewSeriesText => GridEntries[0].Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
public bool LiberateEpisodesEnabled => GridEntries.OfType<ISeriesEntry>().Any(sEntry => sEntry.Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)); public bool LiberateEpisodesEnabled => GridEntries.OfType<SeriesEntry>().Any(sEntry => sEntry.Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload));
public bool SetDownloadedEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || ge.Liberate.IsSeries); public bool SetDownloadedEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || ge.Liberate.IsSeries);
public bool SetNotDownloadedEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || ge.Liberate.IsSeries); public bool SetNotDownloadedEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || ge.Liberate.IsSeries);
public bool ConvertToMp3Enabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated); public bool ConvertToMp3Enabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated);
public bool ReDownloadEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated); public bool ReDownloadEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated);
private IGridEntry[] GridEntries { get; } private GridEntry[] GridEntries { get; }
public ILibraryBookEntry[] LibraryBookEntries { get; } public LibraryBookEntry[] LibraryBookEntries { get; }
public char Accelerator { get; } public char Accelerator { get; }
public GridContextMenu(IGridEntry[] gridEntries, char accelerator) public GridContextMenu(GridEntry[] gridEntries, char accelerator)
{ {
ArgumentNullException.ThrowIfNull(gridEntries, nameof(gridEntries)); ArgumentNullException.ThrowIfNull(gridEntries, nameof(gridEntries));
ArgumentOutOfRangeException.ThrowIfZero(gridEntries.Length, $"{nameof(gridEntries)}.{nameof(gridEntries.Length)}"); ArgumentOutOfRangeException.ThrowIfZero(gridEntries.Length, $"{nameof(gridEntries)}.{nameof(gridEntries.Length)}");
@ -48,9 +48,9 @@ public class GridContextMenu
Accelerator = accelerator; Accelerator = accelerator;
LibraryBookEntries LibraryBookEntries
= GridEntries = GridEntries
.OfType<ISeriesEntry>() .OfType<SeriesEntry>()
.SelectMany(s => s.Children) .SelectMany(s => s.Children)
.Concat(GridEntries.OfType<ILibraryBookEntry>()) .Concat(GridEntries.OfType<LibraryBookEntry>())
.ToArray(); .ToArray();
} }

View File

@ -1,7 +1,6 @@
using ApplicationServices; using ApplicationServices;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using Dinah.Core.Threading;
using FileLiberator; using FileLiberator;
using LibationFileManager; using LibationFileManager;
using System; using System;
@ -9,7 +8,7 @@ using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace LibationUiBase.GridView namespace LibationUiBase.GridView
@ -22,7 +21,7 @@ namespace LibationUiBase.GridView
} }
/// <summary>The View Model base for the DataGridView</summary> /// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry<TStatus> : ReactiveObject, IGridEntry where TStatus : IEntryStatus public abstract class GridEntry : ReactiveObject
{ {
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId; [Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; } [Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
@ -101,7 +100,7 @@ namespace LibationUiBase.GridView
LibraryBook = libraryBook; LibraryBook = libraryBook;
var expanded = Liberate?.Expanded ?? false; var expanded = Liberate?.Expanded ?? false;
Liberate = TStatus.Create(libraryBook); Liberate = new EntryStatus(libraryBook);
Liberate.Expanded = expanded; Liberate.Expanded = expanded;
Title = Book.TitleWithSubtitle; Title = Book.TitleWithSubtitle;
@ -240,7 +239,7 @@ namespace LibationUiBase.GridView
PictureStorage.PictureCached += PictureStorage_PictureCached; PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired. // Mutable property. Set the field so PropertyChanged isn't fired.
_lazyCover = new Lazy<object>(() => Liberate.LoadImage(picture)); _lazyCover = new Lazy<object>(() => BaseUtil.LoadImage(picture, PictureSize._80x80));
} }
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
@ -254,7 +253,7 @@ namespace LibationUiBase.GridView
// logic validation // logic validation
if (e.Definition.PictureId == Book.PictureId) if (e.Definition.PictureId == Book.PictureId)
{ {
_lazyCover = new Lazy<object>(() => Liberate.LoadImage(e.Picture)); _lazyCover = new Lazy<object>(() => BaseUtil.LoadImage(e.Picture, PictureSize._80x80));
RaisePropertyChanged(nameof(Cover)); RaisePropertyChanged(nameof(Cover));
PictureStorage.PictureCached -= PictureStorage_PictureCached; PictureStorage.PictureCached -= PictureStorage_PictureCached;
} }
@ -311,6 +310,35 @@ namespace LibationUiBase.GridView
#endregion #endregion
/// <summary>
/// Creates <see cref="GridEntry"/> for all non-episode books in an enumeration of <see cref="DataLayer.LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<TEntry>> GetAllProductsAsync<TEntry>(IEnumerable<LibraryBook> libraryBooks, Func<LibraryBook, bool> includeIf, Func<LibraryBook, TEntry> factory)
where TEntry : GridEntry
{
var products = libraryBooks.Where(includeIf).ToArray();
if (products.Length == 0)
return [];
int parallelism = int.Max(1, Environment.ProcessorCount - 1);
(int batchSize, int rem) = int.DivRem(products.Length, parallelism);
if (rem != 0) batchSize++;
var syncContext = SynchronizationContext.Current;
//Asynchronously create a GridEntry for every book in the library
var tasks = products.Chunk(batchSize).Select(batch => Task.Run(() =>
{
SynchronizationContext.SetSynchronizationContext(syncContext);
return batch.Select(factory).OfType<TEntry>().ToArray();
}));
return (await Task.WhenAll(tasks)).SelectMany(a => a).ToList();
}
~GridEntry() ~GridEntry()
{ {
PictureStorage.PictureCached -= PictureStorage_PictureCached; PictureStorage.PictureCached -= PictureStorage_PictureCached;

View File

@ -1,34 +0,0 @@
using DataLayer;
using Dinah.Core.DataBinding;
using System;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
public interface IGridEntry : IMemberComparable, INotifyPropertyChanged
{
EntryStatus Liberate { get; }
float SeriesIndex { get; }
string AudibleProductId { get; }
LibraryBook LibraryBook { get; }
Book Book { get; }
DateTime DateAdded { get; }
bool? Remove { get; set; }
string PurchaseDate { get; }
object Cover { get; }
string Length { get; }
LastDownloadStatus LastDownload { get; }
string Series { get; }
SeriesOrder SeriesOrder { get; }
string Title { get; }
string Authors { get; }
string Narrators { get; }
string Category { get; }
string Misc { get; }
string Description { get; }
Rating ProductRating { get; }
Rating MyRating { get; set; }
string BookTags { get; }
void UpdateLibraryBook(LibraryBook libraryBook);
}
}

View File

@ -1,7 +0,0 @@
namespace LibationUiBase.GridView
{
public interface ILibraryBookEntry : IGridEntry
{
ISeriesEntry Parent { get; }
}
}

View File

@ -1,11 +0,0 @@
using System.Collections.Generic;
namespace LibationUiBase.GridView
{
public interface ISeriesEntry : IGridEntry
{
List<ILibraryBookEntry> Children { get; }
void ChildRemoveUpdate();
void RemoveChild(ILibraryBookEntry libraryBookEntry);
}
}

View File

@ -0,0 +1,43 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry : GridEntry
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public SeriesEntry Parent { get; }
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
Parent?.ChildRemoveUpdate();
RaisePropertyChanged(nameof(Remove));
}
}
public LibraryBookEntry(LibraryBook libraryBook, SeriesEntry parent = null)
{
Parent = parent;
UpdateLibraryBook(libraryBook);
LoadCover();
}
/// <summary>
/// Creates <see cref="LibraryBookEntry{TStatus}"/> for all non-episode books in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="System.Threading.SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<GridEntry>> GetAllProductsAsync(IEnumerable<LibraryBook> libraryBooks)
=> await GetAllProductsAsync(libraryBooks, lb => lb.Book.IsProduct(), lb => new LibraryBookEntry(lb) as GridEntry);
protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
}
}

View File

@ -1,65 +0,0 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
using System.Threading;
using System.Linq;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry<TStatus> : GridEntry<TStatus>, ILibraryBookEntry where TStatus : IEntryStatus
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public ISeriesEntry Parent { get; }
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
Parent?.ChildRemoveUpdate();
RaisePropertyChanged(nameof(Remove));
}
}
public LibraryBookEntry(LibraryBook libraryBook, ISeriesEntry parent = null)
{
Parent = parent;
UpdateLibraryBook(libraryBook);
LoadCover();
}
/// <summary>
/// Creates <see cref="LibraryBookEntry{TStatus}"/> for all non-episode books in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<IGridEntry>> GetAllProductsAsync(IEnumerable<LibraryBook> libraryBooks)
{
var products = libraryBooks.Where(lb => lb.Book.IsProduct()).ToArray();
if (products.Length == 0)
return [];
int parallelism = int.Max(1, Environment.ProcessorCount - 1);
(int batchSize, int rem) = int.DivRem(products.Length, parallelism);
if (rem != 0) batchSize++;
var syncContext = SynchronizationContext.Current;
//Asynchronously create an ILibraryBookEntry for every book in the library
var tasks = products.Chunk(batchSize).Select(batch => Task.Run(() =>
{
SynchronizationContext.SetSynchronizationContext(syncContext);
return batch.Select(lb => new LibraryBookEntry<TStatus>(lb) as IGridEntry);
}));
return (await Task.WhenAll(tasks)).SelectMany(a => a).ToList();
}
protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
}
}

View File

@ -10,19 +10,19 @@ namespace LibationUiBase.GridView
#nullable enable #nullable enable
public static class QueryExtensions public static class QueryExtensions
{ {
public static IEnumerable<ILibraryBookEntry> BookEntries(this IEnumerable<IGridEntry> gridEntries) public static IEnumerable<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<ILibraryBookEntry>(); => gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<ISeriesEntry> SeriesEntries(this IEnumerable<IGridEntry> gridEntries) public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<ISeriesEntry>(); => gridEntries.OfType<SeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : IGridEntry public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<ISeriesEntry> EmptySeries(this IEnumerable<IGridEntry> gridEntries) public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static ISeriesEntry? FindSeriesParent(this IEnumerable<IGridEntry> gridEntries, LibraryBook seriesEpisode) public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
{ {
if (seriesEpisode.Book.SeriesLink is null) return null; if (seriesEpisode.Book.SeriesLink is null) return null;
@ -42,14 +42,14 @@ namespace LibationUiBase.GridView
} }
} }
public static bool SearchSetsDiffer(this HashSet<IGridEntry>? searchSet, HashSet<IGridEntry>? otherSet) public static bool SearchSetsDiffer(this HashSet<GridEntry>? searchSet, HashSet<GridEntry>? otherSet)
=> searchSet is null != otherSet is null || => searchSet is null != otherSet is null ||
(searchSet is not null && (searchSet is not null &&
otherSet is not null && otherSet is not null &&
searchSet.Intersect(otherSet).Count() != searchSet.Count); searchSet.Intersect(otherSet).Count() != searchSet.Count);
[return: NotNullIfNotNull(nameof(searchString))] [return: NotNullIfNotNull(nameof(searchString))]
public static HashSet<IGridEntry>? FilterEntries(this IEnumerable<IGridEntry> entries, string? searchString) public static HashSet<GridEntry>? FilterEntries(this IEnumerable<GridEntry> entries, string? searchString)
{ {
if (string.IsNullOrEmpty(searchString)) if (string.IsNullOrEmpty(searchString))
return null; return null;
@ -59,7 +59,7 @@ namespace LibationUiBase.GridView
var booksFilteredIn = entries.IntersectBy(searchResultSet.Docs.Select(d => d.ProductId), l => l.AudibleProductId); var booksFilteredIn = entries.IntersectBy(searchResultSet.Docs.Select(d => d.ProductId), l => l.AudibleProductId);
//Find all series containing children that match the search criteria //Find all series containing children that match the search criteria
var seriesFilteredIn = booksFilteredIn.OfType<ILibraryBookEntry>().Where(lbe => lbe.Parent is not null).Select(lbe => lbe.Parent).Distinct(); var seriesFilteredIn = booksFilteredIn.OfType<LibraryBookEntry>().Where(lbe => lbe.Parent is not null).Select(lbe => lbe.Parent).Distinct();
return booksFilteredIn.Concat(seriesFilteredIn).ToHashSet(); return booksFilteredIn.Concat(seriesFilteredIn).ToHashSet();
} }

View File

@ -10,16 +10,16 @@ namespace LibationUiBase.GridView
/// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain
/// sorted by series index, ascending. /// sorted by series index, ascending.
/// </summary> /// </summary>
public abstract class RowComparerBase : IComparer, IComparer<IGridEntry>, IComparer<object> public abstract class RowComparerBase : IComparer, IComparer<GridEntry>, IComparer<object>
{ {
public abstract string? PropertyName { get; set; } public abstract string? PropertyName { get; set; }
public int Compare(object? x, object? y) public int Compare(object? x, object? y)
=> Compare(x as IGridEntry, y as IGridEntry); => Compare(x as GridEntry, y as GridEntry);
protected abstract ListSortDirection GetSortOrder(); protected abstract ListSortDirection GetSortOrder();
private int InternalCompare(IGridEntry x, IGridEntry y) private int InternalCompare(GridEntry x, GridEntry y)
{ {
var val1 = x.GetMemberValue(PropertyName); var val1 = x.GetMemberValue(PropertyName);
var val2 = y.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName);
@ -32,7 +32,7 @@ namespace LibationUiBase.GridView
: compare; : compare;
} }
public int Compare(IGridEntry? geA, IGridEntry? geB) public int Compare(GridEntry? geA, GridEntry? geB)
{ {
if (geA is null && geB is not null) return -1; if (geA is null && geB is not null) return -1;
if (geA is not null && geB is null) return 1; if (geA is not null && geB is null) return 1;
@ -40,12 +40,12 @@ namespace LibationUiBase.GridView
var sortDirection = GetSortOrder(); var sortDirection = GetSortOrder();
ISeriesEntry? parentA = null; SeriesEntry? parentA = null;
ISeriesEntry? parentB = null; SeriesEntry? parentB = null;
if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA) if (geA is LibraryBookEntry lbA && lbA.Parent is SeriesEntry seA)
parentA = seA; parentA = seA;
if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB) if (geB is LibraryBookEntry lbB && lbB.Parent is SeriesEntry seB)
parentB = seB; parentB = seB;
//both entries are children //both entries are children
@ -59,7 +59,7 @@ namespace LibationUiBase.GridView
//and DateAdded, compare SeriesOrder instead.. //and DateAdded, compare SeriesOrder instead..
return PropertyName switch return PropertyName switch
{ {
nameof(IGridEntry.DateAdded) or nameof(IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder), nameof(GridEntry.DateAdded) or nameof(GridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder),
_ => InternalCompare(geA, geB), _ => InternalCompare(geA, geB),
}; };
} }

View File

@ -0,0 +1,106 @@
using DataLayer;
using Dinah.Core.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry : GridEntry
{
public List<LibraryBookEntry> Children { get; }
public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove == true);
remove = removeCount == 0 ? false : removeCount == Children.Count ? true : null;
RaisePropertyChanged(nameof(Remove));
}
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
RaisePropertyChanged(nameof(Remove));
}
}
public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent, new[] { child }) { }
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
{
LastDownload = new();
SeriesIndex = -1;
Children = children
.Select(c => new LibraryBookEntry(c, this))
.OrderByDescending(c => c.SeriesOrder)
.ToList<LibraryBookEntry>();
UpdateLibraryBook(parent);
LoadCover();
}
/// <summary>
/// Creates <see cref="SeriesEntry{TStatus}"/> for all episodic series in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<SeriesEntry>> GetAllSeriesEntriesAsync(IEnumerable<LibraryBook> libraryBooks)
{
var seriesEntries = await GetAllProductsAsync(libraryBooks, lb => lb.Book.IsEpisodeParent(), lb => new SeriesEntry(lb, []));
var seriesDict = seriesEntries.ToDictionarySafe(s => s.AudibleProductId);
await GetAllProductsAsync(libraryBooks, lb => lb.Book.IsEpisodeChild(), CreateAndLinkEpisodeEntry);
//sort episodes by series order descending and update SeriesEntry
foreach (var series in seriesEntries)
{
series.Children.Sort((a, b) => -a.SeriesOrder.CompareTo(b.SeriesOrder));
series.UpdateLibraryBook(series.LibraryBook);
}
return seriesEntries.Where(s => s.Children.Count != 0).ToList();
//Create a LibraryBookEntry for an episode and link it to its series parent
LibraryBookEntry CreateAndLinkEpisodeEntry(LibraryBook episode)
{
foreach (var s in episode.Book.SeriesLink)
{
if (seriesDict.TryGetValue(s.Series.AudibleSeriesId, out var seriesParent))
{
var entry = new LibraryBookEntry(episode, seriesParent);
seriesParent.Children.Add(entry);
return entry;
}
}
return null;
}
}
public void RemoveChild(LibraryBookEntry lbe)
{
Children.Remove(lbe);
PurchaseDate = GetPurchaseDateString();
Length = GetBookLengthString();
}
protected override string GetBookTags() => null;
protected override int GetLengthInMinutes() => Children.Count == 0 ? 0 : Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
protected override DateTime GetPurchaseDate() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.DateAdded);
}
}

View File

@ -1,129 +0,0 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry<TStatus> : GridEntry<TStatus>, ISeriesEntry where TStatus : IEntryStatus
{
public List<ILibraryBookEntry> Children { get; }
public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove == true);
remove = removeCount == 0 ? false : removeCount == Children.Count ? true : null;
RaisePropertyChanged(nameof(Remove));
}
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
RaisePropertyChanged(nameof(Remove));
}
}
public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent, new[] { child }) { }
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
{
LastDownload = new();
SeriesIndex = -1;
Children = children
.Select(c => new LibraryBookEntry<TStatus>(c, this))
.OrderByDescending(c => c.SeriesOrder)
.ToList<ILibraryBookEntry>();
UpdateLibraryBook(parent);
LoadCover();
}
/// <summary>
/// Creates <see cref="SeriesEntry{TStatus}"/> for all episodic series in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<ISeriesEntry>> GetAllSeriesEntriesAsync(IEnumerable<LibraryBook> libraryBooks)
{
var seriesBooks = libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).ToArray();
var allEpisodes = libraryBooks.Where(lb => lb.Book.IsEpisodeChild()).ToArray();
var seriesEntries = new ISeriesEntry[seriesBooks.Length];
var seriesEpisodes = new ILibraryBookEntry[seriesBooks.Length][];
var syncContext = SynchronizationContext.Current;
var options = new ParallelOptions { MaxDegreeOfParallelism = int.Max(1, Environment.ProcessorCount - 1) };
//Asynchronously create an ILibraryBookEntry for every episode in the library
await Parallel.ForEachAsync(getAllEpisodes(), options, createEpisodeEntry);
//Match all episode entries to their corresponding parents
for (int i = seriesEntries.Length - 1; i >= 0; i--)
{
var series = seriesEntries[i];
//Sort episodes by series order descending, then add them to their parent's entry
Array.Sort(seriesEpisodes[i], (a, b) => -a.SeriesOrder.CompareTo(b.SeriesOrder));
series.Children.AddRange(seriesEpisodes[i]);
series.UpdateLibraryBook(series.LibraryBook);
}
return seriesEntries.Where(s => s.Children.Count != 0).Cast<ISeriesEntry>().ToList();
//Create a LibraryBookEntry for a single episode
ValueTask createEpisodeEntry((int seriesIndex, int episodeIndex, LibraryBook episode) data, CancellationToken cancellationToken)
{
SynchronizationContext.SetSynchronizationContext(syncContext);
var parent = seriesEntries[data.seriesIndex];
seriesEpisodes[data.seriesIndex][data.episodeIndex] = new LibraryBookEntry<TStatus>(data.episode, parent);
return ValueTask.CompletedTask;
}
//Enumeration all series episodes, along with the index to its seriesEntries entry
//and an index to its seriesEpisodes entry
IEnumerable<(int seriesIndex, int episodeIndex, LibraryBook episode)> getAllEpisodes()
{
for (int i = 0; i < seriesBooks.Length; i++)
{
var series = seriesBooks[i];
var childEpisodes = allEpisodes.FindChildren(series);
SynchronizationContext.SetSynchronizationContext(syncContext);
seriesEntries[i] = new SeriesEntry<TStatus>(series, []);
seriesEpisodes[i] = new ILibraryBookEntry[childEpisodes.Count];
for (int j = 0; j < childEpisodes.Count; j++)
yield return (i, j, childEpisodes[j]);
}
}
}
public void RemoveChild(ILibraryBookEntry lbe)
{
Children.Remove(lbe);
PurchaseDate = GetPurchaseDateString();
Length = GetBookLengthString();
}
protected override string GetBookTags() => null;
protected override int GetLengthInMinutes() => Children.Count == 0 ? 0 : Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
protected override DateTime GetPurchaseDate() => Children.Count == 0 ? default : Children.Min(c => c.LibraryBook.DateAdded);
}
}

View File

@ -1,7 +0,0 @@
namespace LibationUiBase
{
public interface ILogForm
{
void WriteLine(string text);
}
}

View File

@ -1,56 +0,0 @@
using System;
using System.Threading.Tasks;
namespace LibationUiBase
{
// decouple serilog and form. include convenience factory method
public class LogMe
{
public event EventHandler<string> LogInfo;
public event EventHandler<string> LogErrorString;
public event EventHandler<(Exception, string)> LogError;
private LogMe()
{
LogInfo += (_, text) => Serilog.Log.Logger.Information($"Automated backup: {text}");
LogErrorString += (_, text) => Serilog.Log.Logger.Error(text);
LogError += (_, tuple) => Serilog.Log.Logger.Error(tuple.Item1, tuple.Item2 ?? "Automated backup: error");
}
private static ILogForm LogForm;
public static LogMe RegisterForm<T>(T form) where T : ILogForm
{
var logMe = new LogMe();
if (form is null)
return logMe;
LogForm = form;
logMe.LogInfo += LogMe_LogInfo;
logMe.LogErrorString += LogMe_LogErrorString;
logMe.LogError += LogMe_LogError;
return logMe;
}
private static async void LogMe_LogError(object sender, (Exception, string) tuple)
{
await Task.Run(() => LogForm?.WriteLine(tuple.Item2 ?? "Automated backup: error"));
await Task.Run(() => LogForm?.WriteLine("ERROR: " + tuple.Item1.Message));
}
private static async void LogMe_LogErrorString(object sender, string text)
{
await Task.Run(() => LogForm?.WriteLine(text));
}
private static async void LogMe_LogInfo(object sender, string text)
{
await Task.Run(() => LogForm?.WriteLine(text));
}
public void Info(string text) => LogInfo?.Invoke(this, text);
public void Error(string text) => LogErrorString?.Invoke(this, text);
public void Error(Exception ex, string text = null) => LogError?.Invoke(this, (ex, text));
}
}

View File

@ -1,4 +1,4 @@
using ApplicationServices; using ApplicationServices;
using AudibleApi; using AudibleApi;
using AudibleApi.Common; using AudibleApi.Common;
using DataLayer; using DataLayer;
@ -40,9 +40,8 @@ public enum ProcessBookStatus
/// <summary> /// <summary>
/// This is the viewmodel for queued processables /// This is the viewmodel for queued processables
/// </summary> /// </summary>
public abstract class ProcessBookViewModelBase : ReactiveObject public class ProcessBookViewModel : ReactiveObject
{ {
private readonly LogMe Logger;
public LibraryBook LibraryBook { get; protected set; } public LibraryBook LibraryBook { get; protected set; }
private ProcessBookResult _result = ProcessBookResult.None; private ProcessBookResult _result = ProcessBookResult.None;
@ -84,6 +83,21 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
#endregion #endregion
#region Process Queue Logging
public event EventHandler<string>? LogWritten;
private void OnLogWritten(string text) => LogWritten?.Invoke(this, text.Trim());
private void LogError(string? message, Exception? ex = null)
{
OnLogWritten(message ?? "Automated backup: error");
if (ex is not null)
OnLogWritten("ERROR: " + ex.Message);
}
private void LogInfo(string text) => OnLogWritten(text);
#endregion
protected Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke(); protected Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke();
protected void NextProcessable() => _currentProcessable = null; protected void NextProcessable() => _currentProcessable = null;
private Processable? _currentProcessable; private Processable? _currentProcessable;
@ -91,10 +105,9 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
/// <summary> A series of Processable actions to perform on this book </summary> /// <summary> A series of Processable actions to perform on this book </summary>
protected Queue<Func<Processable>> Processes { get; } = new(); protected Queue<Func<Processable>> Processes { get; } = new();
protected ProcessBookViewModelBase(LibraryBook libraryBook, LogMe logme) public ProcessBookViewModel(LibraryBook libraryBook)
{ {
LibraryBook = libraryBook; LibraryBook = libraryBook;
Logger = logme;
_title = LibraryBook.Book.TitleWithSubtitle; _title = LibraryBook.Book.TitleWithSubtitle;
_author = LibraryBook.Book.AuthorNames(); _author = LibraryBook.Book.AuthorNames();
@ -106,15 +119,14 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
PictureStorage.PictureCached += PictureStorage_PictureCached; PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired. // Mutable property. Set the field so PropertyChanged isn't fired.
_cover = LoadImageFromBytes(picture, PictureSize._80x80); _cover = BaseUtil.LoadImage(picture, PictureSize._80x80);
} }
protected abstract object? LoadImageFromBytes(byte[] bytes, PictureSize pictureSize);
private void PictureStorage_PictureCached(object? sender, PictureCachedEventArgs e) private void PictureStorage_PictureCached(object? sender, PictureCachedEventArgs e)
{ {
if (e.Definition.PictureId == LibraryBook.Book.PictureId) if (e.Definition.PictureId == LibraryBook.Book.PictureId)
{ {
Cover = LoadImageFromBytes(e.Picture, PictureSize._80x80); Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80);
PictureStorage.PictureCached -= PictureStorage_PictureCached; PictureStorage.PictureCached -= PictureStorage_PictureCached;
} }
} }
@ -133,36 +145,36 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
result = ProcessBookResult.Success; result = ProcessBookResult.Success;
else if (statusHandler.Errors.Contains("Cancelled")) else if (statusHandler.Errors.Contains("Cancelled"))
{ {
Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}"); LogInfo($"{procName}: Process was cancelled - {LibraryBook.Book}");
result = ProcessBookResult.Cancelled; result = ProcessBookResult.Cancelled;
} }
else if (statusHandler.Errors.Contains("Validation failed")) else if (statusHandler.Errors.Contains("Validation failed"))
{ {
Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}"); LogInfo($"{procName}: Validation failed - {LibraryBook.Book}");
result = ProcessBookResult.ValidationFail; result = ProcessBookResult.ValidationFail;
} }
else else
{ {
foreach (var errorMessage in statusHandler.Errors) foreach (var errorMessage in statusHandler.Errors)
Logger.Error($"{procName}: {errorMessage}"); LogError($"{procName}: {errorMessage}");
} }
} }
catch (ContentLicenseDeniedException ldex) catch (ContentLicenseDeniedException ldex)
{ {
if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError) if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
{ {
Logger.Info($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}"); LogInfo($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDeniedPossibleOutage; result = ProcessBookResult.LicenseDeniedPossibleOutage;
} }
else else
{ {
Logger.Info($"{procName}: Content license denied. Check your Audible account to see if you have access to this title. - {LibraryBook.Book}"); LogInfo($"{procName}: Content license denied. Check your Audible account to see if you have access to this title. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDenied; result = ProcessBookResult.LicenseDenied;
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex, procName); LogError(procName, ex);
} }
finally finally
{ {
@ -192,15 +204,15 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
} }
catch (Exception ex) catch (Exception ex)
{ {
Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling"); LogError($"{CurrentProcessable.Name}: Error while cancelling", ex);
} }
} }
public ProcessBookViewModelBase AddDownloadPdf() => AddProcessable<DownloadPdf>(); public ProcessBookViewModel AddDownloadPdf() => AddProcessable<DownloadPdf>();
public ProcessBookViewModelBase AddDownloadDecryptBook() => AddProcessable<DownloadDecryptBook>(); public ProcessBookViewModel AddDownloadDecryptBook() => AddProcessable<DownloadDecryptBook>();
public ProcessBookViewModelBase AddConvertToMp3() => AddProcessable<ConvertToMp3>(); public ProcessBookViewModel AddConvertToMp3() => AddProcessable<ConvertToMp3>();
private ProcessBookViewModelBase AddProcessable<T>() where T : Processable, new() private ProcessBookViewModel AddProcessable<T>() where T : Processable, new()
{ {
Processes.Enqueue(() => new T()); Processes.Enqueue(() => new T());
return this; return this;
@ -252,7 +264,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors; private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors;
private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators; private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators;
private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt) private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt)
=> Cover = LoadImageFromBytes(coverArt, PictureSize._80x80); => Cover = BaseUtil.LoadImage(coverArt, PictureSize._80x80);
private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e) private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e)
{ {
@ -292,7 +304,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
Status = ProcessBookStatus.Working; Status = ProcessBookStatus.Working;
if (sender is Processable processable) if (sender is Processable processable)
Logger.Info($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}"); LogInfo($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}");
Title = libraryBook.Book.TitleWithSubtitle; Title = libraryBook.Book.TitleWithSubtitle;
Author = libraryBook.Book.AuthorNames(); Author = libraryBook.Book.AuthorNames();
@ -303,7 +315,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
{ {
if (sender is Processable processable) if (sender is Processable processable)
{ {
Logger.Info($"{processable.Name} Step, Completed: {libraryBook.Book}"); LogInfo($"{processable.Name} Step, Completed: {libraryBook.Book}");
UnlinkProcessable(processable); UnlinkProcessable(processable);
} }
@ -329,7 +341,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
if (result.HasErrors) if (result.HasErrors)
{ {
foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed")) foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed"))
Logger.Error(errorMessage); LogError(errorMessage);
} }
} }
@ -340,7 +352,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
protected async Task<ProcessBookResult> GetFailureActionAsync(LibraryBook libraryBook) protected async Task<ProcessBookResult> GetFailureActionAsync(LibraryBook libraryBook)
{ {
const DialogResult SkipResult = DialogResult.Ignore; const DialogResult SkipResult = DialogResult.Ignore;
Logger.Error($"ERROR. All books have not been processed. Book failed: {libraryBook.Book}"); LogError($"ERROR. All books have not been processed. Book failed: {libraryBook.Book}");
DialogResult? dialogResult = Configuration.Instance.BadBook switch DialogResult? dialogResult = Configuration.Instance.BadBook switch
{ {
@ -353,7 +365,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
if (dialogResult == SkipResult) if (dialogResult == SkipResult)
{ {
libraryBook.UpdateBookStatus(LiberatedStatus.Error); libraryBook.UpdateBookStatus(LiberatedStatus.Error);
Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}"); LogInfo($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}");
} }
return dialogResult is SkipResult ? ProcessBookResult.FailedSkip return dialogResult is SkipResult ? ProcessBookResult.FailedSkip
@ -411,4 +423,4 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
} }
#endregion #endregion
} }

View File

@ -1,30 +1,32 @@
using DataLayer; using ApplicationServices;
using DataLayer;
using LibationUiBase.Forms; using LibationUiBase.Forms;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using ApplicationServices;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable #nullable enable
namespace LibationUiBase.ProcessQueue; namespace LibationUiBase.ProcessQueue;
public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm public record LogEntry(DateTime LogDate, string LogMessage)
{ {
public abstract void WriteLine(string text); public string LogDateString => LogDate.ToShortTimeString();
protected abstract ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook); }
public TrackedQueue<ProcessBookViewModelBase> Queue { get; } public class ProcessQueueViewModel : ReactiveObject
{
public ObservableCollection<LogEntry> LogEntries { get; } = new();
public TrackedQueue<ProcessBookViewModel> Queue { get; } = new();
public Task? QueueRunner { get; private set; } public Task? QueueRunner { get; private set; }
public bool Running => !QueueRunner?.IsCompleted ?? false; public bool Running => !QueueRunner?.IsCompleted ?? false;
protected LogMe Logger { get; }
public ProcessQueueViewModelBase(ICollection<ProcessBookViewModelBase>? underlyingList) public ProcessQueueViewModel()
{ {
Logger = LogMe.RegisterForm(this);
Queue = new(underlyingList);
Queue.QueuedCountChanged += Queue_QueuedCountChanged; Queue.QueuedCountChanged += Queue_QueuedCountChanged;
Queue.CompletedCountChanged += Queue_CompletedCountChanged; Queue.CompletedCountChanged += Queue_CompletedCountChanged;
SpeedLimit = LibationFileManager.Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
} }
private int _completedCount; private int _completedCount;
@ -32,6 +34,7 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
private int _queuedCount; private int _queuedCount;
private string? _runningTime; private string? _runningTime;
private bool _progressBarVisible; private bool _progressBarVisible;
private decimal _speedLimit;
public int CompletedCount { get => _completedCount; private set { RaiseAndSetIfChanged(ref _completedCount, value); RaisePropertyChanged(nameof(AnyCompleted)); } } public int CompletedCount { get => _completedCount; private set { RaiseAndSetIfChanged(ref _completedCount, value); RaisePropertyChanged(nameof(AnyCompleted)); } }
public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); RaisePropertyChanged(nameof(AnyQueued)); } } public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); RaisePropertyChanged(nameof(AnyQueued)); } }
@ -42,6 +45,32 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
public bool AnyQueued => QueuedCount > 0; public bool AnyQueued => QueuedCount > 0;
public bool AnyErrors => ErrorCount > 0; public bool AnyErrors => ErrorCount > 0;
public double Progress => 100d * Queue.Completed.Count / Queue.Count; public double Progress => 100d * Queue.Completed.Count / Queue.Count;
public decimal SpeedLimitIncrement { get; private set; }
public decimal SpeedLimit
{
get => _speedLimit;
set
{
var newValue = Math.Min(999 * 1024 * 1024, (long)Math.Ceiling(value * 1024 * 1024));
var config = LibationFileManager.Configuration.Instance;
config.DownloadSpeedLimit = newValue;
_speedLimit
= config.DownloadSpeedLimit <= newValue ? value
: value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024
: 0;
config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024);
SpeedLimitIncrement = _speedLimit > 100 ? 10
: _speedLimit > 10 ? 1
: _speedLimit > 1 ? 0.1m
: 0.01m;
RaisePropertyChanged(nameof(SpeedLimitIncrement));
RaisePropertyChanged(nameof(SpeedLimit));
}
}
private void Queue_CompletedCountChanged(object? sender, int e) private void Queue_CompletedCountChanged(object? sender, int e)
{ {
@ -59,6 +88,9 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
RaisePropertyChanged(nameof(Progress)); RaisePropertyChanged(nameof(Progress));
} }
private void ProcessBook_LogWritten(object? sender, string logMessage)
=> Invoke(() => LogEntries.Add(new(DateTime.Now, logMessage.Trim())));
#region Add Books to Queue #region Add Books to Queue
public bool QueueDownloadPdf(IList<LibraryBook> libraryBooks) public bool QueueDownloadPdf(IList<LibraryBook> libraryBooks)
@ -124,47 +156,50 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
} }
private bool IsBookInQueue(LibraryBook libraryBook) private bool IsBookInQueue(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is not ProcessBookViewModelBase entry ? false => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is not ProcessBookViewModel entry ? false
: entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed ? !Queue.RemoveCompleted(entry) : entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed ? !Queue.RemoveCompleted(entry)
: true; : true;
private bool RemoveCompleted(LibraryBook libraryBook) private bool RemoveCompleted(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModelBase entry => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModel entry
&& entry.Status is ProcessBookStatus.Completed && entry.Status is ProcessBookStatus.Completed
&& Queue.RemoveCompleted(entry); && Queue.RemoveCompleted(entry);
private void AddDownloadPdf(IEnumerable<LibraryBook> entries) private void AddDownloadPdf(IList<LibraryBook> entries)
{ {
var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray(); var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray();
Serilog.Log.Logger.Information("Queueing {count} books for PDF-only download", procs.Length); Serilog.Log.Logger.Information("Queueing {count} books for PDF-only download", procs.Length);
AddToQueue(procs); AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry) ProcessBookViewModel Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddDownloadPdf(); => new ProcessBookViewModel(entry).AddDownloadPdf();
} }
private void AddDownloadDecrypt(IEnumerable<LibraryBook> entries) private void AddDownloadDecrypt(IList<LibraryBook> entries)
{ {
var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray(); var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray();
Serilog.Log.Logger.Information("Queueing {count} books ofr download/decrypt", procs.Length); Serilog.Log.Logger.Information("Queueing {count} books ofr download/decrypt", procs.Length);
AddToQueue(procs); AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry) ProcessBookViewModel Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddDownloadDecryptBook().AddDownloadPdf(); => new ProcessBookViewModel(entry).AddDownloadDecryptBook().AddDownloadPdf();
} }
private void AddConvertMp3(IEnumerable<LibraryBook> entries) private void AddConvertMp3(IList<LibraryBook> entries)
{ {
var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray(); var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray();
Serilog.Log.Logger.Information("Queueing {count} books for mp3 conversion", procs.Length); Serilog.Log.Logger.Information("Queueing {count} books for mp3 conversion", procs.Length);
AddToQueue(procs); AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry) ProcessBookViewModel Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddConvertToMp3(); => new ProcessBookViewModel(entry).AddConvertToMp3();
} }
private void AddToQueue(IEnumerable<ProcessBookViewModelBase> pbook) private void AddToQueue(IList<ProcessBookViewModel> pbook)
{ {
foreach (var book in pbook)
book.LogWritten += ProcessBook_LogWritten;
Queue.Enqueue(pbook); Queue.Enqueue(pbook);
if (!Running) if (!Running)
QueueRunner = Task.Run(QueueLoop); QueueRunner = Task.Run(QueueLoop);
@ -187,7 +222,7 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
while (Queue.MoveNext()) while (Queue.MoveNext())
{ {
if (Queue.Current is not ProcessBookViewModelBase nextBook) if (Queue.Current is not ProcessBookViewModel nextBook)
{ {
Serilog.Log.Logger.Information("Current queue item is empty."); Serilog.Log.Logger.Information("Current queue item is empty.");
continue; continue;

View File

@ -1,5 +1,7 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq; using System.Linq;
#nullable enable #nullable enable
@ -7,7 +9,7 @@ namespace LibationUiBase
{ {
public enum QueuePosition public enum QueuePosition
{ {
Fisrt, First,
OneUp, OneUp,
OneDown, OneDown,
Last, Last,
@ -22,38 +24,21 @@ namespace LibationUiBase
* 3) the pile of chain at your feet grows by 1 link (Completed) * 3) the pile of chain at your feet grows by 1 link (Completed)
* *
* The index is the link position from the first link you lifted to the * The index is the link position from the first link you lifted to the
* last one in the chain. * last one in the chain.
*
*
* For this to work with Avalonia's ItemsRepeater, it must be an ObservableCollection
* (not merely a Collection with INotifyCollectionChanged, INotifyPropertyChanged).
* So TrackedQueue maintains 2 copies of the list. The primary copy of the list is
* split into Completed, Current and Queued and is used by ProcessQueue to keep track
* of what's what. The secondary copy is a concatenation of primary's three sources
* and is stored in ObservableCollection.Items. When the primary list changes, the
* secondary list is cleared and reset to match the primary.
*/ */
public class TrackedQueue<T> where T : class public class TrackedQueue<T> : IReadOnlyCollection<T>, IList, INotifyCollectionChanged where T : class
{ {
public event EventHandler<int>? CompletedCountChanged; public event EventHandler<int>? CompletedCountChanged;
public event EventHandler<int>? QueuedCountChanged; public event EventHandler<int>? QueuedCountChanged;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public T? Current { get; private set; } public T? Current { get; private set; }
public IReadOnlyList<T> Queued => _queued;
public IReadOnlyList<T> Completed => _completed; public IReadOnlyList<T> Completed => _completed;
private List<T> Queued { get; } = new();
private readonly List<T> _queued = new();
private readonly List<T> _completed = new(); private readonly List<T> _completed = new();
private readonly object lockObject = new(); private readonly object lockObject = new();
private int QueueStartIndex => Completed.Count + (Current is null ? 0 : 1);
private readonly ICollection<T>? _underlyingList;
public ICollection<T>? UnderlyingList => _underlyingList;
public TrackedQueue(ICollection<T>? underlyingList = null)
{
_underlyingList = underlyingList;
}
public T this[int index] public T this[int index]
{ {
@ -61,17 +46,10 @@ namespace LibationUiBase
{ {
lock (lockObject) lock (lockObject)
{ {
if (index < _completed.Count) return index < Completed.Count ? Completed[index]
return _completed[index]; : index == Completed.Count && Current is not null ? Current
index -= _completed.Count; : index < Count ? Queued[index - QueueStartIndex]
: throw new IndexOutOfRangeException();
if (index == 0 && Current != null) return Current;
if (Current != null) index--;
if (index < _queued.Count) return _queued.ElementAt(index);
throw new IndexOutOfRangeException();
} }
} }
} }
@ -82,7 +60,7 @@ namespace LibationUiBase
{ {
lock (lockObject) lock (lockObject)
{ {
return _queued.Count + _completed.Count + (Current == null ? 0 : 1); return QueueStartIndex + Queued.Count;
} }
} }
} }
@ -91,131 +69,117 @@ namespace LibationUiBase
{ {
lock (lockObject) lock (lockObject)
{ {
if (_completed.Contains(item)) int index = _completed.IndexOf(item);
return _completed.IndexOf(item); if (index < 0 && item == Current)
index = Completed.Count;
if (Current == item) return _completed.Count; if (index < 0)
{
if (_queued.Contains(item)) index = Queued.IndexOf(item);
return _queued.IndexOf(item) + (Current is null ? 0 : 1); if (index >= 0)
return -1; index += QueueStartIndex;
}
return index;
} }
} }
public bool RemoveQueued(T item) public bool RemoveQueued(T item)
{ {
bool itemsRemoved; int queuedCount, queueIndex;
int queuedCount;
lock (lockObject) lock (lockObject)
{ {
itemsRemoved = _queued.Remove(item); queueIndex = Queued.IndexOf(item);
queuedCount = _queued.Count; if (queueIndex >= 0)
Queued.RemoveAt(queueIndex);
queuedCount = Queued.Count;
} }
if (itemsRemoved) if (queueIndex >= 0)
{ {
QueuedCountChanged?.Invoke(this, queuedCount); QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary(); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, QueueStartIndex + queueIndex));
return true;
} }
return itemsRemoved; return false;
}
public void ClearCurrent()
{
lock (lockObject)
Current = null;
RebuildSecondary();
} }
public bool RemoveCompleted(T item) public bool RemoveCompleted(T item)
{ {
bool itemsRemoved; int completedCount, completedIndex;
int completedCount;
lock (lockObject) lock (lockObject)
{ {
itemsRemoved = _completed.Remove(item); completedIndex = _completed.IndexOf(item);
if (completedIndex >= 0)
_completed.RemoveAt(completedIndex);
completedCount = _completed.Count; completedCount = _completed.Count;
} }
if (itemsRemoved) if (completedIndex >= 0)
{ {
CompletedCountChanged?.Invoke(this, completedCount); CompletedCountChanged?.Invoke(this, completedCount);
RebuildSecondary(); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, completedIndex));
return true;
} }
return itemsRemoved; return false;
}
public void ClearCurrent()
{
T? current;
lock (lockObject)
{
current = Current;
Current = null;
}
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, current, _completed.Count));
} }
public void ClearQueue() public void ClearQueue()
{ {
List<T> queuedItems;
lock (lockObject) lock (lockObject)
_queued.Clear(); {
queuedItems = Queued.ToList();
Queued.Clear();
}
QueuedCountChanged?.Invoke(this, 0); QueuedCountChanged?.Invoke(this, 0);
RebuildSecondary(); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, queuedItems, QueueStartIndex));
} }
public void ClearCompleted() public void ClearCompleted()
{ {
List<T> completedItems;
lock (lockObject) lock (lockObject)
{
completedItems = _completed.ToList();
_completed.Clear(); _completed.Clear();
}
CompletedCountChanged?.Invoke(this, 0); CompletedCountChanged?.Invoke(this, 0);
RebuildSecondary(); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, completedItems, 0));
}
public bool Any(Func<T, bool> predicate)
{
lock (lockObject)
{
return (Current != null && predicate(Current)) || _completed.Any(predicate) || _queued.Any(predicate);
}
}
public T? FirstOrDefault(Func<T, bool> predicate)
{
lock (lockObject)
{
return Current != null && predicate(Current) ? Current
: _completed.FirstOrDefault(predicate) is T completed ? completed
: _queued.FirstOrDefault(predicate);
}
} }
public void MoveQueuePosition(T item, QueuePosition requestedPosition) public void MoveQueuePosition(T item, QueuePosition requestedPosition)
{ {
int oldIndex, newIndex;
lock (lockObject) lock (lockObject)
{ {
if (_queued.Count == 0 || !_queued.Contains(item)) return; oldIndex = Queued.IndexOf(item);
newIndex = requestedPosition switch
{
QueuePosition.First => 0,
QueuePosition.OneUp => oldIndex - 1,
QueuePosition.OneDown => oldIndex + 1,
QueuePosition.Last or _ => Queued.Count - 1
};
if ((requestedPosition == QueuePosition.Fisrt || requestedPosition == QueuePosition.OneUp) && _queued[0] == item) if (oldIndex < 0 || newIndex < 0 || newIndex >= Queued.Count || newIndex == oldIndex)
return;
if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item)
return; return;
int queueIndex = _queued.IndexOf(item); Queued.RemoveAt(oldIndex);
Queued.Insert(newIndex, item);
if (requestedPosition == QueuePosition.OneUp)
{
_queued.RemoveAt(queueIndex);
_queued.Insert(queueIndex - 1, item);
}
else if (requestedPosition == QueuePosition.OneDown)
{
_queued.RemoveAt(queueIndex);
_queued.Insert(queueIndex + 1, item);
}
else if (requestedPosition == QueuePosition.Fisrt)
{
_queued.RemoveAt(queueIndex);
_queued.Insert(0, item);
}
else
{
_queued.RemoveAt(queueIndex);
_queued.Insert(_queued.Count, item);
}
} }
RebuildSecondary(); CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, item, QueueStartIndex + newIndex, QueueStartIndex + oldIndex));
} }
public bool MoveNext() public bool MoveNext()
@ -232,15 +196,15 @@ namespace LibationUiBase
completedCount = _completed.Count; completedCount = _completed.Count;
completedChanged = true; completedChanged = true;
} }
if (_queued.Count == 0) if (Queued.Count == 0)
{ {
Current = null; Current = null;
return false; return false;
} }
Current = _queued[0]; Current = Queued[0];
_queued.RemoveAt(0); Queued.RemoveAt(0);
queuedCount = _queued.Count; queuedCount = Queued.Count;
return true; return true;
} }
} }
@ -249,34 +213,48 @@ namespace LibationUiBase
if (completedChanged) if (completedChanged)
CompletedCountChanged?.Invoke(this, completedCount); CompletedCountChanged?.Invoke(this, completedCount);
QueuedCountChanged?.Invoke(this, queuedCount); QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary();
} }
} }
public void Enqueue(IEnumerable<T> item) public void Enqueue(IList<T> item)
{ {
int queueCount; int queueCount;
lock (lockObject) lock (lockObject)
{ {
_queued.AddRange(item); Queued.AddRange(item);
queueCount = _queued.Count; queueCount = Queued.Count;
} }
foreach (var i in item)
_underlyingList?.Add(i);
QueuedCountChanged?.Invoke(this, queueCount); QueuedCountChanged?.Invoke(this, queueCount);
} CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, QueueStartIndex + Queued.Count));
private void RebuildSecondary()
{
_underlyingList?.Clear();
foreach (var item in GetAllItems())
_underlyingList?.Add(item);
} }
public IEnumerable<T> GetAllItems() public IEnumerable<T> GetAllItems()
{ {
if (Current is null) return Completed.Concat(Queued); lock (lockObject)
return Completed.Concat(new List<T> { Current }).Concat(Queued); {
if (Current is null) return Completed.Concat(Queued);
return Completed.Concat([Current]).Concat(Queued);
}
} }
public IEnumerator<T> GetEnumerator() => GetAllItems().GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
#region IList interface implementation
object? IList.this[int index] { get => this[index]; set => throw new NotSupportedException(); }
public bool IsReadOnly => true;
public bool IsFixedSize => false;
public bool IsSynchronized => false;
public object SyncRoot => this;
public int IndexOf(object? value) => value is T t ? IndexOf(t) : -1;
public bool Contains(object? value) => IndexOf(value) >= 0;
//These aren't used by anything, but they are IList interface members and this class needs to be an IList for Avalonia
public int Add(object? value) => throw new NotSupportedException();
public void Clear() => throw new NotSupportedException();
public void Insert(int index, object? value) => throw new NotSupportedException();
public void Remove(object? value) => throw new NotSupportedException();
public void RemoveAt(int index) => throw new NotSupportedException();
public void CopyTo(Array array, int index) => throw new NotSupportedException();
#endregion
} }
} }

View File

@ -5,18 +5,19 @@ namespace LibationWinForms.Dialogs.Login
{ {
public abstract class WinformLoginBase public abstract class WinformLoginBase
{ {
private readonly IWin32Window _owner; protected Control Owner { get; }
protected WinformLoginBase(IWin32Window owner) protected WinformLoginBase(Control owner)
{ {
_owner = owner; Owner = owner;
} }
/// <returns>True if ShowDialog's DialogResult == OK</returns> /// <returns>True if ShowDialog's DialogResult == OK</returns>
protected bool ShowDialog(Form dialog) protected bool ShowDialog(Form dialog)
{ => Owner.Invoke(() =>
var result = dialog.ShowDialog(_owner); {
Serilog.Log.Logger.Debug("{@DebugInfo}", new { DialogResult = result }); var result = dialog.ShowDialog(Owner);
return result == DialogResult.OK; Serilog.Log.Logger.Debug("{@DebugInfo}", new { DialogResult = result });
} return result == DialogResult.OK;
});
} }
} }

View File

@ -13,48 +13,53 @@ namespace LibationWinForms.Login
public string DeviceName { get; } = "Libation"; public string DeviceName { get; } = "Libation";
public WinformLoginCallback(Account account, IWin32Window owner) : base(owner) public WinformLoginCallback(Account account, Control owner) : base(owner)
{ {
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account)); _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
} }
public Task<string> Get2faCodeAsync(string prompt) public Task<string> Get2faCodeAsync(string prompt)
{ => Owner.Invoke(() =>
using var dialog = new _2faCodeDialog(prompt); {
if (ShowDialog(dialog)) using var dialog = new _2faCodeDialog(prompt);
return Task.FromResult(dialog.Code); if (ShowDialog(dialog))
return Task.FromResult<string>(null); return Task.FromResult(dialog.Code);
} return Task.FromResult<string>(null);
});
public Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage) public Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
{ => Owner.Invoke(() =>
using var dialog = new CaptchaDialog(password, captchaImage); {
if (ShowDialog(dialog)) using var dialog = new CaptchaDialog(password, captchaImage);
return Task.FromResult((dialog.Password, dialog.Answer)); if (ShowDialog(dialog))
return Task.FromResult<(string, string)>((null,null)); return Task.FromResult((dialog.Password, dialog.Answer));
} return Task.FromResult<(string, string)>((null, null));
});
public Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig) public Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
{ => Owner.Invoke(() =>
using var dialog = new MfaDialog(mfaConfig); {
if (ShowDialog(dialog)) using var dialog = new MfaDialog(mfaConfig);
return Task.FromResult((dialog.SelectedName, dialog.SelectedValue)); if (ShowDialog(dialog))
return Task.FromResult<(string, string)>((null, null)); return Task.FromResult((dialog.SelectedName, dialog.SelectedValue));
} return Task.FromResult<(string, string)>((null, null));
});
public Task<(string email, string password)> GetLoginAsync() public Task<(string email, string password)> GetLoginAsync()
{ => Owner.Invoke(() =>
using var dialog = new LoginCallbackDialog(_account); {
if (ShowDialog(dialog)) using var dialog = new LoginCallbackDialog(_account);
return Task.FromResult((dialog.Email, dialog.Password)); if (ShowDialog(dialog))
return Task.FromResult<(string, string)>((null, null)); return Task.FromResult((dialog.Email, dialog.Password));
} return Task.FromResult<(string, string)>((null, null));
});
public Task ShowApprovalNeededAsync() public Task ShowApprovalNeededAsync()
{ => Owner.Invoke(() =>
using var dialog = new ApprovalNeededDialog(); {
ShowDialog(dialog); using var dialog = new ApprovalNeededDialog();
return Task.CompletedTask; ShowDialog(dialog);
} return Task.CompletedTask;
});
} }
} }

View File

@ -13,13 +13,16 @@ namespace LibationWinForms.Login
private Account _account { get; } private Account _account { get; }
public WinformLoginChoiceEager(Account account, IWin32Window owner) : base(owner) public WinformLoginChoiceEager(Account account, Control owner) : base(owner)
{ {
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account)); _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
LoginCallback = new WinformLoginCallback(_account, owner); LoginCallback = new WinformLoginCallback(_account, owner);
} }
public Task<ChoiceOut> StartAsync(ChoiceIn choiceIn) public Task<ChoiceOut> StartAsync(ChoiceIn choiceIn)
=> Owner.Invoke(() => StartAsyncInternal(choiceIn));
private Task<ChoiceOut> StartAsyncInternal(ChoiceIn choiceIn)
{ {
if (Environment.OSVersion.Version.Major >= 10) if (Environment.OSVersion.Version.Major >= 10)
{ {

View File

@ -11,7 +11,13 @@ namespace LibationWinForms.Dialogs
{ {
public partial class SettingsDialog public partial class SettingsDialog
{ {
private void logsBtn_Click(object sender, EventArgs e) => Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName); private void logsBtn_Click(object sender, EventArgs e)
{
if (File.Exists(LogFileFilter.LogFilePath))
Go.To.File(LogFileFilter.LogFilePath);
else
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
}
private void Load_Important(Configuration config) private void Load_Important(Configuration config)
{ {

View File

@ -538,7 +538,7 @@
this.productsDisplay.VisibleCountChanged += new System.EventHandler<int>(this.productsDisplay_VisibleCountChanged); this.productsDisplay.VisibleCountChanged += new System.EventHandler<int>(this.productsDisplay_VisibleCountChanged);
this.productsDisplay.RemovableCountChanged += new System.EventHandler<int>(this.productsDisplay_RemovableCountChanged); this.productsDisplay.RemovableCountChanged += new System.EventHandler<int>(this.productsDisplay_RemovableCountChanged);
this.productsDisplay.LiberateClicked += ProductsDisplay_LiberateClicked; this.productsDisplay.LiberateClicked += ProductsDisplay_LiberateClicked;
this.productsDisplay.LiberateSeriesClicked += new System.EventHandler<LibationUiBase.GridView.ISeriesEntry>(this.ProductsDisplay_LiberateSeriesClicked); this.productsDisplay.LiberateSeriesClicked += new System.EventHandler<LibationUiBase.GridView.SeriesEntry>(this.ProductsDisplay_LiberateSeriesClicked);
this.productsDisplay.ConvertToMp3Clicked += ProductsDisplay_ConvertToMp3Clicked; this.productsDisplay.ConvertToMp3Clicked += ProductsDisplay_ConvertToMp3Clicked;
this.productsDisplay.InitialLoaded += new System.EventHandler(this.productsDisplay_InitialLoaded); this.productsDisplay.InitialLoaded += new System.EventHandler(this.productsDisplay_InitialLoaded);
// //

View File

@ -48,7 +48,7 @@ namespace LibationWinForms
} }
} }
private void ProductsDisplay_LiberateSeriesClicked(object sender, ISeriesEntry series) private void ProductsDisplay_LiberateSeriesClicked(object sender, SeriesEntry series)
{ {
try try
{ {

View File

@ -32,11 +32,7 @@ namespace LibationWinForms
// in autoScan, new books SHALL NOT show dialog // in autoScan, new books SHALL NOT show dialog
try try
{ {
Task importAsync() => LibraryCommands.ImportAccountAsync(accounts); await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
if (InvokeRequired)
await Invoke(importAsync);
else
await importAsync();
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@ -74,7 +74,7 @@ namespace LibationWinForms
{ {
try try
{ {
var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(accounts); var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
if (Configuration.Instance.ShowImportedStats && newAdded > 0) if (Configuration.Instance.ShowImportedStats && newAdded > 0)

View File

@ -22,6 +22,7 @@ namespace LibationWinForms
PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format)); PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format));
BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault); BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault);
BaseUtil.SetLoadResourceImageDelegate(Properties.Resources.ResourceManager.GetObject);
// wire-up event to automatically download after scan. // wire-up event to automatically download after scan.
// winforms only. this should NOT be allowed in cli // winforms only. this should NOT be allowed in cli

View File

@ -28,7 +28,7 @@ namespace LibationWinForms.GridView
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{ {
// series // series
if (rowIndex >= 0 && DataGridView.GetBoundItem<IGridEntry>(rowIndex) is ISeriesEntry) if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is SeriesEntry)
{ {
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border);
} }

View File

@ -24,24 +24,24 @@ namespace LibationWinForms.GridView
* event. Adding or removing from the underlying list will not change the * event. Adding or removing from the underlying list will not change the
* BindingList's subscription to that item. * BindingList's subscription to that item.
*/ */
internal class GridEntryBindingList : BindingList<IGridEntry>, IBindingListView internal class GridEntryBindingList : BindingList<GridEntry>, IBindingListView
{ {
public GridEntryBindingList(IEnumerable<IGridEntry> enumeration) : base(new List<IGridEntry>(enumeration)) public GridEntryBindingList(IEnumerable<GridEntry> enumeration) : base(new List<GridEntry>(enumeration))
{ {
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
ListChanged += GridEntryBindingList_ListChanged; ListChanged += GridEntryBindingList_ListChanged;
} }
/// <returns>All items in the list, including those filtered out.</returns> /// <returns>All items in the list, including those filtered out.</returns>
public List<IGridEntry> AllItems() => Items.Concat(FilterRemoved).ToList(); public List<GridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
/// <summary>All items that pass the current filter</summary> /// <summary>All items that pass the current filter</summary>
public IEnumerable<ILibraryBookEntry> GetFilteredInItems() public IEnumerable<LibraryBookEntry> GetFilteredInItems()
=> FilteredInGridEntries? => FilteredInGridEntries?
.OfType<ILibraryBookEntry>() .OfType<LibraryBookEntry>()
?? FilterRemoved ?? FilterRemoved
.OfType<ILibraryBookEntry>() .OfType<LibraryBookEntry>()
.Union(Items.OfType<ILibraryBookEntry>()); .Union(Items.OfType<LibraryBookEntry>());
public bool SupportsFiltering => true; public bool SupportsFiltering => true;
public string Filter public string Filter
@ -67,12 +67,12 @@ namespace LibationWinForms.GridView
protected override ListSortDirection SortDirectionCore => Comparer.SortOrder; protected override ListSortDirection SortDirectionCore => Comparer.SortOrder;
/// <summary> Items that were removed from the base list due to filtering </summary> /// <summary> Items that were removed from the base list due to filtering </summary>
private readonly List<IGridEntry> FilterRemoved = new(); private readonly List<GridEntry> FilterRemoved = new();
private string FilterString; private string FilterString;
private bool isSorted; private bool isSorted;
private PropertyDescriptor propertyDescriptor; private PropertyDescriptor propertyDescriptor;
/// <summary> All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice)</summary> /// <summary> All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice)</summary>
private HashSet<IGridEntry> FilteredInGridEntries; private HashSet<GridEntry> FilteredInGridEntries;
#region Unused - Advanced Filtering #region Unused - Advanced Filtering
public bool SupportsAdvancedSorting => false; public bool SupportsAdvancedSorting => false;
@ -84,7 +84,7 @@ namespace LibationWinForms.GridView
public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException(); public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException();
#endregion #endregion
public new void Remove(IGridEntry entry) public new void Remove(GridEntry entry)
{ {
FilterRemoved.Remove(entry); FilterRemoved.Remove(entry);
base.Remove(entry); base.Remove(entry);
@ -122,13 +122,13 @@ namespace LibationWinForms.GridView
ResetList(); ResetList();
RaiseListChangedEvents = priorState; RaiseListChangedEvents = priorState;
void addRemovedItemsBack(List<IGridEntry> addBackEntries) void addRemovedItemsBack(List<GridEntry> addBackEntries)
{ {
//Add removed entries back into Items so they are displayed //Add removed entries back into Items so they are displayed
//(except for episodes that are collapsed) //(except for episodes that are collapsed)
foreach (var addBack in addBackEntries) foreach (var addBack in addBackEntries)
{ {
if (addBack is ILibraryBookEntry lbe && lbe.Parent is ISeriesEntry se && !se.Liberate.Expanded) if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && !se.Liberate.Expanded)
continue; continue;
FilterRemoved.Remove(addBack); FilterRemoved.Remove(addBack);
@ -160,7 +160,7 @@ namespace LibationWinForms.GridView
ExpandItem(series); ExpandItem(series);
} }
public void CollapseItem(ISeriesEntry sEntry) public void CollapseItem(SeriesEntry sEntry)
{ {
foreach (var episode in sEntry.Children.Intersect(Items.BookEntries()).ToList()) foreach (var episode in sEntry.Children.Intersect(Items.BookEntries()).ToList())
{ {
@ -171,7 +171,7 @@ namespace LibationWinForms.GridView
sEntry.Liberate.Expanded = false; sEntry.Liberate.Expanded = false;
} }
public void ExpandItem(ISeriesEntry sEntry) public void ExpandItem(SeriesEntry sEntry)
{ {
var sindex = Items.IndexOf(sEntry); var sindex = Items.IndexOf(sEntry);
@ -208,7 +208,7 @@ namespace LibationWinForms.GridView
private void SortInternal() private void SortInternal()
{ {
var itemsList = (List<IGridEntry>)Items; var itemsList = (List<GridEntry>)Items;
//User Order/OrderDescending and replace items in list instead of using List.Sort() to achieve stable sorting. //User Order/OrderDescending and replace items in list instead of using List.Sort() to achieve stable sorting.
var sortedItems = Comparer.OrderEntries(itemsList).ToList(); var sortedItems = Comparer.OrderEntries(itemsList).ToList();

View File

@ -1,7 +1,9 @@
using DataLayer; using DataLayer;
using LibationUiBase.GridView;
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
#nullable enable
namespace LibationWinForms.GridView namespace LibationWinForms.GridView
{ {
public class LiberateDataGridViewImageButtonColumn : DataGridViewButtonColumn, IDataGridScaleColumn public class LiberateDataGridViewImageButtonColumn : DataGridViewButtonColumn, IDataGridScaleColumn
@ -20,16 +22,18 @@ namespace LibationWinForms.GridView
private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray)); private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray));
private static readonly Color HiddenForeColor = Color.LightGray; private static readonly Color HiddenForeColor = Color.LightGray;
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object? value, object? formattedValue, string? errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
{ {
if (value is WinFormsEntryStatus status) if (OwningRow is DataGridViewRow row && row.DataGridView is DataGridView grid && value is EntryStatus status)
{ {
if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable) if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable)
//Don't paint the button graphic //Don't paint the button graphic
paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground; paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground;
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = (Color)status.BackgroundBrush; row.DefaultCellStyle.BackColor = status.IsEpisode ? SERIES_BG_COLOR : grid.DefaultCellStyle.BackColor;
DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = status.Opacity == 1 ? DataGridView.DefaultCellStyle.ForeColor : HiddenForeColor; row.DefaultCellStyle.ForeColor = status.Opacity == 1 ? grid.DefaultCellStyle.ForeColor : HiddenForeColor;
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts); base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
DrawButtonImage(graphics, (Image)status.ButtonImage, cellBounds); DrawButtonImage(graphics, (Image)status.ButtonImage, cellBounds);

View File

@ -22,7 +22,7 @@ namespace LibationWinForms.GridView
public event EventHandler<int> VisibleCountChanged; public event EventHandler<int> VisibleCountChanged;
public event EventHandler<int> RemovableCountChanged; public event EventHandler<int> RemovableCountChanged;
public event EventHandler<LibraryBook[]> LiberateClicked; public event EventHandler<LibraryBook[]> LiberateClicked;
public event EventHandler<ISeriesEntry> LiberateSeriesClicked; public event EventHandler<SeriesEntry> LiberateSeriesClicked;
public event EventHandler<LibraryBook[]> ConvertToMp3Clicked; public event EventHandler<LibraryBook[]> ConvertToMp3Clicked;
public event EventHandler InitialLoaded; public event EventHandler InitialLoaded;
@ -36,7 +36,7 @@ namespace LibationWinForms.GridView
#region Button controls #region Button controls
private ImageDisplay imageDisplay; private ImageDisplay imageDisplay;
private void productsGrid_CoverClicked(IGridEntry liveGridEntry) private void productsGrid_CoverClicked(GridEntry liveGridEntry)
{ {
var picDef = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); var picDef = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native);
@ -71,7 +71,7 @@ namespace LibationWinForms.GridView
imageDisplay.Show(null); imageDisplay.Show(null);
} }
private void productsGrid_DescriptionClicked(IGridEntry liveGridEntry, Rectangle cellRectangle) private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle)
{ {
var displayWindow = new DescriptionDisplay var displayWindow = new DescriptionDisplay
{ {
@ -90,7 +90,7 @@ namespace LibationWinForms.GridView
displayWindow.Show(this); displayWindow.Show(this);
} }
private async void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry) private async void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry)
{ {
// HACK: workaround for a Winforms bug. // HACK: workaround for a Winforms bug.
// This event is fired by the DataGridCell.OnMouseUpInternal // This event is fired by the DataGridCell.OnMouseUpInternal
@ -124,12 +124,12 @@ namespace LibationWinForms.GridView
#region Cell Context Menu #region Cell Context Menu
private void productsGrid_CellContextMenuStripNeeded(IGridEntry[] entries, ContextMenuStrip ctxMenu) private void productsGrid_CellContextMenuStripNeeded(GridEntry[] entries, ContextMenuStrip ctxMenu)
{ {
var ctx = new GridContextMenu(entries, '&'); var ctx = new GridContextMenu(entries, '&');
#region Liberate all Episodes (Single series only) #region Liberate all Episodes (Single series only)
if (entries.Length == 1 && entries[0] is ISeriesEntry seriesEntry) if (entries.Length == 1 && entries[0] is SeriesEntry seriesEntry)
{ {
var liberateEpisodesMenuItem = new ToolStripMenuItem() var liberateEpisodesMenuItem = new ToolStripMenuItem()
{ {
@ -166,7 +166,7 @@ namespace LibationWinForms.GridView
#endregion #endregion
#region Locate file (Single book only) #region Locate file (Single book only)
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry)
{ {
var locateFileMenuItem = new ToolStripMenuItem() { Text = ctx.LocateFileText }; var locateFileMenuItem = new ToolStripMenuItem() { Text = ctx.LocateFileText };
ctxMenu.Items.Add(locateFileMenuItem); ctxMenu.Items.Add(locateFileMenuItem);
@ -199,7 +199,7 @@ namespace LibationWinForms.GridView
#endregion #endregion
#region Liberate All (multiple books only) #region Liberate All (multiple books only)
if (entries.OfType<ILibraryBookEntry>().Count() > 1) if (entries.OfType<LibraryBookEntry>().Count() > 1)
{ {
var downloadSelectedMenuItem = new ToolStripMenuItem() var downloadSelectedMenuItem = new ToolStripMenuItem()
{ {
@ -230,7 +230,7 @@ namespace LibationWinForms.GridView
#endregion #endregion
#region Force Re-Download (Single book only) #region Force Re-Download (Single book only)
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry4) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry4)
{ {
var reDownloadMenuItem = new ToolStripMenuItem() var reDownloadMenuItem = new ToolStripMenuItem()
{ {
@ -269,7 +269,7 @@ namespace LibationWinForms.GridView
} }
} }
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry2) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry2)
{ {
var folderTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FolderTemplateText }; var folderTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FolderTemplateText };
var fileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FileTemplateText }; var fileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FileTemplateText };
@ -288,7 +288,7 @@ namespace LibationWinForms.GridView
#endregion #endregion
#region View Bookmarks/Clips (Single book only) #region View Bookmarks/Clips (Single book only)
if (entries.Length == 1 && entries[0] is ILibraryBookEntry entry3) if (entries.Length == 1 && entries[0] is LibraryBookEntry entry3)
{ {
var bookRecordMenuItem = new ToolStripMenuItem { Text = ctx.ViewBookmarksText }; var bookRecordMenuItem = new ToolStripMenuItem { Text = ctx.ViewBookmarksText };
bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry3.LibraryBook).ShowDialog(this); bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry3.LibraryBook).ShowDialog(this);
@ -417,7 +417,7 @@ namespace LibationWinForms.GridView
VisibleCountChanged?.Invoke(this, count); VisibleCountChanged?.Invoke(this, count);
} }
private void productsGrid_LiberateClicked(ILibraryBookEntry liveGridEntry) private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
{ {
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error
&& !liveGridEntry.Liberate.IsUnavailable) && !liveGridEntry.Liberate.IsUnavailable)

View File

@ -270,7 +270,7 @@ namespace LibationWinForms.GridView
// //
// syncBindingSource // syncBindingSource
// //
syncBindingSource.DataSource = typeof(IGridEntry); syncBindingSource.DataSource = typeof(GridEntry);
// //
// ProductsGrid // ProductsGrid
// //

View File

@ -15,10 +15,10 @@ using System.Windows.Forms;
namespace LibationWinForms.GridView namespace LibationWinForms.GridView
{ {
public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry); public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry);
public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry); public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle); public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle);
public delegate void ProductsGridCellContextMenuStripNeededEventHandler(IGridEntry[] liveGridEntry, ContextMenuStrip ctxMenu); public delegate void ProductsGridCellContextMenuStripNeededEventHandler(GridEntry[] liveGridEntry, ContextMenuStrip ctxMenu);
public partial class ProductsGrid : UserControl public partial class ProductsGrid : UserControl
{ {
@ -37,8 +37,8 @@ namespace LibationWinForms.GridView
=> bindingList => bindingList
?.GetFilteredInItems() ?.GetFilteredInItems()
.Select(lbe => lbe.LibraryBook) ?? Enumerable.Empty<LibraryBook>(); .Select(lbe => lbe.LibraryBook) ?? Enumerable.Empty<LibraryBook>();
internal IEnumerable<ILibraryBookEntry> GetAllBookEntries() internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> bindingList?.AllItems().BookEntries() ?? Enumerable.Empty<ILibraryBookEntry>(); => bindingList?.AllItems().BookEntries() ?? Enumerable.Empty<LibraryBookEntry>();
public ProductsGrid() public ProductsGrid()
{ {
@ -189,7 +189,7 @@ namespace LibationWinForms.GridView
.Distinct() .Distinct()
.OrderBy(r => r.Index) .OrderBy(r => r.Index)
.Select(r => r.DataBoundItem) .Select(r => r.DataBoundItem)
.OfType<IGridEntry>() .OfType<GridEntry>()
.ToArray(); .ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry); var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
@ -225,7 +225,7 @@ namespace LibationWinForms.GridView
return; return;
var entry = getGridEntry(e.RowIndex); var entry = getGridEntry(e.RowIndex);
if (entry is ILibraryBookEntry lbEntry) if (entry is LibraryBookEntry lbEntry)
{ {
if (e.ColumnIndex == liberateGVColumn.Index) if (e.ColumnIndex == liberateGVColumn.Index)
LiberateClicked?.Invoke(lbEntry); LiberateClicked?.Invoke(lbEntry);
@ -236,7 +236,7 @@ namespace LibationWinForms.GridView
else if (e.ColumnIndex == coverGVColumn.Index) else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(lbEntry); CoverClicked?.Invoke(lbEntry);
} }
else if (entry is ISeriesEntry sEntry) else if (entry is SeriesEntry sEntry)
{ {
if (e.ColumnIndex == liberateGVColumn.Index) if (e.ColumnIndex == liberateGVColumn.Index)
{ {
@ -265,7 +265,7 @@ namespace LibationWinForms.GridView
} }
} }
private IGridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<IGridEntry>(rowIndex); private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex);
#endregion #endregion
@ -295,8 +295,8 @@ namespace LibationWinForms.GridView
var sc = Invoke(() => System.Threading.SynchronizationContext.Current); var sc = Invoke(() => System.Threading.SynchronizationContext.Current);
System.Threading.SynchronizationContext.SetSynchronizationContext(sc); System.Threading.SynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry<WinFormsEntryStatus>.GetAllProductsAsync(dbBooks); var geList = await LibraryBookEntry.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry<WinFormsEntryStatus>.GetAllSeriesEntriesAsync(dbBooks); var seriesEntries = await SeriesEntry.GetAllSeriesEntriesAsync(dbBooks);
geList.AddRange(seriesEntries); geList.AddRange(seriesEntries);
//Sort descending by date (default sort property) //Sort descending by date (default sort property)
@ -383,7 +383,7 @@ namespace LibationWinForms.GridView
gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow; gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;
} }
public void RemoveBooks(IEnumerable<ILibraryBookEntry> removedBooks) public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
{ {
if (bindingList == null) if (bindingList == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(RemoveBooks)}"); throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(RemoveBooks)}");
@ -398,34 +398,34 @@ namespace LibationWinForms.GridView
.AllItems() .AllItems()
.EmptySeries(); .EmptySeries();
foreach (var removed in removedBooks.Cast<IGridEntry>().Concat(removedSeries)) foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries))
//no need to re-filter for removed books //no need to re-filter for removed books
bindingList.Remove(removed); bindingList.Remove(removed);
VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count()); VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count());
} }
private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry? existingBookEntry) private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry? existingBookEntry)
{ {
if (bindingList == null) if (bindingList == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateBook)}"); throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateBook)}");
if (existingBookEntry is null) if (existingBookEntry is null)
// Add the new product to top // Add the new product to top
bindingList.Insert(0, new LibraryBookEntry<WinFormsEntryStatus>(book)); bindingList.Insert(0, new LibraryBookEntry(book));
else else
// update existing // update existing
existingBookEntry.UpdateLibraryBook(book); existingBookEntry.UpdateLibraryBook(book);
} }
private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry? existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks) private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry? existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{ {
if (bindingList == null) if (bindingList == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateEpisode)}"); throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(AddOrUpdateEpisode)}");
if (existingEpisodeEntry is null) if (existingEpisodeEntry is null)
{ {
ILibraryBookEntry episodeEntry; LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
@ -443,7 +443,7 @@ namespace LibationWinForms.GridView
return; return;
} }
seriesEntry = new SeriesEntry<WinFormsEntryStatus>(seriesBook, episodeBook); seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry); seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0]; episodeEntry = seriesEntry.Children[0];
@ -453,7 +453,7 @@ namespace LibationWinForms.GridView
else else
{ {
//Series exists. Create and add episode child then update the SeriesEntry //Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new LibraryBookEntry<WinFormsEntryStatus>(episodeBook, seriesEntry); episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry); seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex)); seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);

View File

@ -8,13 +8,13 @@ namespace LibationWinForms.GridView
internal class RowComparer : RowComparerBase internal class RowComparer : RowComparerBase
{ {
public ListSortDirection SortOrder { get; set; } = ListSortDirection.Descending; public ListSortDirection SortOrder { get; set; } = ListSortDirection.Descending;
public override string PropertyName { get; set; } = nameof(IGridEntry.DateAdded); public override string PropertyName { get; set; } = nameof(GridEntry.DateAdded);
protected override ListSortDirection GetSortOrder() => SortOrder; protected override ListSortDirection GetSortOrder() => SortOrder;
/// <summary> /// <summary>
/// Helper method for ordering grid entries /// Helper method for ordering grid entries
/// </summary> /// </summary>
public IOrderedEnumerable<IGridEntry> OrderEntries(IEnumerable<IGridEntry> entries) public IOrderedEnumerable<GridEntry> OrderEntries(IEnumerable<GridEntry> entries)
=> SortOrder is ListSortDirection.Descending ? entries.OrderDescending(this) : entries.Order(this); => SortOrder is ListSortDirection.Descending ? entries.OrderDescending(this) : entries.Order(this);
} }
} }

View File

@ -1,24 +0,0 @@
using DataLayer;
using LibationUiBase.GridView;
using System.Drawing;
namespace LibationWinForms.GridView
{
public class WinFormsEntryStatus : EntryStatus, IEntryStatus
{
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
public Color BackgroundBrush => IsEpisode ? SERIES_BG_COLOR : SystemColors.ControlLightLight;
private WinFormsEntryStatus(LibraryBook libraryBook) : base(libraryBook) { }
public static EntryStatus Create(LibraryBook libraryBook) => new WinFormsEntryStatus(libraryBook);
protected override Image LoadImage(byte[] picture)
=> WinFormsUtil.TryLoadImageOrDefault(picture, LibationFileManager.PictureSize._80x80);
protected override Image GetResourceImage(string rescName)
{
var image = Properties.Resources.ResourceManager.GetObject(rescName);
return image as Bitmap;
}
}
}

View File

@ -3,33 +3,20 @@ using System;
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
#nullable enable
namespace LibationWinForms.ProcessQueue namespace LibationWinForms.ProcessQueue
{ {
internal partial class ProcessBookControl : UserControl internal partial class ProcessBookControl : UserControl
{ {
private readonly int CancelBtnDistanceFromEdge; private readonly int CancelBtnDistanceFromEdge;
private readonly int ProgressBarDistanceFromEdge; private readonly int ProgressBarDistanceFromEdge;
private object? m_OldContext;
private static Color FailedColor { get; } = Color.LightCoral; private static Color FailedColor { get; } = Color.LightCoral;
private static Color CancelledColor { get; } = Color.Khaki; private static Color CancelledColor { get; } = Color.Khaki;
private static Color QueuedColor { get; } = SystemColors.Control; private static Color QueuedColor { get; } = SystemColors.Control;
private static Color SuccessColor { get; } = Color.PaleGreen; private static Color SuccessColor { get; } = Color.PaleGreen;
private ProcessBookViewModelBase m_Context;
public ProcessBookViewModelBase Context
{
get => m_Context;
set
{
if (m_Context != value)
{
OnContextChanging();
m_Context = value;
OnContextChanged();
}
}
}
public ProcessBookControl() public ProcessBookControl()
{ {
InitializeComponent(); InitializeComponent();
@ -41,35 +28,41 @@ namespace LibationWinForms.ProcessQueue
ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width; ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width;
} }
private void OnContextChanging() protected override void OnDataContextChanged(EventArgs e)
{ {
if (Context is not null) if (m_OldContext is ProcessBookViewModel oldContext)
Context.PropertyChanged -= Context_PropertyChanged; oldContext.PropertyChanged -= DataContext_PropertyChanged;
if (DataContext is ProcessBookViewModel newContext)
{
m_OldContext = newContext;
newContext.PropertyChanged += DataContext_PropertyChanged;
DataContext_PropertyChanged(DataContext, new System.ComponentModel.PropertyChangedEventArgs(null));
}
base.OnDataContextChanged(e);
} }
private void OnContextChanged() private void DataContext_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{ {
Context.PropertyChanged += Context_PropertyChanged; if (sender is not ProcessBookViewModel vm)
Context_PropertyChanged(this, new System.ComponentModel.PropertyChangedEventArgs(null)); return;
}
private void Context_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
SuspendLayout(); SuspendLayout();
if (e.PropertyName is null or nameof(Context.Cover)) if (e.PropertyName is null or nameof(vm.Cover))
SetCover(Context.Cover as Image); SetCover(vm.Cover as Image);
if (e.PropertyName is null or nameof(Context.Title) or nameof(Context.Author) or nameof(Context.Narrator)) if (e.PropertyName is null or nameof(vm.Title) or nameof(vm.Author) or nameof(vm.Narrator))
SetBookInfo($"{Context.Title}\r\nBy {Context.Author}\r\nNarrated by {Context.Narrator}"); SetBookInfo($"{vm.Title}\r\nBy {vm.Author}\r\nNarrated by {vm.Narrator}");
if (e.PropertyName is null or nameof(Context.Status) or nameof(Context.StatusText)) if (e.PropertyName is null or nameof(vm.Status) or nameof(vm.StatusText))
SetStatus(Context.Status, Context.StatusText); SetStatus(vm.Status, vm.StatusText);
if (e.PropertyName is null or nameof(Context.Progress)) if (e.PropertyName is null or nameof(vm.Progress))
SetProgress(Context.Progress); SetProgress(vm.Progress);
if (e.PropertyName is null or nameof(Context.TimeRemaining)) if (e.PropertyName is null or nameof(vm.TimeRemaining))
SetRemainingTime(Context.TimeRemaining); SetRemainingTime(vm.TimeRemaining);
ResumeLayout(); ResumeLayout();
} }
private void SetCover(Image cover) => pictureBox1.Image = cover; private void SetCover(Image? cover) => pictureBox1.Image = cover;
private void SetBookInfo(string title) => bookInfoLbl.Text = title; private void SetBookInfo(string title) => bookInfoLbl.Text = title;
private void SetRemainingTime(TimeSpan remaining) private void SetRemainingTime(TimeSpan remaining)
=> remainingTimeLbl.Text = $"{remaining:mm\\:ss}"; => remainingTimeLbl.Text = $"{remaining:mm\\:ss}";

View File

@ -1,14 +0,0 @@
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.ProcessQueue;
namespace LibationWinForms.ProcessQueue;
public class ProcessBookViewModel : ProcessBookViewModelBase
{
public ProcessBookViewModel(LibraryBook libraryBook, LogMe logme) : base(libraryBook, logme) { }
protected override object LoadImageFromBytes(byte[] bytes, PictureSize pictureSize)
=> WinFormsUtil.TryLoadImageOrDefault(bytes, PictureSize._80x80);
}

View File

@ -1,5 +1,6 @@
using LibationFileManager; using LibationFileManager;
using LibationUiBase; using LibationUiBase;
using LibationUiBase.ProcessQueue;
using System; using System;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
@ -27,49 +28,47 @@ internal partial class ProcessQueueControl : UserControl
{ {
InitializeComponent(); InitializeComponent();
var speedLimitMBps = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
numericUpDown1.Value = speedLimitMBps > numericUpDown1.Maximum || speedLimitMBps < numericUpDown1.Minimum ? 0 : speedLimitMBps;
statusStrip1.Items.Add(PopoutButton); statusStrip1.Items.Add(PopoutButton);
virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked; virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked;
virtualFlowControl2.DataContext = ViewModel.Queue;
ViewModel.LogWritten += (_, text) => WriteLine(text);
ViewModel.PropertyChanged += ProcessQueue_PropertyChanged; ViewModel.PropertyChanged += ProcessQueue_PropertyChanged;
virtualFlowControl2.Items = ViewModel.Items; ViewModel.LogEntries.CollectionChanged += LogEntries_CollectionChanged;
Load += ProcessQueueControl_Load;
} }
private void ProcessQueueControl_Load(object? sender, EventArgs e) private void LogEntries_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (!IsDisposed && e.Action == System.Collections.Specialized.NotifyCollectionChangedAction.Add)
{
foreach(var entry in e.NewItems?.OfType<LogEntry>() ?? [])
logDGV.Rows.Add(entry.LogDate, entry.LogMessage);
}
}
protected override void OnLoad(EventArgs e)
{ {
if (DesignMode) return; if (DesignMode) return;
ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null)); ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null));
} }
public void WriteLine(string text)
{
if (!IsDisposed)
logDGV.Rows.Add(DateTime.Now, text.Trim());
}
private async void cancelAllBtn_Click(object? sender, EventArgs e) private async void cancelAllBtn_Click(object? sender, EventArgs e)
{ {
ViewModel.Queue.ClearQueue(); ViewModel.Queue.ClearQueue();
if (ViewModel.Queue.Current is not null) if (ViewModel.Queue.Current is not null)
await ViewModel.Queue.Current.CancelAsync(); await ViewModel.Queue.Current.CancelAsync();
virtualFlowControl2.RefreshDisplay();
} }
private void btnClearFinished_Click(object? sender, EventArgs e) private void btnClearFinished_Click(object? sender, EventArgs e)
{ {
ViewModel.Queue.ClearCompleted(); ViewModel.Queue.ClearCompleted();
virtualFlowControl2.RefreshDisplay();
if (!ViewModel.Running) if (!ViewModel.Running)
runningTimeLbl.Text = string.Empty; runningTimeLbl.Text = string.Empty;
} }
private void clearLogBtn_Click(object? sender, EventArgs e) private void clearLogBtn_Click(object? sender, EventArgs e)
{ {
ViewModel.LogEntries.Clear();
logDGV.Rows.Clear(); logDGV.Rows.Clear();
} }
@ -92,7 +91,6 @@ internal partial class ProcessQueueControl : UserControl
{ {
queueNumberLbl.Text = ViewModel.QueuedCount.ToString(); queueNumberLbl.Text = ViewModel.QueuedCount.ToString();
queueNumberLbl.Visible = ViewModel.QueuedCount > 0; queueNumberLbl.Visible = ViewModel.QueuedCount > 0;
virtualFlowControl2.RefreshDisplay();
} }
if (e.PropertyName is null or nameof(ViewModel.ErrorCount)) if (e.PropertyName is null or nameof(ViewModel.ErrorCount))
{ {
@ -117,16 +115,22 @@ internal partial class ProcessQueueControl : UserControl
{ {
runningTimeLbl.Text = ViewModel.RunningTime; runningTimeLbl.Text = ViewModel.RunningTime;
} }
if (e.PropertyName is null or nameof(ViewModel.SpeedLimit))
{
numericUpDown1.Value = ViewModel.SpeedLimit;
numericUpDown1.Increment = ViewModel.SpeedLimitIncrement;
numericUpDown1.DecimalPlaces = ViewModel.SpeedLimit >= 10 ? 0 : ViewModel.SpeedLimit >= 1 ? 1 : 2;
}
} }
/// <summary> /// <summary>
/// View notified the model that a botton was clicked /// View notified the model that a button was clicked
/// </summary> /// </summary>
/// <param name="sender">the <see cref="ProcessBookControl"/> whose button was clicked</param> /// <param name="sender">the <see cref="ProcessBookControl"/> whose button was clicked</param>
/// <param name="buttonName">The name of the button clicked</param> /// <param name="buttonName">The name of the button clicked</param>
private async void VirtualFlowControl2_ButtonClicked(object? sender, string buttonName) private async void VirtualFlowControl2_ButtonClicked(object? sender, string buttonName)
{ {
if (sender is not ProcessBookControl control || control.Context is not ProcessBookViewModel item) if (sender is not ProcessBookControl control || control.DataContext is not ProcessBookViewModel item)
return; return;
try try
@ -135,13 +139,12 @@ internal partial class ProcessQueueControl : UserControl
{ {
await item.CancelAsync(); await item.CancelAsync();
ViewModel.Queue.RemoveQueued(item); ViewModel.Queue.RemoveQueued(item);
virtualFlowControl2.RefreshDisplay();
} }
else else
{ {
QueuePosition? position = buttonName switch QueuePosition? position = buttonName switch
{ {
nameof(ProcessBookControl.moveFirstBtn) => QueuePosition.Fisrt, nameof(ProcessBookControl.moveFirstBtn) => QueuePosition.First,
nameof(ProcessBookControl.moveUpBtn) => QueuePosition.OneUp, nameof(ProcessBookControl.moveUpBtn) => QueuePosition.OneUp,
nameof(ProcessBookControl.moveDownBtn) => QueuePosition.OneDown, nameof(ProcessBookControl.moveDownBtn) => QueuePosition.OneDown,
nameof(ProcessBookControl.moveLastBtn) => QueuePosition.Last, nameof(ProcessBookControl.moveLastBtn) => QueuePosition.Last,
@ -149,10 +152,7 @@ internal partial class ProcessQueueControl : UserControl
}; };
if (position is not null) if (position is not null)
{
ViewModel.Queue.MoveQueuePosition(item, position.Value); ViewModel.Queue.MoveQueuePosition(item, position.Value);
virtualFlowControl2.RefreshDisplay();
}
} }
} }
catch(Exception ex) catch(Exception ex)
@ -164,27 +164,7 @@ internal partial class ProcessQueueControl : UserControl
#endregion #endregion
private void numericUpDown1_ValueChanged(object? sender, EventArgs e) private void numericUpDown1_ValueChanged(object? sender, EventArgs e)
{ => ViewModel.SpeedLimit = numericUpDown1.Value;
var newValue = (long)(numericUpDown1.Value * 1024 * 1024);
var config = Configuration.Instance;
config.DownloadSpeedLimit = newValue;
if (config.DownloadSpeedLimit > newValue)
numericUpDown1.Value =
numericUpDown1.Value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024
: 0;
numericUpDown1.Increment =
numericUpDown1.Value > 100 ? 10
: numericUpDown1.Value > 10 ? 1
: numericUpDown1.Value > 1 ? 0.1m
: 0.01m;
numericUpDown1.DecimalPlaces =
numericUpDown1.Value >= 10 ? 0
: numericUpDown1.Value >= 1 ? 1
: 2;
}
} }
public class NumericUpDownSuffix : NumericUpDown public class NumericUpDownSuffix : NumericUpDown

View File

@ -1,24 +0,0 @@
using DataLayer;
using LibationUiBase.ProcessQueue;
using System;
using System.Collections.Generic;
#nullable enable
namespace LibationWinForms.ProcessQueue;
internal class ProcessQueueViewModel : ProcessQueueViewModelBase
{
public event EventHandler<string>? LogWritten;
public List<ProcessBookViewModelBase> Items { get; }
public ProcessQueueViewModel() : base(new List<ProcessBookViewModelBase>())
{
Items = Queue.UnderlyingList as List<ProcessBookViewModelBase>
?? throw new ArgumentNullException(nameof(Queue.UnderlyingList));
}
public override void WriteLine(string text) => Invoke(() => LogWritten?.Invoke(this, text.Trim()));
protected override ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook)
=> new ProcessBookViewModel(libraryBook, Logger);
}

View File

@ -1,9 +1,11 @@
using LibationUiBase.ProcessQueue; using System;
using System; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
#nullable enable
namespace LibationWinForms.ProcessQueue namespace LibationWinForms.ProcessQueue
{ {
internal partial class VirtualFlowControl : UserControl internal partial class VirtualFlowControl : UserControl
@ -11,18 +13,28 @@ namespace LibationWinForms.ProcessQueue
/// <summary> /// <summary>
/// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked /// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked
/// </summary> /// </summary>
public event EventHandler<string> ButtonClicked; public event EventHandler<string>? ButtonClicked;
public IList? Items { get; private set; }
private List<ProcessBookViewModelBase> m_Items; private object? m_OldContext;
public List<ProcessBookViewModelBase> Items protected override void OnDataContextChanged(EventArgs e)
{ {
get => m_Items; if (m_OldContext is INotifyCollectionChanged oldNotify)
set oldNotify.CollectionChanged -= Items_CollectionChanged;
if (DataContext is INotifyCollectionChanged newNotify)
{ {
m_Items = value; m_OldContext = newNotify;
if (m_Items is not null) newNotify.CollectionChanged += Items_CollectionChanged;
RefreshDisplay();
} }
Items = DataContext as IList;
base.OnDataContextChanged(e);
}
private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RefreshDisplay();
} }
public void RefreshDisplay() public void RefreshDisplay()
@ -65,7 +77,7 @@ namespace LibationWinForms.ProcessQueue
#region Instance variables #region Instance variables
/// <summary> /// <summary>
/// The total height, inclusing margins, of the repeated <see cref="ProcessBookControl"/> /// The total height, including margins, of the repeated <see cref="ProcessBookControl"/>
/// </summary> /// </summary>
private readonly int VirtualControlHeight; private readonly int VirtualControlHeight;
/// <summary> /// <summary>
@ -137,14 +149,15 @@ namespace LibationWinForms.ProcessQueue
/// <summary> /// <summary>
/// Handles all button clicks from all <see cref="ProcessBookControl"/>, detects which one sent the click, and fires <see cref="ButtonClicked"/> to notify the model of the click /// Handles all button clicks from all <see cref="ProcessBookControl"/>, detects which one sent the click, and fires <see cref="ButtonClicked"/> to notify the model of the click
/// </summary> /// </summary>
private void ControlButton_Click(object sender, EventArgs e) private void ControlButton_Click(object? sender, EventArgs e)
{ {
Control button = sender as Control; Control? button = sender as Control;
Control form = button.Parent; Control? form = button?.Parent;
while (form is not ProcessBookControl) while (form is not null and not ProcessBookControl)
form = form.Parent; form = form?.Parent;
ButtonClicked?.Invoke(form, button.Name); if (form is not null && button?.Name is string buttonText)
ButtonClicked?.Invoke(form, buttonText);
} }
/// <summary> /// <summary>
@ -176,7 +189,7 @@ namespace LibationWinForms.ProcessQueue
} }
/// <summary> /// <summary>
/// Calculated the virtual controls that are in view at the currrent scroll position and windows size, /// Calculated the virtual controls that are in view at the current scroll position and windows size,
/// positions <see cref="panel1"/> to simulate scroll activity, then fires updates the controls with /// positions <see cref="panel1"/> to simulate scroll activity, then fires updates the controls with
/// the context corresponding to the virtual scroll position /// the context corresponding to the virtual scroll position
/// </summary> /// </summary>
@ -195,9 +208,10 @@ namespace LibationWinForms.ProcessQueue
numVisible = Math.Min(numVisible, VirtualControlCount); numVisible = Math.Min(numVisible, VirtualControlCount);
numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible); numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible);
for (int i = 0; i < numVisible; i++) if (Items is IList items)
{ {
BookControls[i].Context = Items[firstVisible + i]; for (int i = 0; i < numVisible; i++)
BookControls[i].DataContext = items[firstVisible + i];
} }
for (int i = 0; i < BookControls.Count; i++) for (int i = 0; i < BookControls.Count; i++)

View File

@ -6,4 +6,3 @@ For reasons I cannot figure out, upgrading the major .net version breaks Libatio
* https://github.com/Mbucari/AAXClean.Codecs * https://github.com/Mbucari/AAXClean.Codecs
* https://github.com/Mbucari/AAXClean * https://github.com/Mbucari/AAXClean
* https://github.com/Mbucari/serilog-sinks-zipfile