Compare commits

...

12 Commits

Author SHA1 Message Date
rmcrackan
0f7ffacdf8 incr ver 2025-07-22 10:20:39 -04:00
rmcrackan
829b35c5a8 Merge pull request #1311 from Mbucari/master
Fix serilog dynamic assembly loading issue (#1310)
2025-07-22 10:18:33 -04:00
Michael Bucari-Tovo
614b05d5ff Fix serilog dynamic assembly loading issue (#1310) 2025-07-22 08:00:31 -06:00
rmcrackan
26ccc77b47 incr ver 2025-07-22 07:24:26 -04:00
rmcrackan
64fb2ccf7c Merge pull request #1308 from Mbucari/master
Refactors, bug fixes, and performance improvements.
2025-07-22 07:22:35 -04:00
MBucari
890747a902 Do library scan on background thread 2025-07-22 00:20:16 -06:00
Michael Bucari-Tovo
1fdcea929f Form thread safety 2025-07-21 22:52:17 -06:00
Michael Bucari-Tovo
7848366818 Write logs to text .log file instead of .zip file
The ZipFile sink could cause program hangs. Additionally, the only reason it was ever used was to package verbose AudibleApi account login errors, saving the returned Html page as a file. Otherwise, the zip file only contains a .log text file.

- Removed Serilog.Sinks.ZipFile
- Add Serilog configuration migration
- Added a custom destructure to handle logging files. If any files are logged, they will be written to "LogyyyyMM_AdditionalFiles.zip"
2025-07-21 22:19:55 -06:00
Michael Bucari-Tovo
40b4915b65 Improve download/decrypt cancellation 2025-07-21 15:56:41 -06:00
Michael Bucari-Tovo
80b86086ca Consolidate process queue view models
Remove classic and chardonnay-specific implementations
Refactor TrackedQueue into an IList with INotifyCollectionChanged
2025-07-21 15:56:30 -06:00
Michael Bucari-Tovo
bff9b67b72 Remove GridEntry derrived types and interfaces
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images

Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
2025-07-21 10:47:10 -06:00
Mbucari
657a7bb6bc Improve podcast episode GridEntry creation performance.
Tested on a library with ~5000 podcast episodes on an AMD Ryzen 7700X. Startup time decreases by ~400 ms in Release mode.
2025-07-21 09:49:25 -06:00
77 changed files with 1025 additions and 1118 deletions

View File

@@ -25,9 +25,8 @@ namespace AaxDecrypter
public override async Task CancelAsync()
{
IsCanceled = true;
await base.CancelAsync();
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload();
}
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();
public virtual void SetCoverArt(byte[] coverArt) { }

View File

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

View File

@@ -2,15 +2,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.4.7.1</Version>
<Version>12.4.9.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />
<!-- 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.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />

View File

@@ -115,11 +115,22 @@ namespace AppScaffolding
{
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";
config.SetNonString(serilog.DeepClone(), "Serilog");
zipFileSink["Name"] = "File";
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;
}
@@ -129,17 +140,17 @@ namespace AppScaffolding
{ "WriteTo", new JArray
{
// 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", "ZipFile" },
{ "Name", "File" },
{ "Args",
new JObject
{
// 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" },
// Serilog template formatting examples
// - 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),
});
if (InteropFactory.InteropFunctionsType is null)
if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
}

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AaxDecrypter;
using ApplicationServices;
@@ -18,10 +19,15 @@ namespace FileLiberator
{
public override string Name => "Download & Decrypt";
private AudiobookDownloadBase abDownloader;
private readonly CancellationTokenSource cancellationTokenSource = new();
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override async Task CancelAsync()
{
cancellationTokenSource.Cancel();
if (abDownloader is not null)
await abDownloader.CancelAsync();
}
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
@@ -41,8 +47,9 @@ namespace FileLiberator
}
OnBegin(libraryBook);
var cancellationToken = cancellationTokenSource.Token;
try
try
{
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
@@ -50,7 +57,7 @@ namespace FileLiberator
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
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;
try
@@ -74,38 +81,32 @@ namespace FileLiberator
.Where(f => f.FileType != FileType.AAXC)
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
return
abDownloader?.IsCanceled is true
? new StatusHandler { "Cancelled" }
: new StatusHandler { "Decrypt failed" };
cancellationToken.ThrowIfCancellationRequested();
return new StatusHandler { "Decrypt failed" };
}
var finalStorageDir = getDestinationDirectory(libraryBook);
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken));
Task[] finalTasks =
[
Task.Run(() => downloadCoverArt(downloadOptions)),
Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)),
moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
];
try
{
await Task.WhenAll(finalTasks);
}
catch
{
await Task.WhenAll(finalTasks);
}
catch when (!moveFilesTask.IsFaulted)
{
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
//Only fail if the downloaded audio files failed to move to Books directory
if (moveFilesTask.IsFaulted)
{
throw;
}
}
finally
//Only fail if the downloaded audio files failed to move to Books directory
}
finally
{
if (moveFilesTask.IsCompletedSuccessfully)
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
{
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
@@ -114,8 +115,13 @@ namespace FileLiberator
}
return new StatusHandler();
}
finally
}
catch when (cancellationToken.IsCancellationRequested)
{
Serilog.Log.Logger.Information("Download/Decrypt was cancelled. {@Book}", libraryBook.LogFriendly());
return new StatusHandler { "Cancelled" };
}
finally
{
OnCompleted(libraryBook);
}
@@ -257,16 +263,17 @@ namespace FileLiberator
/// <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>
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
var destinationDir = getDestinationDirectory(libraryBook);
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
var realDest
var realDest
= FileUtility.SaferMoveToValidPath(
entry.Path,
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
@@ -278,7 +285,8 @@ namespace FileLiberator
// 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 };
}
cancellationToken.ThrowIfCancellationRequested();
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
@@ -287,7 +295,8 @@ namespace FileLiberator
SetFileTime(libraryBook, cue.Path);
}
AudibleFileStorage.Audio.Refresh();
cancellationToken.ThrowIfCancellationRequested();
AudibleFileStorage.Audio.Refresh();
}
private static string getDestinationDirectory(LibraryBook libraryBook)
@@ -301,7 +310,7 @@ namespace FileLiberator
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> 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;
@@ -316,7 +325,7 @@ namespace FileLiberator
if (File.Exists(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)
{
File.WriteAllBytes(coverPath, picBytes);
@@ -327,6 +336,7 @@ namespace FileLiberator
{
//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.");
throw;
}
}
}

View File

@@ -11,6 +11,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
@@ -24,9 +25,10 @@ public partial class DownloadOptions
/// <summary>
/// Initiate an audiobook download from the audible api.
/// </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,
//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)
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
var options = BuildDownloadOptions(libraryBook, config, license);
return options;
token.ThrowIfCancellationRequested();
return BuildDownloadOptions(libraryBook, config, license);
}
private class LicenseInfo
@@ -57,16 +58,18 @@ public partial class DownloadOptions
=> 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;
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
{
token.ThrowIfCancellationRequested();
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
return new LicenseInfo(license);
}
token.ThrowIfCancellationRequested();
try
{
//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);
using var client = new HttpClient();
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
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.
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
ele.IsThreeState = dataItem is ISeriesEntry;
ele.IsThreeState = dataItem is SeriesEntry;
return ele;
}
}

