Merge pull request #1308 from Mbucari/master

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

View File

@ -25,9 +25,8 @@ namespace AaxDecrypter
public override async Task CancelAsync()
{
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

@ -7,10 +7,9 @@
<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,12 @@ namespace FileLiberator
}
return new StatusHandler();
}
finally
}
catch when (cancellationToken.IsCancellationRequested)
{
return new StatusHandler { "Cancelled" };
}
finally
{
OnCompleted(libraryBook);
}
@ -257,16 +262,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 +284,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 +294,8 @@ namespace FileLiberator
SetFileTime(libraryBook, cue.Path);
}
AudibleFileStorage.Audio.Refresh();
cancellationToken.ThrowIfCancellationRequested();
AudibleFileStorage.Audio.Refresh();
}
private static string getDestinationDirectory(LibraryBook libraryBook)
@ -301,7 +309,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 +324,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 +335,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,6 +1,5 @@
using System;
using System.ComponentModel;
using System.Linq;
using Dinah.Core.Logging;
using FileManager;
using Microsoft.Extensions.Configuration;
@ -10,7 +9,7 @@ using Serilog.Events;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
public partial class Configuration
{
private IConfigurationRoot? configuration;
@ -19,13 +18,14 @@ namespace LibationFileManager
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)
.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