View File

@@ -34,11 +34,11 @@ namespace LibationAvalonia.Controls
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
{
if (sender is DataGridCell cell &&
cell.DataContext is IGridEntry clickedEntry &&
cell.DataContext is GridEntry clickedEntry &&
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
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);
if (clickedIndex == -1)
{
@@ -101,7 +101,7 @@ namespace LibationAvalonia.Controls
private static string RemoveLineBreaks(string text)
=> 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();
return string.Join("\t", contents);
@@ -109,7 +109,7 @@ namespace LibationAvalonia.Controls
public required DataGrid Grid { 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 AvaloniaList<Control> ContextMenuItems
=> ContextMenu.ItemsSource as AvaloniaList<Control>;

View File

@@ -58,10 +58,5 @@ namespace LibationAvalonia.Controls.Settings
}
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());
}
QueuedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Queued };
WorkingBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Working };
CompletedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Completed };
CancelledBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Cancelled };
FailedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Failed };
QueuedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Queued };
WorkingBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Working };
CompletedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Completed };
CancelledBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Cancelled };
FailedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Failed };
//Set the current processable so that the empty queue doesn't try to advance.
QueuedBook.AddDownloadPdf();

View File

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

View File

@@ -1,5 +1,6 @@
using AudibleApi;
using AudibleUtilities;
using Avalonia.Threading;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
@@ -21,6 +22,9 @@ namespace LibationAvalonia.Dialogs.Login
}
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)
{

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
{
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
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
{

View File

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

View File

@@ -1,5 +1,6 @@
using LibationFileManager;
using LibationUiBase;
using System;
using System.IO;
#nullable enable
@@ -7,7 +8,7 @@ namespace LibationAvalonia.ViewModels
{
partial class MainVM
{
private void Configure_NonUI()
public static void Configure_NonUI()
{
using var ms1 = new MemoryStream();
App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1);
@@ -23,6 +24,20 @@ namespace LibationAvalonia.ViewModels
PictureStorage.SetDefaultImage(PictureSize.Native, ms3.ToArray());
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 LibationAvalonia.Views;
using LibationFileManager;
using LibationUiBase.ProcessQueue;
using ReactiveUI;
using System.Collections.Generic;
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;
/// <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>
private HashSet<IGridEntry>? FilteredInGridEntries;
private HashSet<GridEntry>? FilteredInGridEntries;
public string? FilterString { get; private set; }
private DataGridCollectionView? _gridEntries;
@@ -43,15 +43,15 @@ namespace LibationAvalonia.ViewModels
public List<LibraryBook> GetVisibleBookEntries()
=> FilteredInGridEntries?
.OfType<ILibraryBookEntry>()
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList()
?? SOURCE
.OfType<ILibraryBookEntry>()
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList();
private IEnumerable<ILibraryBookEntry> GetAllBookEntries()
private IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> SOURCE
.BookEntries();
@@ -112,8 +112,8 @@ namespace LibationAvalonia.ViewModels
var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current);
AvaloniaSynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
var geList = await LibraryBookEntry.GetAllProductsAsync(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
//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)
{
var count
= FilteredInGridEntries?.OfType<ILibraryBookEntry>().Count()
?? SOURCE.OfType<ILibraryBookEntry>().Count();
= FilteredInGridEntries?.OfType<LibraryBookEntry>().Count()
?? SOURCE.OfType<LibraryBookEntry>().Count();
VisibleCountChanged?.Invoke(this, count);
}
@@ -223,9 +223,9 @@ namespace LibationAvalonia.ViewModels
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)
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)
// Add the new product to top
SOURCE.Insert(0, new LibraryBookEntry<AvaloniaEntryStatus>(book));
SOURCE.Insert(0, new LibraryBookEntry(book));
else
// update existing
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)
{
ILibraryBookEntry episodeEntry;
LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
@@ -270,7 +270,7 @@ namespace LibationAvalonia.ViewModels
return;
}
seriesEntry = new SeriesEntry<AvaloniaEntryStatus>(seriesBook, episodeBook);
seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
@@ -280,7 +280,7 @@ namespace LibationAvalonia.ViewModels
else
{
//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.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
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;
@@ -332,7 +332,7 @@ namespace LibationAvalonia.ViewModels
private bool CollectionFilter(object item)
{
if (item is ILibraryBookEntry lbe
if (item is LibraryBookEntry lbe
&& lbe.Liberate.IsEpisode
&& lbe.Parent?.Liberate?.Expanded != true)
return false;
@@ -454,7 +454,7 @@ namespace LibationAvalonia.ViewModels
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);
RemovableCountChanged?.Invoke(this, removeCount);

View File

@@ -17,7 +17,7 @@ namespace LibationAvalonia.ViewModels
public RowComparer(DataGridColumn? 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

View File

@@ -57,7 +57,13 @@ namespace LibationAvalonia.ViewModels.Settings
config.GridFontScaleFactor = linearRangeToScaleFactor(GridFontScaleFactor);
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()
{

View File

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

View File

@@ -22,7 +22,7 @@ namespace LibationAvalonia.Views
public MainWindow()
{
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;
InitializeComponent();
@@ -137,7 +137,7 @@ namespace LibationAvalonia.Views
}
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);
BookDetailsDialog bookDetailsForm;

View File

@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
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"
x:DataType="vm:ProcessBookViewModel"
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.Controls;
using DataLayer;
using LibationAvalonia.ViewModels;
using LibationUiBase;
using LibationUiBase.ProcessQueue;
@@ -31,10 +30,8 @@ namespace LibationAvalonia.Views
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
DataContext = new ProcessBookViewModel(
context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"),
LogMe.RegisterForm(default(ILogForm))
);
ViewModels.MainVM.Configure_NonUI();
DataContext = new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"));
return;
}
}
@@ -44,7 +41,7 @@ namespace LibationAvalonia.Views
public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> CancelButtonClicked?.Invoke(DataItem);
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)
=> PositionButtonClicked?.Invoke(DataItem, QueuePosition.OneUp);
public void MoveDown_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)

View File

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

View File

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

View File

@@ -59,7 +59,7 @@
Width="75">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<CheckBox
HorizontalAlignment="Center"
IsThreeState="True"
@@ -70,7 +70,7 @@
<controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="True" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<views:LiberateStatusButton
ToolTip.Tip="{CompiledBinding Liberate.ToolTip}"
BookStatus="{CompiledBinding Liberate.BookStatus}"
@@ -85,7 +85,7 @@
<controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<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" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@@ -93,7 +93,7 @@
<controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{Binding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Classes="h1" Text="{CompiledBinding Title}" />
</Panel>
@@ -103,7 +103,7 @@
<controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{Binding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Authors}" />
</Panel>
@@ -113,7 +113,7 @@
<controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{Binding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Narrators}" />
</Panel>
@@ -123,7 +123,7 @@
<controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{Binding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Length}" />
</Panel>
@@ -133,7 +133,7 @@
<controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{Binding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Series}" />
</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}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding SeriesOrder}" HorizontalAlignment="Center" />
</Panel>
@@ -153,7 +153,7 @@
<controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{Binding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}">
<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" >
<TextBlock Text="{CompiledBinding Description}" VerticalAlignment="Top" />
</Panel>
@@ -163,7 +163,7 @@
<controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{Binding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Category}" />
</Panel>
@@ -172,7 +172,7 @@
</controls:DataGridTemplateColumnExt>
<controls:DataGridMyRatingColumn
x:DataType="uibase:IGridEntry"
x:DataType="uibase:GridEntry"
Header="Product&#xA;Rating"
IsReadOnly="true"
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}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding PurchaseDate}" />
</Panel>
@@ -192,7 +192,7 @@
</controls:DataGridTemplateColumnExt>
<controls:DataGridMyRatingColumn
x:DataType="uibase:IGridEntry"
x:DataType="uibase:GridEntry"
Header="My Rating"
IsReadOnly="false"
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}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<TextBlock Text="{CompiledBinding Misc}" TextWrapping="WrapWithOverflow" />
</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}">
<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">
<TextBlock Text="{CompiledBinding LastDownload}" TextWrapping="WrapWithOverflow" />
</Panel>
@@ -223,7 +223,7 @@
<controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{Binding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<DataTemplate x:DataType="uibase:GridEntry">
<Button
IsVisible="{CompiledBinding !Liberate.IsSeries}"
VerticalAlignment="Stretch"

View File

@@ -26,7 +26,7 @@ namespace LibationAvalonia.Views
public partial class ProductsDisplay : UserControl
{
public event EventHandler<LibraryBook[]>? LiberateClicked;
public event EventHandler<ISeriesEntry>? LiberateSeriesClicked;
public event EventHandler<SeriesEntry>? LiberateSeriesClicked;
public event EventHandler<LibraryBook[]>? ConvertToMp3Clicked;
public event EventHandler<LibraryBook>? TagsButtonClicked;
@@ -102,7 +102,7 @@ namespace LibationAvalonia.Views
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");
else
e.Row.DynamicResource(DataGridRow.BackgroundProperty, "SystemRegionColor");
@@ -173,10 +173,10 @@ namespace LibationAvalonia.Views
{
switch (column.SortMemberPath)
{
case nameof(IGridEntry.Liberate):
case nameof(GridEntry.Liberate):
column.Width = new DataGridLength(BaseLiberateWidth * scaleFactor);
break;
case nameof(IGridEntry.Cover):
case nameof(GridEntry.Cover):
column.Width = new DataGridLength(BaseCoverWidth * scaleFactor);
break;
}
@@ -220,7 +220,7 @@ namespace LibationAvalonia.Views
#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()
{
@@ -253,7 +253,7 @@ namespace LibationAvalonia.Views
#endregion
#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
{
@@ -301,7 +301,7 @@ namespace LibationAvalonia.Views
#endregion
#region Liberate All (multiple books only)
if (entries.OfType<ILibraryBookEntry>().Count() > 1)
if (entries.OfType<LibraryBookEntry>().Count() > 1)
{
args.ContextMenuItems.Add(new MenuItem
{
@@ -325,7 +325,7 @@ namespace LibationAvalonia.Views
#endregion
#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()
{
@@ -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
{
@@ -391,7 +391,7 @@ namespace LibationAvalonia.Views
#endregion
#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
{
@@ -447,7 +447,7 @@ namespace LibationAvalonia.Views
{
var itemName = column.SortMemberPath;
if (itemName == nameof(IGridEntry.Remove))
if (itemName == nameof(GridEntry.Remove))
continue;
menuItems.Add
@@ -536,7 +536,7 @@ namespace LibationAvalonia.Views
if (sender is not LiberateStatusButton button)
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);
@@ -544,7 +544,7 @@ namespace LibationAvalonia.Views
//to the topright cell. Reset focus onto the clicked button's cell.
button.Focus();
}
else if (button.DataContext is ILibraryBookEntry lbEntry)
else if (button.DataContext is LibraryBookEntry lbEntry)
{
LiberateClicked?.Invoke(this, [lbEntry.LibraryBook]);
}
@@ -558,13 +558,13 @@ namespace LibationAvalonia.Views
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();
}
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;
if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible)
@@ -605,7 +605,7 @@ namespace LibationAvalonia.Views
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 displayWindow = new DescriptionDisplayDialog
@@ -632,7 +632,7 @@ namespace LibationAvalonia.Views
{
var button = args.Source as Button;
if (button?.DataContext is ILibraryBookEntry lbEntry)
if (button?.DataContext is LibraryBookEntry lbEntry)
{
TagsButtonClicked?.Invoke(this, lbEntry.LibraryBook);
}

View File

@@ -1,31 +1,47 @@
using System;
using System.ComponentModel;
using System.Linq;
using Dinah.Core.Logging;
using FileManager;
using Microsoft.Extensions.Configuration;
using Serilog;
using Serilog.Events;
using Serilog.Exceptions;
using Serilog.Settings.Configuration;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
public partial class Configuration
{
private IConfigurationRoot? configuration;
public void ConfigureLogging()
{
//pass explicit assemblies to the ConfigurationReaderOptions
//This is a workaround for the issue where serilog will try to load all
//Assemblies starting with "serilog" from the app folder, but it will fail
//if those assemblies are unreferenced.
//This was a problem when migrating from the ZipFile sink to the File sink.
//Upgrading users would still have the ZipFile sink dll in the program
//folder and serilog would try to load it, unsuccessfully.
//https://github.com/serilog/serilog-settings-configuration/issues/406
var readerOptions = new ConfigurationReaderOptions(
typeof(ILogger).Assembly, // Serilog
typeof(LoggerEnrichmentConfigurationExtensions).Assembly, // Serilog.Exceptions
typeof(ConsoleLoggerConfigurationExtensions).Assembly, // Serilog.Sinks.Console
typeof(FileLoggerConfigurationExtensions).Assembly); // Serilog.Sinks.File
configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.Destructure.ByTransforming<LongPath>(lp => lp.Path)
.CreateLogger();
}
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration, readerOptions)
.Destructure.ByTransforming<LongPath>(lp => lp.Path)
.Destructure.With<LogFileFilter>()
.CreateLogger();
}
[Description("The importance of a log event")]
[Description("The importance of a log event")]
public LogEventLevel LogLevel
{
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.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
#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);
}
public static byte[] GetPictureSynchronously(PictureDefinition def)
public static byte[] GetPictureSynchronously(PictureDefinition def, CancellationToken cancellationToken = default)
{
lock (cacheLocker)
{
@@ -94,7 +95,7 @@ namespace LibationFileManager
var bytes
= File.Exists(path)
? File.ReadAllBytes(path)
: downloadBytes(def);
: downloadBytes(def, cancellationToken);
cache[def] = bytes;
}
return cache[def];
@@ -124,7 +125,7 @@ namespace LibationFileManager
}
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)
return GetDefaultImage(def.Size);
@@ -132,7 +133,7 @@ namespace LibationFileManager
try
{
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
var path = getPath(def);

View File

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

View File

@@ -1,13 +1,35 @@
using LibationFileManager;
using System;
#nullable enable
namespace LibationUiBase
{
public static class BaseUtil
{
/// <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 void SetLoadImageDelegate(Func<byte[], PictureSize, object> tryLoadImage)
=> LoadImage = tryLoadImage;
public static Func<byte[], PictureSize, object?> LoadImage => s_LoadImage ?? DefaultLoadImageImpl;
/// <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 DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using System;
using System.Collections.Generic;
using System.ComponentModel;
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.
//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.
public abstract class EntryStatus : ReactiveObject, IComparable
public class EntryStatus : ReactiveObject, IComparable
{
public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book);
public LiberatedStatus BookStatus
@@ -70,7 +63,7 @@ namespace LibationUiBase.GridView
private readonly bool isAbsent;
private static readonly Dictionary<string, object> iconCache = new();
protected EntryStatus(LibraryBook libraryBook)
internal EntryStatus(LibraryBook libraryBook)
{
Book = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)).Book;
isAbsent = libraryBook.AbsentFromLastScan is true;
@@ -78,9 +71,6 @@ namespace LibationUiBase.GridView
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>
public void Invalidate(params string[] properties)
{
@@ -179,7 +169,7 @@ namespace LibationUiBase.GridView
private object GetAndCacheResource(string rescName)
{
if (!iconCache.ContainsKey(rescName))
iconCache[rescName] = GetResourceImage(rescName);
iconCache[rescName] = BaseUtil.LoadResourceImage(rescName);
return iconCache[rescName];
}
}

View File

@@ -29,17 +29,17 @@ public class GridContextMenu
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 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 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 ReDownloadEnabled => LibraryBookEntries.Any(ge => ge.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated);
private IGridEntry[] GridEntries { get; }
public ILibraryBookEntry[] LibraryBookEntries { get; }
private GridEntry[] GridEntries { get; }
public LibraryBookEntry[] LibraryBookEntries { get; }
public char Accelerator { get; }
public GridContextMenu(IGridEntry[] gridEntries, char accelerator)
public GridContextMenu(GridEntry[] gridEntries, char accelerator)
{
ArgumentNullException.ThrowIfNull(gridEntries, nameof(gridEntries));
ArgumentOutOfRangeException.ThrowIfZero(gridEntries.Length, $"{nameof(gridEntries)}.{nameof(gridEntries.Length)}");
@@ -48,9 +48,9 @@ public class GridContextMenu
Accelerator = accelerator;
LibraryBookEntries
= GridEntries
.OfType<ISeriesEntry>()
.OfType<SeriesEntry>()
.SelectMany(s => s.Children)
.Concat(GridEntries.OfType<ILibraryBookEntry>())
.Concat(GridEntries.OfType<LibraryBookEntry>())
.ToArray();
}

View File

@@ -1,7 +1,6 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using FileLiberator;
using LibationFileManager;
using System;
@@ -9,7 +8,7 @@ using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
namespace LibationUiBase.GridView
@@ -22,7 +21,7 @@ namespace LibationUiBase.GridView
}
/// <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 LibraryBook LibraryBook { get; protected set; }
@@ -101,7 +100,7 @@ namespace LibationUiBase.GridView
LibraryBook = libraryBook;
var expanded = Liberate?.Expanded ?? false;
Liberate = TStatus.Create(libraryBook);
Liberate = new EntryStatus(libraryBook);
Liberate.Expanded = expanded;
Title = Book.TitleWithSubtitle;
@@ -240,7 +239,7 @@ namespace LibationUiBase.GridView
PictureStorage.PictureCached += PictureStorage_PictureCached;
// 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)
@@ -254,7 +253,7 @@ namespace LibationUiBase.GridView
// logic validation
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));
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
@@ -311,6 +310,35 @@ namespace LibationUiBase.GridView
#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()
{
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
public static class QueryExtensions
{
public static IEnumerable<ILibraryBookEntry> BookEntries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.OfType<ILibraryBookEntry>();
public static IEnumerable<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<ISeriesEntry> SeriesEntries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.OfType<ISeriesEntry>();
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> 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);
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);
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;
@@ -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 not null &&
otherSet is not null &&
searchSet.Intersect(otherSet).Count() != searchSet.Count);
[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))
return null;
@@ -59,7 +59,7 @@ namespace LibationUiBase.GridView
var booksFilteredIn = entries.IntersectBy(searchResultSet.Docs.Select(d => d.ProductId), l => l.AudibleProductId);
//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();
}

View File

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

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.Common;
using DataLayer;
@@ -40,9 +40,8 @@ public enum ProcessBookStatus
/// <summary>
/// This is the viewmodel for queued processables
/// </summary>
public abstract class ProcessBookViewModelBase : ReactiveObject
public class ProcessBookViewModel : ReactiveObject
{
private readonly LogMe Logger;
public LibraryBook LibraryBook { get; protected set; }
private ProcessBookResult _result = ProcessBookResult.None;
@@ -84,6 +83,21 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
#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 void NextProcessable() => _currentProcessable = null;
private Processable? _currentProcessable;
@@ -91,10 +105,9 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
/// <summary> A series of Processable actions to perform on this book </summary>
protected Queue<Func<Processable>> Processes { get; } = new();
protected ProcessBookViewModelBase(LibraryBook libraryBook, LogMe logme)
public ProcessBookViewModel(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Logger = logme;
_title = LibraryBook.Book.TitleWithSubtitle;
_author = LibraryBook.Book.AuthorNames();
@@ -106,15 +119,14 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
PictureStorage.PictureCached += PictureStorage_PictureCached;
// 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)
{
if (e.Definition.PictureId == LibraryBook.Book.PictureId)
{
Cover = LoadImageFromBytes(e.Picture, PictureSize._80x80);
Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
@@ -133,36 +145,36 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
result = ProcessBookResult.Success;
else if (statusHandler.Errors.Contains("Cancelled"))
{
Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}");
LogInfo($"{procName}: Process was cancelled - {LibraryBook.Book}");
result = ProcessBookResult.Cancelled;
}
else if (statusHandler.Errors.Contains("Validation failed"))
{
Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}");
LogInfo($"{procName}: Validation failed - {LibraryBook.Book}");
result = ProcessBookResult.ValidationFail;
}
else
{
foreach (var errorMessage in statusHandler.Errors)
Logger.Error($"{procName}: {errorMessage}");
LogError($"{procName}: {errorMessage}");
}
}
catch (ContentLicenseDeniedException ldex)
{
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;
}
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;
}
}
catch (Exception ex)
{
Logger.Error(ex, procName);
LogError(procName, ex);
}
finally
{
@@ -192,15 +204,15 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
}
catch (Exception ex)
{
Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling");
LogError($"{CurrentProcessable.Name}: Error while cancelling", ex);
}
}
public ProcessBookViewModelBase AddDownloadPdf() => AddProcessable<DownloadPdf>();
public ProcessBookViewModelBase AddDownloadDecryptBook() => AddProcessable<DownloadDecryptBook>();
public ProcessBookViewModelBase AddConvertToMp3() => AddProcessable<ConvertToMp3>();
public ProcessBookViewModel AddDownloadPdf() => AddProcessable<DownloadPdf>();
public ProcessBookViewModel AddDownloadDecryptBook() => AddProcessable<DownloadDecryptBook>();
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());
return this;
@@ -252,7 +264,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors;
private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators;
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)
{
@@ -292,7 +304,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
Status = ProcessBookStatus.Working;
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;
Author = libraryBook.Book.AuthorNames();
@@ -303,7 +315,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
{
if (sender is Processable processable)
{
Logger.Info($"{processable.Name} Step, Completed: {libraryBook.Book}");
LogInfo($"{processable.Name} Step, Completed: {libraryBook.Book}");
UnlinkProcessable(processable);
}
@@ -329,7 +341,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
if (result.HasErrors)
{
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)
{
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
{
@@ -353,7 +365,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
if (dialogResult == SkipResult)
{
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
@@ -411,4 +423,4 @@ public abstract class ProcessBookViewModelBase : ReactiveObject
}
#endregion
}
}

View File

@@ -1,30 +1,32 @@
using DataLayer;
using ApplicationServices;
using DataLayer;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using ApplicationServices;
using System.Threading.Tasks;
#nullable enable
namespace LibationUiBase.ProcessQueue;
public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
public record LogEntry(DateTime LogDate, string LogMessage)
{
public abstract void WriteLine(string text);
protected abstract ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook);
public string LogDateString => LogDate.ToShortTimeString();
}
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 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.CompletedCountChanged += Queue_CompletedCountChanged;
SpeedLimit = LibationFileManager.Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
}
private int _completedCount;
@@ -32,6 +34,7 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
private int _queuedCount;
private string? _runningTime;
private bool _progressBarVisible;
private decimal _speedLimit;
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)); } }
@@ -42,6 +45,32 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
public bool AnyQueued => QueuedCount > 0;
public bool AnyErrors => ErrorCount > 0;
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)
{
@@ -59,6 +88,9 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
RaisePropertyChanged(nameof(Progress));
}
private void ProcessBook_LogWritten(object? sender, string logMessage)
=> Invoke(() => LogEntries.Add(new(DateTime.Now, logMessage.Trim())));
#region Add Books to Queue
public bool QueueDownloadPdf(IList<LibraryBook> libraryBooks)
@@ -124,47 +156,50 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
}
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)
: true;
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
&& 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();
Serilog.Log.Logger.Information("Queueing {count} books for PDF-only download", procs.Length);
AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddDownloadPdf();
ProcessBookViewModel Create(LibraryBook entry)
=> 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();
Serilog.Log.Logger.Information("Queueing {count} books ofr download/decrypt", procs.Length);
AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddDownloadDecryptBook().AddDownloadPdf();
ProcessBookViewModel Create(LibraryBook entry)
=> 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();
Serilog.Log.Logger.Information("Queueing {count} books for mp3 conversion", procs.Length);
AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddConvertToMp3();
ProcessBookViewModel Create(LibraryBook entry)
=> 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);
if (!Running)
QueueRunner = Task.Run(QueueLoop);
@@ -187,7 +222,7 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
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.");
continue;

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
#nullable enable
@@ -7,7 +9,7 @@ namespace LibationUiBase
{
public enum QueuePosition
{
Fisrt,
First,
OneUp,
OneDown,
Last,
@@ -22,38 +24,21 @@ namespace LibationUiBase
* 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
* 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.
* last one in the chain.
*/
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>? QueuedCountChanged;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public T? Current { get; private set; }
public IReadOnlyList<T> Queued => _queued;
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 object lockObject = new();
private readonly ICollection<T>? _underlyingList;
public ICollection<T>? UnderlyingList => _underlyingList;
public TrackedQueue(ICollection<T>? underlyingList = null)
{
_underlyingList = underlyingList;
}
private int QueueStartIndex => Completed.Count + (Current is null ? 0 : 1);
public T this[int index]
{
@@ -61,17 +46,10 @@ namespace LibationUiBase
{
lock (lockObject)
{
if (index < _completed.Count)
return _completed[index];
index -= _completed.Count;
if (index == 0 && Current != null) return Current;
if (Current != null) index--;
if (index < _queued.Count) return _queued.ElementAt(index);
throw new IndexOutOfRangeException();
return index < Completed.Count ? Completed[index]
: index == Completed.Count && Current is not null ? Current
: index < Count ? Queued[index - QueueStartIndex]
: throw new IndexOutOfRangeException();
}
}
}
@@ -82,7 +60,7 @@ namespace LibationUiBase
{
lock (lockObject)
{
return _queued.Count + _completed.Count + (Current == null ? 0 : 1);
return QueueStartIndex + Queued.Count;
}
}
}
@@ -91,131 +69,117 @@ namespace LibationUiBase
{
lock (lockObject)
{
if (_completed.Contains(item))
return _completed.IndexOf(item);
if (Current == item) return _completed.Count;
if (_queued.Contains(item))
return _queued.IndexOf(item) + (Current is null ? 0 : 1);
return -1;
int index = _completed.IndexOf(item);
if (index < 0 && item == Current)
index = Completed.Count;
if (index < 0)
{
index = Queued.IndexOf(item);
if (index >= 0)
index += QueueStartIndex;
}
return index;
}
}
public bool RemoveQueued(T item)
{
bool itemsRemoved;
int queuedCount;
int queuedCount, queueIndex;
lock (lockObject)
{
itemsRemoved = _queued.Remove(item);
queuedCount = _queued.Count;
queueIndex = Queued.IndexOf(item);
if (queueIndex >= 0)
Queued.RemoveAt(queueIndex);
queuedCount = Queued.Count;
}
if (itemsRemoved)
if (queueIndex >= 0)
{
QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary();
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, item, QueueStartIndex + queueIndex));
return true;
}
return itemsRemoved;
}
public void ClearCurrent()
{
lock (lockObject)
Current = null;
RebuildSecondary();
return false;
}
public bool RemoveCompleted(T item)
{
bool itemsRemoved;
int completedCount;
int completedCount, completedIndex;
lock (lockObject)
{
itemsRemoved = _completed.Remove(item);
completedIndex = _completed.IndexOf(item);
if (completedIndex >= 0)
_completed.RemoveAt(completedIndex);
completedCount = _completed.Count;
}
if (itemsRemoved)
if (completedIndex >= 0)
{
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()
{
List<T> queuedItems;
lock (lockObject)
_queued.Clear();
{
queuedItems = Queued.ToList();
Queued.Clear();
}
QueuedCountChanged?.Invoke(this, 0);
RebuildSecondary();
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, queuedItems, QueueStartIndex));
}
public void ClearCompleted()
{
List<T> completedItems;
lock (lockObject)
{
completedItems = _completed.ToList();
_completed.Clear();
}
CompletedCountChanged?.Invoke(this, 0);
RebuildSecondary();
}
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);
}
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, completedItems, 0));
}
public void MoveQueuePosition(T item, QueuePosition requestedPosition)
{
int oldIndex, newIndex;
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)
return;
if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item)
if (oldIndex < 0 || newIndex < 0 || newIndex >= Queued.Count || newIndex == oldIndex)
return;
int queueIndex = _queued.IndexOf(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);
}
Queued.RemoveAt(oldIndex);
Queued.Insert(newIndex, item);
}
RebuildSecondary();
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Move, item, QueueStartIndex + newIndex, QueueStartIndex + oldIndex));
}
public bool MoveNext()
@@ -232,15 +196,15 @@ namespace LibationUiBase
completedCount = _completed.Count;
completedChanged = true;
}
if (_queued.Count == 0)
if (Queued.Count == 0)
{
Current = null;
return false;
}
Current = _queued[0];
_queued.RemoveAt(0);
Current = Queued[0];
Queued.RemoveAt(0);
queuedCount = _queued.Count;
queuedCount = Queued.Count;
return true;
}
}
@@ -249,34 +213,48 @@ namespace LibationUiBase
if (completedChanged)
CompletedCountChanged?.Invoke(this, completedCount);
QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary();
}
}
public void Enqueue(IEnumerable<T> item)
public void Enqueue(IList<T> item)
{
int queueCount;
lock (lockObject)
{
_queued.AddRange(item);
queueCount = _queued.Count;
Queued.AddRange(item);
queueCount = Queued.Count;
}
foreach (var i in item)
_underlyingList?.Add(i);
QueuedCountChanged?.Invoke(this, queueCount);
}
private void RebuildSecondary()
{
_underlyingList?.Clear();
foreach (var item in GetAllItems())
_underlyingList?.Add(item);
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, item, QueueStartIndex + Queued.Count));
}
public IEnumerable<T> GetAllItems()
{
if (Current is null) return Completed.Concat(Queued);
return Completed.Concat(new List<T> { Current }).Concat(Queued);
lock (lockObject)
{
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
{
private readonly IWin32Window _owner;
protected WinformLoginBase(IWin32Window owner)
protected Control Owner { get; }
protected WinformLoginBase(Control owner)
{
_owner = owner;
Owner = owner;
}
/// <returns>True if ShowDialog's DialogResult == OK</returns>
protected bool ShowDialog(Form dialog)
{
var result = dialog.ShowDialog(_owner);
Serilog.Log.Logger.Debug("{@DebugInfo}", new { DialogResult = result });
return result == DialogResult.OK;
}
=> Owner.Invoke(() =>
{
var result = dialog.ShowDialog(Owner);
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 WinformLoginCallback(Account account, IWin32Window owner) : base(owner)
public WinformLoginCallback(Account account, Control owner) : base(owner)
{
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
}
public Task<string> Get2faCodeAsync(string prompt)
{
using var dialog = new _2faCodeDialog(prompt);
if (ShowDialog(dialog))
return Task.FromResult(dialog.Code);
return Task.FromResult<string>(null);
}
=> Owner.Invoke(() =>
{
using var dialog = new _2faCodeDialog(prompt);
if (ShowDialog(dialog))
return Task.FromResult(dialog.Code);
return Task.FromResult<string>(null);
});
public Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
{
using var dialog = new CaptchaDialog(password, captchaImage);
if (ShowDialog(dialog))
return Task.FromResult((dialog.Password, dialog.Answer));
return Task.FromResult<(string, string)>((null,null));
}
=> Owner.Invoke(() =>
{
using var dialog = new CaptchaDialog(password, captchaImage);
if (ShowDialog(dialog))
return Task.FromResult((dialog.Password, dialog.Answer));
return Task.FromResult<(string, string)>((null, null));
});
public Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
{
using var dialog = new MfaDialog(mfaConfig);
if (ShowDialog(dialog))
return Task.FromResult((dialog.SelectedName, dialog.SelectedValue));
return Task.FromResult<(string, string)>((null, null));
}
=> Owner.Invoke(() =>
{
using var dialog = new MfaDialog(mfaConfig);
if (ShowDialog(dialog))
return Task.FromResult((dialog.SelectedName, dialog.SelectedValue));
return Task.FromResult<(string, string)>((null, null));
});
public Task<(string email, string password)> GetLoginAsync()
{
using var dialog = new LoginCallbackDialog(_account);
if (ShowDialog(dialog))
return Task.FromResult((dialog.Email, dialog.Password));
return Task.FromResult<(string, string)>((null, null));
}
=> Owner.Invoke(() =>
{
using var dialog = new LoginCallbackDialog(_account);
if (ShowDialog(dialog))
return Task.FromResult((dialog.Email, dialog.Password));
return Task.FromResult<(string, string)>((null, null));
});
public Task ShowApprovalNeededAsync()
{
using var dialog = new ApprovalNeededDialog();
ShowDialog(dialog);
return Task.CompletedTask;
}
=> Owner.Invoke(() =>
{
using var dialog = new ApprovalNeededDialog();
ShowDialog(dialog);
return Task.CompletedTask;
});
}
}

View File

@@ -13,13 +13,16 @@ namespace LibationWinForms.Login
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));
LoginCallback = new WinformLoginCallback(_account, owner);
}
public Task<ChoiceOut> StartAsync(ChoiceIn choiceIn)
=> Owner.Invoke(() => StartAsyncInternal(choiceIn));
private Task<ChoiceOut> StartAsyncInternal(ChoiceIn choiceIn)
{
if (Environment.OSVersion.Version.Major >= 10)
{

View File

@@ -11,7 +11,13 @@ namespace LibationWinForms.Dialogs
{
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)
{

View File

@@ -538,7 +538,7 @@
this.productsDisplay.VisibleCountChanged += new System.EventHandler<int>(this.productsDisplay_VisibleCountChanged);
this.productsDisplay.RemovableCountChanged += new System.EventHandler<int>(this.productsDisplay_RemovableCountChanged);
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.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
{

View File

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

View File

@@ -74,7 +74,7 @@ namespace LibationWinForms
{
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
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));
BaseUtil.SetLoadImageDelegate(WinFormsUtil.TryLoadImageOrDefault);
BaseUtil.SetLoadResourceImageDelegate(Properties.Resources.ResourceManager.GetObject);
// wire-up event to automatically download after scan.
// 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)
{
// 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);
}

View File

@@ -24,24 +24,24 @@ namespace LibationWinForms.GridView
* event. Adding or removing from the underlying list will not change the
* 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;
ListChanged += GridEntryBindingList_ListChanged;
}
/// <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>
public IEnumerable<ILibraryBookEntry> GetFilteredInItems()
public IEnumerable<LibraryBookEntry> GetFilteredInItems()
=> FilteredInGridEntries?
.OfType<ILibraryBookEntry>()
.OfType<LibraryBookEntry>()
?? FilterRemoved
.OfType<ILibraryBookEntry>()
.Union(Items.OfType<ILibraryBookEntry>());
.OfType<LibraryBookEntry>()
.Union(Items.OfType<LibraryBookEntry>());
public bool SupportsFiltering => true;
public string Filter
@@ -67,12 +67,12 @@ namespace LibationWinForms.GridView
protected override ListSortDirection SortDirectionCore => Comparer.SortOrder;
/// <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 bool isSorted;
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>
private HashSet<IGridEntry> FilteredInGridEntries;
private HashSet<GridEntry> FilteredInGridEntries;
#region Unused - Advanced Filtering
public bool SupportsAdvancedSorting => false;
@@ -84,7 +84,7 @@ namespace LibationWinForms.GridView
public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException();
#endregion
public new void Remove(IGridEntry entry)
public new void Remove(GridEntry entry)
{
FilterRemoved.Remove(entry);
base.Remove(entry);
@@ -122,13 +122,13 @@ namespace LibationWinForms.GridView
ResetList();
RaiseListChangedEvents = priorState;
void addRemovedItemsBack(List<IGridEntry> addBackEntries)
void addRemovedItemsBack(List<GridEntry> addBackEntries)
{
//Add removed entries back into Items so they are displayed
//(except for episodes that are collapsed)
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;
FilterRemoved.Remove(addBack);
@@ -160,7 +160,7 @@ namespace LibationWinForms.GridView
ExpandItem(series);
}
public void CollapseItem(ISeriesEntry sEntry)
public void CollapseItem(SeriesEntry sEntry)
{
foreach (var episode in sEntry.Children.Intersect(Items.BookEntries()).ToList())
{
@@ -171,7 +171,7 @@ namespace LibationWinForms.GridView
sEntry.Liberate.Expanded = false;
}
public void ExpandItem(ISeriesEntry sEntry)
public void ExpandItem(SeriesEntry sEntry)
{
var sindex = Items.IndexOf(sEntry);
@@ -208,7 +208,7 @@ namespace LibationWinForms.GridView
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.
var sortedItems = Comparer.OrderEntries(itemsList).ToList();

View File

@@ -1,7 +1,9 @@
using DataLayer;
using LibationUiBase.GridView;
using System.Drawing;
using System.Windows.Forms;
#nullable enable
namespace LibationWinForms.GridView
{
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 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)
//Don't paint the button graphic
paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground;
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = (Color)status.BackgroundBrush;
DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = status.Opacity == 1 ? DataGridView.DefaultCellStyle.ForeColor : HiddenForeColor;
row.DefaultCellStyle.BackColor = status.IsEpisode ? SERIES_BG_COLOR : grid.DefaultCellStyle.BackColor;
row.DefaultCellStyle.ForeColor = status.Opacity == 1 ? grid.DefaultCellStyle.ForeColor : HiddenForeColor;
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
DrawButtonImage(graphics, (Image)status.ButtonImage, cellBounds);

View File

@@ -22,7 +22,7 @@ namespace LibationWinForms.GridView
public event EventHandler<int> VisibleCountChanged;
public event EventHandler<int> RemovableCountChanged;
public event EventHandler<LibraryBook[]> LiberateClicked;
public event EventHandler<ISeriesEntry> LiberateSeriesClicked;
public event EventHandler<SeriesEntry> LiberateSeriesClicked;
public event EventHandler<LibraryBook[]> ConvertToMp3Clicked;
public event EventHandler InitialLoaded;
@@ -36,7 +36,7 @@ namespace LibationWinForms.GridView
#region Button controls
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);
@@ -71,7 +71,7 @@ namespace LibationWinForms.GridView
imageDisplay.Show(null);
}
private void productsGrid_DescriptionClicked(IGridEntry liveGridEntry, Rectangle cellRectangle)
private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle)
{
var displayWindow = new DescriptionDisplay
{
@@ -90,7 +90,7 @@ namespace LibationWinForms.GridView
displayWindow.Show(this);
}
private async void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry)
private async void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry)
{
// HACK: workaround for a Winforms bug.
// This event is fired by the DataGridCell.OnMouseUpInternal
@@ -124,12 +124,12 @@ namespace LibationWinForms.GridView
#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, '&');
#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()
{
@@ -166,7 +166,7 @@ namespace LibationWinForms.GridView
#endregion
#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 };
ctxMenu.Items.Add(locateFileMenuItem);
@@ -199,7 +199,7 @@ namespace LibationWinForms.GridView
#endregion
#region Liberate All (multiple books only)
if (entries.OfType<ILibraryBookEntry>().Count() > 1)
if (entries.OfType<LibraryBookEntry>().Count() > 1)
{
var downloadSelectedMenuItem = new ToolStripMenuItem()
{
@@ -230,7 +230,7 @@ namespace LibationWinForms.GridView
#endregion
#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()
{
@@ -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 fileTemplateMenuItem = new ToolStripMenuItem { Text = ctx.FileTemplateText };
@@ -288,7 +288,7 @@ namespace LibationWinForms.GridView
#endregion
#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 };
bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry3.LibraryBook).ShowDialog(this);
@@ -417,7 +417,7 @@ namespace LibationWinForms.GridView
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
&& !liveGridEntry.Liberate.IsUnavailable)

View File

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

View File

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

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.Windows.Forms;
#nullable enable
namespace LibationWinForms.ProcessQueue
{
internal partial class ProcessBookControl : UserControl
{
private readonly int CancelBtnDistanceFromEdge;
private readonly int ProgressBarDistanceFromEdge;
private object? m_OldContext;
private static Color FailedColor { get; } = Color.LightCoral;
private static Color CancelledColor { get; } = Color.Khaki;
private static Color QueuedColor { get; } = SystemColors.Control;
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()
{
InitializeComponent();
@@ -41,35 +28,41 @@ namespace LibationWinForms.ProcessQueue
ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width;
}
private void OnContextChanging()
protected override void OnDataContextChanged(EventArgs e)
{
if (Context is not null)
Context.PropertyChanged -= Context_PropertyChanged;
if (m_OldContext is ProcessBookViewModel oldContext)
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;
Context_PropertyChanged(this, new System.ComponentModel.PropertyChangedEventArgs(null));
}
if (sender is not ProcessBookViewModel vm)
return;
private void Context_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
SuspendLayout();
if (e.PropertyName is null or nameof(Context.Cover))
SetCover(Context.Cover as Image);
if (e.PropertyName is null or nameof(Context.Title) or nameof(Context.Author) or nameof(Context.Narrator))
SetBookInfo($"{Context.Title}\r\nBy {Context.Author}\r\nNarrated by {Context.Narrator}");
if (e.PropertyName is null or nameof(Context.Status) or nameof(Context.StatusText))
SetStatus(Context.Status, Context.StatusText);
if (e.PropertyName is null or nameof(Context.Progress))
SetProgress(Context.Progress);
if (e.PropertyName is null or nameof(Context.TimeRemaining))
SetRemainingTime(Context.TimeRemaining);
if (e.PropertyName is null or nameof(vm.Cover))
SetCover(vm.Cover as Image);
if (e.PropertyName is null or nameof(vm.Title) or nameof(vm.Author) or nameof(vm.Narrator))
SetBookInfo($"{vm.Title}\r\nBy {vm.Author}\r\nNarrated by {vm.Narrator}");
if (e.PropertyName is null or nameof(vm.Status) or nameof(vm.StatusText))
SetStatus(vm.Status, vm.StatusText);
if (e.PropertyName is null or nameof(vm.Progress))
SetProgress(vm.Progress);
if (e.PropertyName is null or nameof(vm.TimeRemaining))
SetRemainingTime(vm.TimeRemaining);
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 SetRemainingTime(TimeSpan remaining)
=> 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 LibationUiBase;
using LibationUiBase.ProcessQueue;
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
@@ -27,49 +28,47 @@ internal partial class ProcessQueueControl : UserControl
{
InitializeComponent();
var speedLimitMBps = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
numericUpDown1.Value = speedLimitMBps > numericUpDown1.Maximum || speedLimitMBps < numericUpDown1.Minimum ? 0 : speedLimitMBps;
statusStrip1.Items.Add(PopoutButton);
virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked;
virtualFlowControl2.DataContext = ViewModel.Queue;
ViewModel.LogWritten += (_, text) => WriteLine(text);
ViewModel.PropertyChanged += ProcessQueue_PropertyChanged;
virtualFlowControl2.Items = ViewModel.Items;
Load += ProcessQueueControl_Load;
ViewModel.LogEntries.CollectionChanged += LogEntries_CollectionChanged;
}
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;
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)
{
ViewModel.Queue.ClearQueue();
if (ViewModel.Queue.Current is not null)
await ViewModel.Queue.Current.CancelAsync();
virtualFlowControl2.RefreshDisplay();
}
private void btnClearFinished_Click(object? sender, EventArgs e)
{
ViewModel.Queue.ClearCompleted();
virtualFlowControl2.RefreshDisplay();
if (!ViewModel.Running)
runningTimeLbl.Text = string.Empty;
}
private void clearLogBtn_Click(object? sender, EventArgs e)
{
ViewModel.LogEntries.Clear();
logDGV.Rows.Clear();
}
@@ -92,7 +91,6 @@ internal partial class ProcessQueueControl : UserControl
{
queueNumberLbl.Text = ViewModel.QueuedCount.ToString();
queueNumberLbl.Visible = ViewModel.QueuedCount > 0;
virtualFlowControl2.RefreshDisplay();
}
if (e.PropertyName is null or nameof(ViewModel.ErrorCount))
{
@@ -117,16 +115,22 @@ internal partial class ProcessQueueControl : UserControl
{
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>
/// View notified the model that a botton was clicked
/// View notified the model that a button was clicked
/// </summary>
/// <param name="sender">the <see cref="ProcessBookControl"/> whose button was clicked</param>
/// <param name="buttonName">The name of the button clicked</param>
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;
try
@@ -135,13 +139,12 @@ internal partial class ProcessQueueControl : UserControl
{
await item.CancelAsync();
ViewModel.Queue.RemoveQueued(item);
virtualFlowControl2.RefreshDisplay();
}
else
{
QueuePosition? position = buttonName switch
{
nameof(ProcessBookControl.moveFirstBtn) => QueuePosition.Fisrt,
nameof(ProcessBookControl.moveFirstBtn) => QueuePosition.First,
nameof(ProcessBookControl.moveUpBtn) => QueuePosition.OneUp,
nameof(ProcessBookControl.moveDownBtn) => QueuePosition.OneDown,
nameof(ProcessBookControl.moveLastBtn) => QueuePosition.Last,
@@ -149,10 +152,7 @@ internal partial class ProcessQueueControl : UserControl
};
if (position is not null)
{
ViewModel.Queue.MoveQueuePosition(item, position.Value);
virtualFlowControl2.RefreshDisplay();
}
}
}
catch(Exception ex)
@@ -164,27 +164,7 @@ internal partial class ProcessQueueControl : UserControl
#endregion
private void numericUpDown1_ValueChanged(object? sender, EventArgs e)
{
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;
}
=> ViewModel.SpeedLimit = numericUpDown1.Value;
}
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.Specialized;
using System.Drawing;
using System.Windows.Forms;
#nullable enable
namespace LibationWinForms.ProcessQueue
{
internal partial class VirtualFlowControl : UserControl
@@ -11,18 +13,28 @@ namespace LibationWinForms.ProcessQueue
/// <summary>
/// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked
/// </summary>
public event EventHandler<string> ButtonClicked;
public event EventHandler<string>? ButtonClicked;
public IList? Items { get; private set; }
private List<ProcessBookViewModelBase> m_Items;
public List<ProcessBookViewModelBase> Items
private object? m_OldContext;
protected override void OnDataContextChanged(EventArgs e)
{
get => m_Items;
set
if (m_OldContext is INotifyCollectionChanged oldNotify)
oldNotify.CollectionChanged -= Items_CollectionChanged;
if (DataContext is INotifyCollectionChanged newNotify)
{
m_Items = value;
if (m_Items is not null)
RefreshDisplay();
m_OldContext = newNotify;
newNotify.CollectionChanged += Items_CollectionChanged;
}
Items = DataContext as IList;
base.OnDataContextChanged(e);
}
private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RefreshDisplay();
}
public void RefreshDisplay()
@@ -65,7 +77,7 @@ namespace LibationWinForms.ProcessQueue
#region Instance variables
/// <summary>
/// The total height, inclusing margins, of the repeated <see cref="ProcessBookControl"/>
/// The total height, including margins, of the repeated <see cref="ProcessBookControl"/>
/// </summary>
private readonly int VirtualControlHeight;
/// <summary>
@@ -137,14 +149,15 @@ namespace LibationWinForms.ProcessQueue
/// <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
/// </summary>
private void ControlButton_Click(object sender, EventArgs e)
private void ControlButton_Click(object? sender, EventArgs e)
{
Control button = sender as Control;
Control form = button.Parent;
while (form is not ProcessBookControl)
form = form.Parent;
Control? button = sender as Control;
Control? form = button?.Parent;
while (form is not null and not ProcessBookControl)
form = form?.Parent;
ButtonClicked?.Invoke(form, button.Name);
if (form is not null && button?.Name is string buttonText)
ButtonClicked?.Invoke(form, buttonText);
}
/// <summary>
@@ -176,7 +189,7 @@ namespace LibationWinForms.ProcessQueue
}
/// <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
/// the context corresponding to the virtual scroll position
/// </summary>
@@ -195,9 +208,10 @@ namespace LibationWinForms.ProcessQueue
numVisible = Math.Min(numVisible, VirtualControlCount);
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++)

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
* https://github.com/Mbucari/serilog-sinks-zipfile