diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 37063783..44efcb1e 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -15,6 +15,10 @@ on: description: "Skip running unit tests" required: false default: true + architecture: + type: string + description: "CPU architecture targeted by the build." + required: true env: DOTNET_CONFIGURATION: "Release" @@ -22,8 +26,11 @@ env: jobs: build: - name: "${{ matrix.os }}-${{ matrix.release_name }}" + name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}" runs-on: windows-latest + env: + OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}" + RUNTIME_ID: "win-${{ inputs.architecture }}" strategy: matrix: os: [Windows] @@ -63,38 +70,42 @@ jobs: run: | dotnet publish ` Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj ` + --runtime ${{ env.RUNTIME_ID }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` + --output bin/Publish/${{ env.OUTPUT_NAME }} ` -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml dotnet publish ` LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj ` + --runtime ${{ env.RUNTIME_ID }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` + --output bin/Publish/${{ env.OUTPUT_NAME }} ` -p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml dotnet publish ` LibationCli/LibationCli.csproj ` + --runtime ${{ env.RUNTIME_ID }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` + --output bin/Publish/${{ env.OUTPUT_NAME }} ` -p:DefineConstants="${{ matrix.release_name }}" ` -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml dotnet publish ` Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj ` + --runtime ${{ env.RUNTIME_ID }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` + --output bin/Publish/${{ env.OUTPUT_NAME }} ` -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml - name: Zip artifact id: zip working-directory: ./Source/bin/Publish run: | - $bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\" + $bin_dir = "${{ env.OUTPUT_NAME }}\" $delfiles = @( "WindowsConfigApp.exe", "WindowsConfigApp.runtimeconfig.json", "WindowsConfigApp.deps.json" ) foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } } - $artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}" + $artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}" "artifact=$artifact" >> $env:GITHUB_OUTPUT Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a67098ea..341f8033 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,10 +18,14 @@ on: jobs: windows: + strategy: + matrix: + architecture: [x64] uses: ./.github/workflows/build-windows.yml with: version_override: ${{ inputs.version_override }} run_unit_tests: ${{ inputs.run_unit_tests }} + architecture: ${{ matrix.architecture }} linux: strategy: diff --git a/.releaseindex.json b/.releaseindex.json index 9d9dea23..7ae58e51 100644 --- a/.releaseindex.json +++ b/.releaseindex.json @@ -1,10 +1,10 @@ { - "WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip", - "WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip", - "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb", - "LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm", - "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz", - "LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb", - "LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm", - "MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz" + "WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip", + "WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip", + "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb", + "LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm", + "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz", + "LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb", + "LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm", + "MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz" } diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index 8255f5b9..d585820a 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -13,7 +13,7 @@ - + diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 19c154b0..41d25595 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -290,33 +290,24 @@ namespace AppScaffolding public static UpgradeProperties GetLatestRelease() { // timed out - (var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10)); + (var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10)); - if (latest is null || zip is null) - return null; - - var latestVersionString = latest.TagName.Trim('v'); - if (!Version.TryParse(latestVersionString, out var latestRelease)) - return null; - - // we're up to date - if (latestRelease <= BuildVersion) + if (version is null || latest is null || zip is null) return null; // we have an update - var zipUrl = zip?.BrowserDownloadUrl; Log.Logger.Information("Update available: {@DebugInfo}", new { - latestRelease = latestRelease.ToString(), + latestRelease = version.ToString(), latest.HtmlUrl, zipUrl }); - return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body); + return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body); } - private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout) + private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout) { try { @@ -330,15 +321,23 @@ namespace AppScaffolding { Log.Logger.Error(aggEx, "Checking for new version too often"); } - return (null, null); + return (null, null, null); } - private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease() + private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease() { const string ownerAccount = "rmcrackan"; const string repoName = "Libation"; var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName)); + //https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release + var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName); + + //Ensure that latest release is greater than the current version + var latestVersionString = latestRelease.TagName.Trim('v'); + if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion) + return (null, null, null); + //Download the release index var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json"); var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts)); @@ -356,10 +355,7 @@ namespace AppScaffolding var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); - //https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release - var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName); - - return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name))); + return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name))); } } diff --git a/Source/ApplicationServices/ApplicationServices.csproj b/Source/ApplicationServices/ApplicationServices.csproj index 0fcb98cc..f59184aa 100644 --- a/Source/ApplicationServices/ApplicationServices.csproj +++ b/Source/ApplicationServices/ApplicationServices.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index c550207d..8007d316 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/DataLayer/DataLayer.csproj b/Source/DataLayer/DataLayer.csproj index ef9dd47c..aa0eff88 100644 --- a/Source/DataLayer/DataLayer.csproj +++ b/Source/DataLayer/DataLayer.csproj @@ -12,12 +12,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 91f89995..deca475b 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -103,13 +103,11 @@ namespace DataLayer ) == true ).ToList(); - public static IEnumerable UnLiberated(this IEnumerable bookList) - => bookList - .Where( - lb => - !lb.AbsentFromLastScan && - (lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload - || lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) - ); + public static bool NeedsPdfDownload(this LibraryBook libraryBook) + => !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated; + public static bool NeedsBookDownload(this LibraryBook libraryBook) + => !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload; + public static IEnumerable UnLiberated(this IEnumerable bookList) + => bookList.Where(lb => lb.NeedsPdfDownload() || lb.NeedsBookDownload()); } } diff --git a/Source/FileManager/FileManager.csproj b/Source/FileManager/FileManager.csproj index c66f3236..df55768f 100644 --- a/Source/FileManager/FileManager.csproj +++ b/Source/FileManager/FileManager.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj index fd506340..9b140e6a 100644 --- a/Source/HangoverAvalonia/HangoverAvalonia.csproj +++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj @@ -71,12 +71,12 @@ - - + + - - - + + + diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index 3201b953..9c89e841 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -16,6 +16,8 @@ using Dinah.Core; using LibationAvalonia.Themes; using Avalonia.Data.Core.Plugins; using System.Linq; +using LibationUiBase.Forms; +using Avalonia.Controls; #nullable enable namespace LibationAvalonia @@ -42,6 +44,9 @@ namespace LibationAvalonia if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { + MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) => + MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition); + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins DisableAvaloniaDataAnnotationValidation(); diff --git a/Source/LibationAvalonia/AvaloniaUtils.cs b/Source/LibationAvalonia/AvaloniaUtils.cs index 11508b70..4bfc1aa7 100644 --- a/Source/LibationAvalonia/AvaloniaUtils.cs +++ b/Source/LibationAvalonia/AvaloniaUtils.cs @@ -4,6 +4,7 @@ using Avalonia.Markup.Xaml.MarkupExtensions; using Avalonia.Media.Imaging; using Avalonia.VisualTree; using LibationFileManager; +using LibationUiBase.Forms; using System.Threading.Tasks; #nullable enable diff --git a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs index b5ac7fcc..fe51170e 100644 --- a/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/DirectoryOrCustomSelectControl.axaml.cs @@ -1,5 +1,6 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Platform.Storage; using Dinah.Core; using LibationFileManager; using ReactiveUI; @@ -90,7 +91,7 @@ namespace LibationAvalonia.Controls var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options); - directoryState.CustomDir = selectedFolders.SingleOrDefault()?.Path?.LocalPath ?? directoryState.CustomDir; + directoryState.CustomDir = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? directoryState.CustomDir; } private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs index 8cb6af48..a7f1d601 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs @@ -4,6 +4,7 @@ using LibationAvalonia.Dialogs; using LibationAvalonia.ViewModels.Settings; using LibationFileManager; using LibationFileManager.Templates; +using LibationUiBase.Forms; using System.Linq; using System.Threading.Tasks; diff --git a/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs b/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs index 0a55b0e4..2be2bb5b 100644 --- a/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs @@ -3,6 +3,7 @@ using LibationAvalonia.Dialogs; using LibationAvalonia.ViewModels.Settings; using LibationFileManager; using LibationFileManager.Templates; +using LibationUiBase.Forms; using System.Threading.Tasks; namespace LibationAvalonia.Controls.Settings diff --git a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs index deeaf945..214ea33b 100644 --- a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs @@ -1,12 +1,10 @@ using Avalonia.Controls; -using Avalonia.Media.Imaging; using DataLayer; using Dinah.Core.ErrorHandling; using LibationAvalonia.ViewModels; using LibationFileManager; -using NPOI.Util.Collections; +using LibationUiBase.ProcessQueue; using System.Collections.Generic; -using System.ComponentModel; using System.IO; using System.Linq; using System.Reflection; diff --git a/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs index cbb6de11..80f87d1b 100644 --- a/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs @@ -3,6 +3,7 @@ using LibationAvalonia.Controls; using LibationAvalonia.ViewModels; using LibationFileManager; using LibationUiBase; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.Collections.Generic; diff --git a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs index fe74cea9..bb8b3168 100644 --- a/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/AccountsDialog.axaml.cs @@ -3,6 +3,7 @@ using AudibleUtilities; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Platform.Storage; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.Collections.Generic; diff --git a/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs index bb04161e..d3860579 100644 --- a/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/BookRecordsDialog.axaml.cs @@ -7,6 +7,7 @@ using Avalonia.Platform.Storage; using Avalonia.Threading; using DataLayer; using FileLiberator; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.Collections.Generic; diff --git a/Source/LibationAvalonia/Dialogs/DialogWindow.cs b/Source/LibationAvalonia/Dialogs/DialogWindow.cs index bda5e804..016bbd3a 100644 --- a/Source/LibationAvalonia/Dialogs/DialogWindow.cs +++ b/Source/LibationAvalonia/Dialogs/DialogWindow.cs @@ -3,6 +3,7 @@ using Avalonia.Controls; using Avalonia.Controls.Primitives; using Avalonia.Styling; using LibationFileManager; +using LibationUiBase.Forms; using System; using System.Threading.Tasks; diff --git a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs index b7224ae3..2e2c447f 100644 --- a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Styling; using Dinah.Core; using LibationFileManager; using LibationFileManager.Templates; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.IO; diff --git a/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs index 051d78e1..86b9933a 100644 --- a/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/ImageDisplayDialog.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Media.Imaging; using Avalonia.Platform.Storage; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.ComponentModel; diff --git a/Source/LibationAvalonia/Dialogs/LibationFilesDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/LibationFilesDialog.axaml.cs index a20a588a..73c8011a 100644 --- a/Source/LibationAvalonia/Dialogs/LibationFilesDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/LibationFilesDialog.axaml.cs @@ -1,4 +1,5 @@ using LibationFileManager; +using LibationUiBase.Forms; using System.Collections.Generic; namespace LibationAvalonia.Dialogs diff --git a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginCallback.cs b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginCallback.cs index e5234d7c..bf4f8b56 100644 --- a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginCallback.cs +++ b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginCallback.cs @@ -1,5 +1,6 @@ using AudibleApi; using AudibleUtilities; +using LibationUiBase.Forms; using System.Threading.Tasks; namespace LibationAvalonia.Dialogs.Login diff --git a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs index b8122f9d..d2872e3f 100644 --- a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs +++ b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs @@ -1,6 +1,7 @@ using AudibleApi; using AudibleUtilities; using LibationFileManager; +using LibationUiBase.Forms; using System; using System.Threading.Tasks; diff --git a/Source/LibationAvalonia/Dialogs/Login/MfaDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/Login/MfaDialog.axaml.cs index 9cbf1363..90e348dc 100644 --- a/Source/LibationAvalonia/Dialogs/Login/MfaDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/Login/MfaDialog.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Data; +using LibationUiBase.Forms; using ReactiveUI; using System.Collections.Generic; using System.Linq; diff --git a/Source/LibationAvalonia/Dialogs/Login/WebLoginDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/Login/WebLoginDialog.axaml.cs index d6c1c644..b2df7078 100644 --- a/Source/LibationAvalonia/Dialogs/Login/WebLoginDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/Login/WebLoginDialog.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using Dinah.Core; +using LibationUiBase.Forms; using System; namespace LibationAvalonia.Dialogs.Login diff --git a/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs index 85d04f5c..ddcfecf6 100644 --- a/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/MessageBoxAlertAdminDialog.axaml.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using Dinah.Core; using FileManager; +using LibationUiBase.Forms; using System; namespace LibationAvalonia.Dialogs diff --git a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml index 204a23a4..2b2ef05b 100644 --- a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml +++ b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml @@ -1,12 +1,13 @@ + Title="{CompiledBinding Caption}" ShowInTaskbar="True"> @@ -14,14 +15,22 @@ - - - - - + + + + + + + + + + + + + - + @@ -35,13 +44,13 @@ - - diff --git a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml.cs b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml.cs index c1cbb009..1633112f 100644 --- a/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/MessageBoxWindow.axaml.cs @@ -1,4 +1,5 @@ using LibationAvalonia.ViewModels.Dialogs; +using LibationUiBase.Forms; namespace LibationAvalonia.Dialogs { diff --git a/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs index 912f2c0d..cdd22318 100644 --- a/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/ScanAccountsDialog.axaml.cs @@ -1,6 +1,6 @@ using AudibleUtilities; using Avalonia.Controls; -using Avalonia.Interactivity; +using LibationUiBase.Forms; using System.Collections; using System.Collections.Generic; using System.Linq; diff --git a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs index ba1839b9..5155973e 100644 --- a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs @@ -1,6 +1,7 @@ using Avalonia.Controls; using LibationAvalonia.ViewModels.Settings; using LibationFileManager; +using LibationUiBase.Forms; using System.Threading.Tasks; namespace LibationAvalonia.Dialogs diff --git a/Source/LibationAvalonia/Dialogs/SetupDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/SetupDialog.axaml.cs index bc73eaed..9745b9b0 100644 --- a/Source/LibationAvalonia/Dialogs/SetupDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/SetupDialog.axaml.cs @@ -1,5 +1,6 @@ using Avalonia.Controls; using LibationFileManager; +using LibationUiBase.Forms; namespace LibationAvalonia.Dialogs { diff --git a/Source/LibationAvalonia/Dialogs/ThemePickerDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/ThemePickerDialog.axaml.cs index 618f5a44..e3a2d9e3 100644 --- a/Source/LibationAvalonia/Dialogs/ThemePickerDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/ThemePickerDialog.axaml.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using System.Collections.Generic; using System.Linq; using Avalonia.Platform.Storage; +using LibationUiBase.Forms; #nullable enable namespace LibationAvalonia.Dialogs; diff --git a/Source/LibationAvalonia/Dialogs/UpgradeNotificationDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/UpgradeNotificationDialog.axaml.cs index cb298132..712b4ff5 100644 --- a/Source/LibationAvalonia/Dialogs/UpgradeNotificationDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/UpgradeNotificationDialog.axaml.cs @@ -1,6 +1,7 @@ using AppScaffolding; using Avalonia.Controls; using Dinah.Core; +using LibationUiBase.Forms; namespace LibationAvalonia.Dialogs { diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index d7860a57..10d24d0c 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -73,13 +73,13 @@ - - - - - - - + + + + + + + diff --git a/Source/LibationAvalonia/MessageBox.cs b/Source/LibationAvalonia/MessageBox.cs index 0142bc41..290b254f 100644 --- a/Source/LibationAvalonia/MessageBox.cs +++ b/Source/LibationAvalonia/MessageBox.cs @@ -6,6 +6,7 @@ using DataLayer; using Dinah.Core.Logging; using LibationAvalonia.Dialogs; using LibationAvalonia.ViewModels.Dialogs; +using LibationUiBase.Forms; using System; using System.Collections.Generic; using System.Linq; @@ -13,92 +14,45 @@ using System.Threading.Tasks; namespace LibationAvalonia { - public enum DialogResult - { - None = 0, - OK = 1, - Cancel = 2, - Abort = 3, - Retry = 4, - Ignore = 5, - Yes = 6, - No = 7, - TryAgain = 10, - Continue = 11 - } - - public enum MessageBoxIcon - { - None = 0, - Error = 16, - Hand = 16, - Stop = 16, - Question = 32, - Exclamation = 48, - Warning = 48, - Asterisk = 64, - Information = 64 - } - - public enum MessageBoxButtons - { - OK, - OKCancel, - AbortRetryIgnore, - YesNoCancel, - YesNo, - RetryCancel, - CancelTryContinue - } - - public enum MessageBoxDefaultButton - { - Button1, - Button2 = 256, - Button3 = 512, - } public class MessageBox { - public static Task Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) => ShowCoreAsync(null, text, caption, buttons, icon, defaultButton); public static Task Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, bool saveAndRestorePosition = true) - => ShowCoreAsync(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition); + => ShowCoreAsync(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition); public static Task Show(string text, string caption, MessageBoxButtons buttons) - => ShowCoreAsync(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + => ShowCoreAsync(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); public static Task Show(string text, string caption) - => ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + => ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); public static Task Show(string text) - => ShowCoreAsync(null, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); - public static Task Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) - => ShowCoreAsync(owner, text, caption, buttons, icon, defaultButton); - + => ShowCoreAsync(null, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + public static Task Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) + => ShowCoreAsync(owner, text, caption, buttons, icon, defaultButton, saveAndRestorePosition); public static Task Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon) - => ShowCoreAsync(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1); + => ShowCoreAsync(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1); public static Task Show(Window owner, string text, string caption, MessageBoxButtons buttons) - => ShowCoreAsync(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + => ShowCoreAsync(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); public static Task Show(Window owner, string text, string caption) - => ShowCoreAsync(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + => ShowCoreAsync(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); public static Task Show(Window owner, string text) => ShowCoreAsync(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); - public static async Task VerboseLoggingWarning_ShowIfTrue() { // when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured if (Serilog.Log.Logger.IsVerboseEnabled()) - await Show(@" -Warning: verbose logging is enabled. + await Show(""" + Warning: verbose logging is enabled. -This should be used for debugging only. It creates many -more logs and debug files, neither of which are as -strictly anonymous. + This should be used for debugging only. It creates many + more logs and debug files, neither of which are as + strictly anonymous. -When you are finished debugging, it's highly recommended -to set your debug MinimumLevel to Information and restart -Libation. -".Trim(), "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + When you are finished debugging, it's highly recommended + to set your debug MinimumLevel to Information and restart + Libation. + """, "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning); } /// @@ -138,7 +92,8 @@ Libation. { // for development and debugging, show me what broke! if (System.Diagnostics.Debugger.IsAttached) - throw exception; + //Wrap the exception to preserve its stack trace. + throw new Exception("An unhandled exception was encountered", exception); try { @@ -152,12 +107,12 @@ Libation. } private static async Task ShowCoreAsync(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) + => await Dispatcher.UIThread.InvokeAsync(async () => { owner = owner?.IsLoaded is true ? owner : null; - var dialog = await Dispatcher.UIThread.InvokeAsync(() => CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition)); - + var dialog = CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition); return await DisplayWindow(dialog, owner); - } + }); private static MessageBoxWindow CreateMessageBox(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) { @@ -175,7 +130,6 @@ Libation. tbx.MinWidth = vm.TextBlockMinWidth; tbx.Text = message; - var thisScreen = owner.Screens?.ScreenFromVisual(owner); var maxSize @@ -229,6 +183,5 @@ Libation. return await toDisplay.ShowDialog(owner); } } - } } diff --git a/Source/LibationAvalonia/ViewModels/Dialogs/MessageBoxViewModel.cs b/Source/LibationAvalonia/ViewModels/Dialogs/MessageBoxViewModel.cs index 4bef5275..ecf4bc90 100644 --- a/Source/LibationAvalonia/ViewModels/Dialogs/MessageBoxViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/Dialogs/MessageBoxViewModel.cs @@ -1,4 +1,5 @@ -using System; +using LibationUiBase.Forms; +using System; namespace LibationAvalonia.ViewModels.Dialogs { diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs index b355788d..4f8ac7d2 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs @@ -4,6 +4,7 @@ using Avalonia.Controls; using Avalonia.Data; using Avalonia.Input; using LibationFileManager; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.Linq; diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs index 213a7cf4..952fb045 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs @@ -7,6 +7,7 @@ using System; using System.Linq; using System.Threading.Tasks; using Avalonia.Input; +using LibationUiBase.Forms; #nullable enable namespace LibationAvalonia.ViewModels diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs index b69a8ef2..267dc00b 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs @@ -4,6 +4,9 @@ using System; using System.Linq; using System.Threading.Tasks; using DataLayer; +using LibationUiBase.Forms; +using LibationUiBase; +using System.Collections.Generic; #nullable enable namespace LibationAvalonia.ViewModels @@ -12,19 +15,14 @@ namespace LibationAvalonia.ViewModels { public void Configure_Liberate() { } - public void BackupAllBooks() + public async Task BackupAllBooks() { try { - setQueueCollapseState(false); + var unliberated = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray()); - Serilog.Log.Logger.Information("Begin backing up all library books"); - - ProcessQueue.AddDownloadDecrypt( - DbContexts - .GetLibrary_Flat_NoTracking() - .UnLiberated() - ); + if (ProcessQueue.QueueDownloadDecrypt(unliberated)) + setQueueCollapseState(false); } catch (Exception ex) { @@ -32,10 +30,10 @@ namespace LibationAvalonia.ViewModels } } - public void BackupAllPdfs() + public async Task BackupAllPdfs() { - setQueueCollapseState(false); - ProcessQueue.AddDownloadPdf(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)); + if (ProcessQueue.QueueDownloadPdf(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking()))) + setQueueCollapseState(false); } public async Task ConvertAllToMp3Async() @@ -48,12 +46,8 @@ namespace LibationAvalonia.ViewModels "Convert all M4b => Mp3?", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (result == DialogResult.Yes) - { + if (result == DialogResult.Yes && ProcessQueue.QueueConvertToMp3(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking()))) setQueueCollapseState(false); - ProcessQueue.AddConvertMp3(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is ContentType.Product)); - } - //Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing. } private void setQueueCollapseState(bool collapsed) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs index b964e6b9..a35f1036 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs @@ -1,10 +1,11 @@ -using LibationFileManager; -using System; -using System.Linq; -using DataLayer; +using DataLayer; using Dinah.Core; +using LibationFileManager; +using LibationUiBase; using LibationUiBase.GridView; using ReactiveUI; +using System; +using System.Linq; #nullable enable namespace LibationAvalonia.ViewModels @@ -37,50 +38,16 @@ namespace LibationAvalonia.ViewModels { try { - if (libraryBooks.Length == 1) + if (ProcessQueue.QueueDownloadDecrypt(libraryBooks)) + setQueueCollapseState(false); + else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists()) { - var item = libraryBooks[0]; - - //Remove this item from the queue if it's already present and completed. - //Only do this when adding a single book at a time to prevent accidental - //extra downloads when queueing in batches. - ProcessQueue.RemoveCompleted(item); - - if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId); + if (!Go.To.File(filePath?.ShortPathName)) { - Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item); - setQueueCollapseState(false); - ProcessQueue.AddDownloadDecrypt(item); - } - else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - { - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); - setQueueCollapseState(false); - ProcessQueue.AddDownloadPdf(item); - } - else if (item.Book.Audio_Exists()) - { - // liberated: open explorer to file - var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId); - - if (!Go.To.File(filePath?.ShortPathName)) - { - var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; - await MessageBox.Show($"File not found" + suffix); - } - } - } - else - { - var toLiberate - = libraryBooks - .Where(x => x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - .ToArray(); - - if (toLiberate.Length > 0) - { - setQueueCollapseState(false); - ProcessQueue.AddDownloadDecrypt(toLiberate); + var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; + await MessageBox.Show($"File not found" + suffix); } } } @@ -94,11 +61,10 @@ namespace LibationAvalonia.ViewModels { try { - setQueueCollapseState(false); - Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); - ProcessQueue.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated()); + if (ProcessQueue.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray())) + setQueueCollapseState(false); } catch (Exception ex) { @@ -110,13 +76,8 @@ namespace LibationAvalonia.ViewModels { try { - var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray(); - if (preLiberated.Length > 0) - { - Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length); + if (ProcessQueue.QueueConvertToMp3(libraryBooks)) setQueueCollapseState(false); - ProcessQueue.AddConvertMp3(preLiberated); - } } catch (Exception ex) { diff --git a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs index f9a2aeb7..728a0b1f 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs @@ -5,6 +5,9 @@ using DataLayer; using Avalonia.Threading; using LibationAvalonia.Dialogs; using ReactiveUI; +using LibationUiBase.Forms; +using System.Linq; +using LibationUiBase; #nullable enable namespace LibationAvalonia.ViewModels @@ -71,15 +74,8 @@ namespace LibationAvalonia.ViewModels { try { - setQueueCollapseState(false); - - Serilog.Log.Logger.Information("Begin backing up visible library books"); - - ProcessQueue.AddDownloadDecrypt( - ProductsDisplay - .GetVisibleBookEntries() - .UnLiberated() - ); + if (ProcessQueue.QueueDownloadDecrypt(ProductsDisplay.GetVisibleBookEntries().UnLiberated().ToArray())) + setQueueCollapseState(false); } catch (Exception ex) { diff --git a/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs index 908ee707..3bc4b30f 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs @@ -1,420 +1,17 @@ -using ApplicationServices; -using AudibleApi; -using AudibleApi.Common; -using Avalonia.Media; -using Avalonia.Media.Imaging; -using Avalonia.Threading; -using DataLayer; -using Dinah.Core; -using Dinah.Core.ErrorHandling; -using FileLiberator; +using DataLayer; using LibationFileManager; using LibationUiBase; -using ReactiveUI; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; +using LibationUiBase.ProcessQueue; #nullable enable -namespace LibationAvalonia.ViewModels +namespace LibationAvalonia.ViewModels; + +public class ProcessBookViewModel : ProcessBookViewModelBase { - public enum ProcessBookResult - { - None, - Success, - Cancelled, - ValidationFail, - FailedRetry, - FailedSkip, - FailedAbort, - LicenseDenied, - LicenseDeniedPossibleOutage - } - public enum ProcessBookStatus - { - Queued, - Cancelled, - Working, - Completed, - Failed - } + public ProcessBookViewModel(LibraryBook libraryBook, LogMe logme) : base(libraryBook, logme) { } - /// - /// This is the viewmodel for queued processables - /// - public class ProcessBookViewModel : ViewModelBase - { - public event EventHandler? Completed; + protected override object? LoadImageFromBytes(byte[] bytes, PictureSize pictureSize) + => AvaloniaUtils.TryLoadImageOrDefault(bytes, pictureSize); - public LibraryBook LibraryBook { get; private set; } - - private ProcessBookResult _result = ProcessBookResult.None; - private ProcessBookStatus _status = ProcessBookStatus.Queued; - private string? _narrator; - private string? _author; - private string? _title; - private int _progress; - private string? _eta; - private Bitmap? _cover; - - #region Properties exposed to the view - public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } } - public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } } - public string? Narrator { get => _narrator; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); } - public string? Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); } - public string? Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); } - public int Progress { get => _progress; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _progress, value)); } - public string? ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); } - public Bitmap? Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _cover, value)); } - public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working; - public bool IsDownloading => Status is ProcessBookStatus.Working; - public bool Queued => Status is ProcessBookStatus.Queued; - - public string StatusText => Result switch - { - ProcessBookResult.Success => "Finished", - ProcessBookResult.Cancelled => "Cancelled", - ProcessBookResult.ValidationFail => "Validation fail", - ProcessBookResult.FailedRetry => "Error, will retry later", - ProcessBookResult.FailedSkip => "Error, Skipping", - ProcessBookResult.FailedAbort => "Error, Abort", - ProcessBookResult.LicenseDenied => "License Denied", - ProcessBookResult.LicenseDeniedPossibleOutage => "Possible Service Interruption", - _ => Status.ToString(), - }; - - #endregion - - private TimeSpan TimeRemaining { set { ETA = $"ETA: {value:mm\\:ss}"; } } - private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke(); - private Processable? NextProcessable() => _currentProcessable = null; - private Processable? _currentProcessable; - private readonly Queue> Processes = new(); - private readonly LogMe Logger; - - public ProcessBookViewModel(LibraryBook libraryBook, LogMe logme) - { - LibraryBook = libraryBook; - Logger = logme; - - _title = LibraryBook.Book.TitleWithSubtitle; - _author = LibraryBook.Book.AuthorNames(); - _narrator = LibraryBook.Book.NarratorNames(); - - (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._80x80)); - - if (isDefault) - PictureStorage.PictureCached += PictureStorage_PictureCached; - - // Mutable property. Set the field so PropertyChanged isn't fired. - _cover = AvaloniaUtils.TryLoadImageOrDefault(picture, PictureSize._80x80); - } - - private void PictureStorage_PictureCached(object? sender, PictureCachedEventArgs e) - { - if (e.Definition.PictureId == LibraryBook.Book.PictureId) - { - Cover = AvaloniaUtils.TryLoadImageOrDefault(e.Picture, PictureSize._80x80); - PictureStorage.PictureCached -= PictureStorage_PictureCached; - } - } - - public async Task ProcessOneAsync() - { - string procName = CurrentProcessable.Name; - ProcessBookResult result = ProcessBookResult.None; - try - { - LinkProcessable(CurrentProcessable); - - var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true); - - if (statusHandler.IsSuccess) - result = ProcessBookResult.Success; - else if (statusHandler.Errors.Contains("Cancelled")) - { - Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}"); - result = ProcessBookResult.Cancelled; - } - else if (statusHandler.Errors.Contains("Validation failed")) - { - Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}"); - result = ProcessBookResult.ValidationFail; - } - else - { - foreach (var errorMessage in statusHandler.Errors) - Logger.Error($"{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}"); - 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}"); - result = ProcessBookResult.LicenseDenied; - } - } - catch (Exception ex) - { - Logger.Error(ex, procName); - } - finally - { - if (result == ProcessBookResult.None) - result = await showRetry(LibraryBook); - - var status = result switch - { - ProcessBookResult.Success => ProcessBookStatus.Completed, - ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled, - _ => ProcessBookStatus.Failed, - }; - - await Dispatcher.UIThread.InvokeAsync(() => Status = status); - } - - await Dispatcher.UIThread.InvokeAsync(() => Result = result); - return result; - } - - public async Task CancelAsync() - { - try - { - if (CurrentProcessable is AudioDecodable audioDecodable) - await audioDecodable.CancelAsync(); - } - catch (Exception ex) - { - Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling"); - } - } - - public void AddDownloadPdf() => AddProcessable(); - public void AddDownloadDecryptBook() => AddProcessable(); - public void AddConvertToMp3() => AddProcessable(); - - private void AddProcessable() where T : Processable, new() - { - Processes.Enqueue(() => new T()); - } - - public override string ToString() => LibraryBook.ToString(); - - #region Subscribers and Unsubscribers - - private void LinkProcessable(Processable processable) - { - processable.Begin += Processable_Begin; - processable.Completed += Processable_Completed; - processable.StreamingProgressChanged += Streamable_StreamingProgressChanged; - processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining; - - if (processable is AudioDecodable audioDecodable) - { - audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt; - audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered; - audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered; - audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered; - audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered; - } - } - - private void UnlinkProcessable(Processable processable) - { - processable.Begin -= Processable_Begin; - processable.Completed -= Processable_Completed; - processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged; - processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining; - - if (processable is AudioDecodable audioDecodable) - { - audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt; - audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered; - audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered; - audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered; - audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered; - } - } - - #endregion - - #region AudioDecodable event handlers - - private void AudioDecodable_TitleDiscovered(object? sender, string title) => Title = title; - - private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors; - - private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators; - - - private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e) - { - var quality - = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null - ? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native) - : new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500); - - byte[] coverData = PictureStorage.GetPictureSynchronously(quality); - - AudioDecodable_CoverImageDiscovered(this, coverData); - return coverData; - } - - private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt) - { - using var ms = new System.IO.MemoryStream(coverArt); - Cover = new Avalonia.Media.Imaging.Bitmap(ms); - } - - #endregion - - #region Streamable event handlers - private void Streamable_StreamingTimeRemaining(object? sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining; - - - private void Streamable_StreamingProgressChanged(object? sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress) - { - if (!downloadProgress.ProgressPercentage.HasValue) - return; - - if (downloadProgress.ProgressPercentage == 0) - TimeRemaining = TimeSpan.Zero; - else - Progress = (int)downloadProgress.ProgressPercentage; - } - - #endregion - - #region Processable event handlers - - private async void Processable_Begin(object? sender, LibraryBook libraryBook) - { - await Dispatcher.UIThread.InvokeAsync(() => Status = ProcessBookStatus.Working); - - if (sender is Processable processable) - Logger.Info($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}"); - - Title = libraryBook.Book.TitleWithSubtitle; - Author = libraryBook.Book.AuthorNames(); - Narrator = libraryBook.Book.NarratorNames(); - } - - private async void Processable_Completed(object? sender, LibraryBook libraryBook) - { - if (sender is Processable processable) - { - Logger.Info($"{processable.Name} Step, Completed: {libraryBook.Book}"); - UnlinkProcessable(processable); - } - - if (Processes.Count == 0) - { - Completed?.Invoke(this, EventArgs.Empty); - return; - } - - NextProcessable(); - LinkProcessable(CurrentProcessable); - - StatusHandler result; - try - { - result = await CurrentProcessable.ProcessSingleAsync(libraryBook, validate: true); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"{nameof(Processable_Completed)} error"); - - result = new StatusHandler(); - result.AddError($"{nameof(Processable_Completed)} error. See log for details. Error summary: {ex.Message}"); - } - - if (result.HasErrors) - { - foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed")) - Logger.Error(errorMessage); - - Completed?.Invoke(this, EventArgs.Empty); - } - } - - #endregion - - #region Failure Handler - - private async Task showRetry(LibraryBook libraryBook) - { - Logger.Error("ERROR. All books have not been processed. Most recent book: processing failed"); - - DialogResult? dialogResult = Configuration.Instance.BadBook switch - { - Configuration.BadBookAction.Abort => DialogResult.Abort, - Configuration.BadBookAction.Retry => DialogResult.Retry, - Configuration.BadBookAction.Ignore => DialogResult.Ignore, - Configuration.BadBookAction.Ask => null, - _ => null - }; - - string details; - try - { - static string trunc(string str) - => string.IsNullOrWhiteSpace(str) ? "[empty]" - : (str.Length > 50) ? $"{str.Truncate(47)}..." - : str; - - details = - $@" Title: {libraryBook.Book.TitleWithSubtitle} - ID: {libraryBook.Book.AudibleProductId} - Author: {trunc(libraryBook.Book.AuthorNames())} - Narr: {trunc(libraryBook.Book.NarratorNames())}"; - } - catch - { - details = "[Error retrieving details]"; - } - - // if null then ask user - dialogResult ??= await MessageBox.Show(string.Format(SkipDialogText + "\r\n\r\nSee Settings to avoid this box in the future.", details), "Skip importing this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton); - - if (dialogResult == DialogResult.Abort) - return ProcessBookResult.FailedAbort; - - if (dialogResult == SkipResult) - { - libraryBook.UpdateBookStatus(LiberatedStatus.Error); - - Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}"); - - return ProcessBookResult.FailedSkip; - } - - return ProcessBookResult.FailedRetry; - } - - private static string SkipDialogText => @" -An error occurred while trying to process this book. -{0} - -- ABORT: Stop processing books. - -- RETRY: retry this book later. Just skip it for now. Continue processing books. (Will try this book again later.) - -- IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.) -".Trim(); - private static MessageBoxButtons SkipDialogButtons => MessageBoxButtons.AbortRetryIgnore; - private static MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button1; - private static DialogResult SkipResult => DialogResult.Ignore; - } - - #endregion -} +} \ No newline at end of file diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index 29620384..de329357 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -1,289 +1,74 @@ -using ApplicationServices; -using Avalonia.Collections; +using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Threading; using DataLayer; using LibationFileManager; -using LibationUiBase; -using ReactiveUI; +using LibationUiBase.ProcessQueue; using System; -using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; -using System.Threading.Tasks; #nullable enable -namespace LibationAvalonia.ViewModels +namespace LibationAvalonia.ViewModels; + +public record LogEntry(DateTime LogDate, string? LogMessage) { + public string LogDateString => LogDate.ToShortTimeString(); +} - public class ProcessQueueViewModel : ViewModelBase, ILogForm +public class ProcessQueueViewModel : ProcessQueueViewModelBase +{ + public ProcessQueueViewModel() : base(CreateEmptyList()) { - public ObservableCollection LogEntries { get; } = new(); - public AvaloniaList Items { get; } = new(); - public TrackedQueue Queue { get; } - public ProcessBookViewModel? SelectedItem { get; set; } - public Task? QueueRunner { get; private set; } - public bool Running => !QueueRunner?.IsCompleted ?? false; + Items = Queue.UnderlyingList as AvaloniaList + ?? throw new ArgumentNullException(nameof(Queue.UnderlyingList)); - private readonly LogMe Logger; + SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; + } - public ProcessQueueViewModel() + private decimal _speedLimit; + public decimal SpeedLimitIncrement { get; private set; } + public ObservableCollection LogEntries { get; } = new(); + public AvaloniaList Items { get; } + + public decimal SpeedLimit + { + get { - Logger = LogMe.RegisterForm(this); - Queue = new(Items); - Queue.QueuededCountChanged += Queue_QueuededCountChanged; - Queue.CompletedCountChanged += Queue_CompletedCountChanged; - - if (Design.IsDesignMode) - _ = Configuration.Instance.LibationFiles; - - SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; + return _speedLimit; } - - private int _completedCount; - private int _errorCount; - private int _queuedCount; - private string? _runningTime; - private bool _progressBarVisible; - private decimal _speedLimit; - - public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); } - public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); } - public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); } - public string? RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); } - public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); } - public bool AnyCompleted => CompletedCount > 0; - public bool AnyQueued => QueuedCount > 0; - public bool AnyErrors => ErrorCount > 0; - public double Progress => 100d * Queue.Completed.Count / Queue.Count; - - public decimal SpeedLimit + set { - get - { - return _speedLimit; - } - set - { - var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024)); - var config = Configuration.Instance; - config.DownloadSpeedLimit = newValue; + 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; + _speedLimit + = config.DownloadSpeedLimit <= newValue ? value + : value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024 + : 0; - config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024); + config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024); - SpeedLimitIncrement = _speedLimit > 100 ? 10 - : _speedLimit > 10 ? 1 - : _speedLimit > 1 ? 0.1m - : 0.01m; + SpeedLimitIncrement = _speedLimit > 100 ? 10 + : _speedLimit > 10 ? 1 + : _speedLimit > 1 ? 0.1m + : 0.01m; - Dispatcher.UIThread.Invoke(() => - { - this.RaisePropertyChanged(nameof(SpeedLimitIncrement)); - this.RaisePropertyChanged(); - }); - } - } - - public decimal SpeedLimitIncrement { get; private set; } - - private void Queue_CompletedCountChanged(object? sender, int e) - { - int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); - int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); - - ErrorCount = errCount; - CompletedCount = completeCount; - Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress))); - } - private void Queue_QueuededCountChanged(object? sender, int cueCount) - { - QueuedCount = cueCount; - Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress))); - } - - public void WriteLine(string text) - { - Dispatcher.UIThread.Invoke(() => - LogEntries.Add(new() - { - LogDate = DateTime.Now, - LogMessage = text.Trim() - })); - } - - - #region Add Books to Queue - - private bool isBookInQueue(LibraryBook libraryBook) - { - var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); - if (entry == null) - return false; - else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed) - return !Queue.RemoveCompleted(entry); - else - return true; - } - - public bool RemoveCompleted(LibraryBook libraryBook) - => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModel entry - && entry.Status is ProcessBookStatus.Completed - && Queue.RemoveCompleted(entry); - - public void AddDownloadPdf(LibraryBook libraryBook) - => AddDownloadPdf(new List() { libraryBook }); - - public void AddDownloadDecrypt(LibraryBook libraryBook) - => AddDownloadDecrypt(new List() { libraryBook }); - - public void AddConvertMp3(LibraryBook libraryBook) - => AddConvertMp3(new List() { libraryBook }); - - public void AddDownloadPdf(IEnumerable entries) - { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - ProcessBookViewModel pbook = new(entry, Logger); - pbook.AddDownloadPdf(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); - AddToQueue(procs); - } - - public void AddDownloadDecrypt(IEnumerable entries) - { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - ProcessBookViewModel pbook = new(entry, Logger); - pbook.AddDownloadDecryptBook(); - pbook.AddDownloadPdf(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); - AddToQueue(procs); - } - - public void AddConvertMp3(IEnumerable entries) - { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - ProcessBookViewModel pbook = new(entry, Logger); - pbook.AddConvertToMp3(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); - AddToQueue(procs); - } - - public void AddToQueue(IEnumerable pbook) - { - Dispatcher.UIThread.Invoke(() => - { - Queue.Enqueue(pbook); - if (!Running) - QueueRunner = QueueLoop(); - }); - } - - #endregion - - DateTime StartingTime; - private async Task QueueLoop() - { - try - { - Serilog.Log.Logger.Information("Begin processing queue"); - - RunningTime = string.Empty; - ProgressBarVisible = true; - StartingTime = DateTime.Now; - - using var counterTimer = new System.Threading.Timer(CounterTimer_Tick, null, 0, 500); - - bool shownServiceOutageMessage = false; - - while (Queue.MoveNext()) - { - if (Queue.Current is not ProcessBookViewModel nextBook) - { - Serilog.Log.Logger.Information("Current queue item is empty."); - continue; - } - - Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook.LibraryBook); - - var result = await nextBook.ProcessOneAsync(); - - Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook.LibraryBook, result); - - if (result == ProcessBookResult.ValidationFail) - Queue.ClearCurrent(); - else if (result == ProcessBookResult.FailedAbort) - Queue.ClearQueue(); - else if (result == ProcessBookResult.FailedSkip) - nextBook.LibraryBook.UpdateBookStatus(LiberatedStatus.Error); - else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage) - { - await MessageBox.Show(@$" -You were denied a content license for {nextBook.LibraryBook.Book.TitleWithSubtitle} - -This error appears to be caused by a temporary interruption of service that sometimes affects Libation's users. This type of error usually resolves itself in 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app. -", - "Possible Interruption of Service", - MessageBoxButtons.OK, - MessageBoxIcon.Asterisk); - shownServiceOutageMessage = true; - } - } - Serilog.Log.Logger.Information("Completed processing queue"); - - Queue_CompletedCountChanged(this, 0); - ProgressBarVisible = false; - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items"); - } - } - - private void CounterTimer_Tick(object? state) - { - string timeToStr(TimeSpan time) - { - string minsSecs = $"{time:mm\\:ss}"; - if (time.TotalHours >= 1) - return $"{time.TotalHours:F0}:{minsSecs}"; - return minsSecs; - } - RunningTime = timeToStr(DateTime.Now - StartingTime); + RaisePropertyChanged(nameof(SpeedLimitIncrement)); + RaisePropertyChanged(nameof(SpeedLimit)); } } - public class LogEntry + 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 CreateEmptyList() { - public DateTime LogDate { get; init; } - public string LogDateString => LogDate.ToShortTimeString(); - public string? LogMessage { get; init; } + if (Design.IsDesignMode) + _ = Configuration.Instance.LibationFiles; + return new AvaloniaList(); } } diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 13e282f5..e7416e14 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -5,8 +5,8 @@ using Avalonia.Controls; using Avalonia.Threading; using DataLayer; using Dinah.Core.Collections.Generic; -using LibationAvalonia.Dialogs.Login; using LibationFileManager; +using LibationUiBase.Forms; using LibationUiBase.GridView; using ReactiveUI; using System; diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 22262c5a..7132e6f5 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -7,6 +7,7 @@ using FileManager; using LibationAvalonia.Dialogs; using LibationAvalonia.ViewModels; using LibationFileManager; +using LibationUiBase.Forms; using LibationUiBase.GridView; using ReactiveUI; using System; diff --git a/Source/LibationAvalonia/Views/ProcessBookControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessBookControl.axaml.cs index b79ea913..9e66987d 100644 --- a/Source/LibationAvalonia/Views/ProcessBookControl.axaml.cs +++ b/Source/LibationAvalonia/Views/ProcessBookControl.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Controls; using DataLayer; using LibationAvalonia.ViewModels; using LibationUiBase; +using LibationUiBase.ProcessQueue; namespace LibationAvalonia.Views { diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml index 8d3a8ebd..f65a400e 100644 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml +++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml @@ -35,6 +35,11 @@ VerticalScrollBarVisibility="Auto" AllowAutoHide="False"> + + + + + diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs index de001556..97ae56f9 100644 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs +++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs @@ -5,6 +5,7 @@ using Avalonia.Data.Converters; using DataLayer; using LibationAvalonia.ViewModels; using LibationUiBase; +using LibationUiBase.ProcessQueue; using System; using System.Collections.Generic; using System.Globalization; @@ -16,7 +17,7 @@ namespace LibationAvalonia.Views { public partial class ProcessQueueControl : UserControl { - private TrackedQueue? Queue => _viewModel?.Queue; + private TrackedQueue? Queue => _viewModel?.Queue; private ProcessQueueViewModel? _viewModel => DataContext as ProcessQueueViewModel; public ProcessQueueControl() diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index d94adb16..7cc9b681 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -2,7 +2,6 @@ using ApplicationServices; using Avalonia; using Avalonia.Controls; using Avalonia.Input.Platform; -using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Styling; using DataLayer; @@ -13,6 +12,7 @@ using LibationAvalonia.Dialogs; using LibationAvalonia.ViewModels; using LibationFileManager; using LibationFileManager.Templates; +using LibationUiBase.Forms; using LibationUiBase.GridView; using ReactiveUI; using System; diff --git a/Source/LibationAvalonia/Walkthrough.cs b/Source/LibationAvalonia/Walkthrough.cs index 2c50a505..a170d139 100644 --- a/Source/LibationAvalonia/Walkthrough.cs +++ b/Source/LibationAvalonia/Walkthrough.cs @@ -9,6 +9,7 @@ using Dinah.Core.StepRunner; using LibationAvalonia.Dialogs; using LibationAvalonia.Views; using LibationFileManager; +using LibationUiBase.Forms; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; diff --git a/Source/LibationFileManager/LibationFileManager.csproj b/Source/LibationFileManager/LibationFileManager.csproj index 8712a88b..cdd49260 100644 --- a/Source/LibationFileManager/LibationFileManager.csproj +++ b/Source/LibationFileManager/LibationFileManager.csproj @@ -5,7 +5,7 @@ - + diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs index 1e8bfc0d..e2354372 100644 --- a/Source/LibationUiBase/GridView/EntryStatus.cs +++ b/Source/LibationUiBase/GridView/EntryStatus.cs @@ -16,9 +16,8 @@ namespace LibationUiBase.GridView //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 : SynchronizeInvoker, IComparable, INotifyPropertyChanged + public abstract class EntryStatus : ReactiveObject, IComparable { - public event PropertyChangedEventHandler PropertyChanged; public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book); public LiberatedStatus BookStatus { @@ -81,8 +80,6 @@ namespace LibationUiBase.GridView internal protected abstract object LoadImage(byte[] picture); protected abstract object GetResourceImage(string rescName); - public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args)); - public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); /// Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names. public void Invalidate(params string[] properties) @@ -104,7 +101,13 @@ namespace LibationUiBase.GridView else if (!IsUnavailable && second.IsUnavailable) return -1; else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1; else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1; - else return BookStatus.CompareTo(second.BookStatus); + + var statusCompare = BookStatus.CompareTo(second.BookStatus); + if (statusCompare != 0) return statusCompare; + else if (PdfStatus is null && second.PdfStatus is null) return 0; + else if (PdfStatus is null && second.PdfStatus is not null) return 1; + else if (PdfStatus is not null && second.PdfStatus is null) return -1; + else return PdfStatus.Value.CompareTo(second.PdfStatus.Value); } private object GetLiberateIcon() diff --git a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs index f8ef11d4..a57b52db 100644 --- a/Source/LibationUiBase/GridView/GridEntry[TStatus].cs +++ b/Source/LibationUiBase/GridView/GridEntry[TStatus].cs @@ -22,7 +22,7 @@ namespace LibationUiBase.GridView } /// The View Model base for the DataGridView - public abstract class GridEntry : SynchronizeInvoker, IGridEntry where TStatus : IEntryStatus + public abstract class GridEntry : ReactiveObject, IGridEntry where TStatus : IEntryStatus { [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } @@ -183,19 +183,6 @@ namespace LibationUiBase.GridView } } - private TRet RaiseAndSetIfChanged(ref TRet backingField, TRet newValue, [CallerMemberName] string propertyName = null) - { - if (EqualityComparer.Default.Equals(backingField, newValue)) return newValue; - - backingField = newValue; - RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); - return newValue; - } - - public event PropertyChangedEventHandler PropertyChanged; - public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args)); - public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); - #endregion #region Sorting @@ -228,16 +215,16 @@ namespace LibationUiBase.GridView // Instantiate comparers for every exposed member object type. private static readonly Dictionary memberTypeComparers = new() { - { typeof(RemoveStatus), new ObjectComparer() }, - { typeof(string), new ObjectComparer() }, - { typeof(int), new ObjectComparer() }, - { typeof(float), new ObjectComparer() }, - { typeof(bool), new ObjectComparer() }, - { typeof(Rating), new ObjectComparer() }, - { typeof(DateTime), new ObjectComparer() }, - { typeof(EntryStatus), new ObjectComparer() }, - { typeof(SeriesOrder), new ObjectComparer() }, - { typeof(LastDownloadStatus), new ObjectComparer() }, + { typeof(RemoveStatus), Comparer.Default }, + { typeof(string), Comparer.Default }, + { typeof(int), Comparer .Default }, + { typeof(float), Comparer.Default }, + { typeof(bool), Comparer.Default }, + { typeof(Rating), Comparer.Default }, + { typeof(DateTime), Comparer.Default }, + { typeof(EntryStatus), Comparer.Default }, + { typeof(SeriesOrder), Comparer.Default }, + { typeof(LastDownloadStatus), Comparer.Default }, }; #endregion diff --git a/Source/LibationUiBase/GridView/ObjectComparer[T].cs b/Source/LibationUiBase/GridView/ObjectComparer[T].cs deleted file mode 100644 index e66beca0..00000000 --- a/Source/LibationUiBase/GridView/ObjectComparer[T].cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Collections; - -namespace LibationUiBase.GridView -{ - public class ObjectComparer : IComparer where T : IComparable - { - public int Compare(object x, object y) => ((T)x).CompareTo(y); - } -} diff --git a/Source/LibationUiBase/LibationUiBase.csproj b/Source/LibationUiBase/LibationUiBase.csproj index 9493bebe..e863eea2 100644 --- a/Source/LibationUiBase/LibationUiBase.csproj +++ b/Source/LibationUiBase/LibationUiBase.csproj @@ -9,7 +9,7 @@ - + diff --git a/Source/LibationUiBase/MessageBoxBase.cs b/Source/LibationUiBase/MessageBoxBase.cs new file mode 100644 index 00000000..4ff8c24d --- /dev/null +++ b/Source/LibationUiBase/MessageBoxBase.cs @@ -0,0 +1,133 @@ +using MathNet.Numerics; +using System.IO; +using System.Threading.Tasks; + +#nullable enable +namespace LibationUiBase.Forms; + +public enum DialogResult +{ + /// Nothing is returned from the dialog box. This means that the modal dialog continues running. + None = 0, + /// The dialog box return value is OK (usually sent from a button labeled OK). + OK = 1, //IDOK + /// The dialog box return value is Cancel (usually sent from a button labeled Cancel). + Cancel = 2, //IDCANCEL + /// The dialog box return value is Abort (usually sent from a button labeled Abort). + Abort = 3, //IDABORT + /// The dialog box return value is Retry (usually sent from a button labeled Retry). + Retry = 4, //IDRETRY + /// The dialog box return value is Ignore (usually sent from a button labeled Ignore). + Ignore = 5, //IDIGNORE + /// The dialog box return value is Yes (usually sent from a button labeled Yes). + Yes = 6, //IDYES + /// The dialog box return value is No (usually sent from a button labeled No). + No = 7, //IDNO + /// The dialog box return value is Try Again (usually sent from a button labeled Try Again). + TryAgain = 10, //IDTRYAGAIN + /// The dialog box return value is Continue (usually sent from a button labeled Continue). + Continue = 11 //IDCONTINUE +} + +public enum MessageBoxIcon +{ + /// Specifies that the message box contain no symbols. + None = 0x00000000, + /// Specifies that the message box contains a hand symbol. + Hand = 0x00000010, //MB_ICONHAND + /// Specifies that the message box contains a question mark symbol. + Question = 0x00000020, //MB_ICONQUESTION + /// Specifies that the message box contains an exclamation symbol. + Exclamation = 0x00000030, //MB_ICONEXCLAMATION + /// Specifies that the message box contains an asterisk symbol. + Asterisk = 0x00000040, //MB_ICONASTERISK + /// Specifies that the message box contains a hand icon. This field is constant. + Stop = Hand, + /// Specifies that the message box contains a hand icon. + Error = Hand, + /// Specifies that the message box contains an exclamation icon. + Warning = Exclamation, + /// Specifies that the message box contains an asterisk icon. + Information = Asterisk +} + +public enum MessageBoxButtons +{ + /// Specifies that the message box contains an OK button. + OK = 0x00000000, //MB_OK + /// Specifies that the message box contains OK and Cancel buttons. + OKCancel = 0x00000001, //MB_OKCANCEL + /// Specifies that the message box contains Abort, Retry, and Ignore buttons. + AbortRetryIgnore = 0x00000002, //MB_ABORTRETRYIGNORE + /// Specifies that the message box contains Yes, No, and Cancel buttons. + YesNoCancel = 0x00000003, //MB_YESNOCANCEL + /// Specifies that the message box contains Yes and No buttons. + YesNo = 0x00000004, //MB_YESNO + /// Specifies that the message box contains Retry and Cancel buttons. + RetryCancel = 0x00000005, //MB_RETRYCANCEL + /// Specifies that the message box contains Cancel, Try Again, and Continue buttons. + CancelTryContinue = 0x00000006 //MB_CANCELTRYCONTINUE +} + +public enum MessageBoxDefaultButton +{ + /// Specifies that the first button on the message box should be the default button. + Button1 = 0x00000000, //MB_DEFBUTTON1 + /// Specifies that the second button on the message box should be the default button. + Button2 = 0x00000100, //MB_DEFBUTTON2 + /// Specifies that the third button on the message box should be the default button. + Button3 = 0x00000200, //MB_DEFBUTTON3 + /// Specifies that the Help button on the message box should be the default button. + Button4 = 0x00000300, //MB_DEFBUTTON4 +} + +/// +/// Displays a message box in front of the specified object and with the specified text, caption, buttons, icon, and default button. +/// +/// An implementation of a GUI window that will own the modal dialog box +/// The text to display in the message box +/// The text to display in the title bar of the message box +/// One of the values that specifies which buttons to disply in the message box +/// One of the values that specifies which icon to disply in the message box +/// One of the values that specifies the default button of the message box +/// A value indicating whether the message box's position should be saved and restored the next time it is shown +/// One of the values +public delegate Task ShowAsyncDelegate(object? owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true); + +public static class MessageBoxBase +{ + private static ShowAsyncDelegate? s_ShowAsyncImpl; + public static ShowAsyncDelegate ShowAsyncImpl + { + get => s_ShowAsyncImpl ?? DefaultShowAsyncImpl; + set => s_ShowAsyncImpl = value; + } + + private static Task DefaultShowAsyncImpl(object? owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) + { + // default to a no-op impl + Serilog.Log.Logger.Error("MessageBoxBase implementation not set. {@DebugInfo}", new { owner, message, caption, buttons, icon, defaultButton }); + return Task.FromResult(DialogResult.None); + } + + public static Task Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) + => ShowAsyncImpl(null, text, caption, buttons, icon, defaultButton); + public static Task Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, bool saveAndRestorePosition = true) + => ShowAsyncImpl(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition); + public static Task Show(string text, string caption, MessageBoxButtons buttons) + => ShowAsyncImpl(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + public static Task Show(string text, string caption) + => ShowAsyncImpl(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + public static Task Show(string text) + => ShowAsyncImpl(null, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + public static Task Show(object? owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) + => ShowAsyncImpl(owner, text, caption, buttons, icon, defaultButton); + public static Task Show(object? owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon) + => ShowAsyncImpl(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1); + public static Task Show(object? owner, string text, string caption, MessageBoxButtons buttons) + => ShowAsyncImpl(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + public static Task Show(object? owner, string text, string caption) + => ShowAsyncImpl(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + public static Task Show(object? owner, string text) + => ShowAsyncImpl(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); +} \ No newline at end of file diff --git a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs new file mode 100644 index 00000000..62b00e7d --- /dev/null +++ b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs @@ -0,0 +1,414 @@ +using ApplicationServices; +using AudibleApi; +using AudibleApi.Common; +using DataLayer; +using Dinah.Core; +using Dinah.Core.ErrorHandling; +using FileLiberator; +using LibationFileManager; +using LibationUiBase.Forms; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +#nullable enable +namespace LibationUiBase.ProcessQueue; + +public enum ProcessBookResult +{ + None, + Success, + Cancelled, + ValidationFail, + FailedRetry, + FailedSkip, + FailedAbort, + LicenseDenied, + LicenseDeniedPossibleOutage +} + +public enum ProcessBookStatus +{ + Queued, + Cancelled, + Working, + Completed, + Failed +} + +/// +/// This is the viewmodel for queued processables +/// +public abstract class ProcessBookViewModelBase : ReactiveObject +{ + private readonly LogMe Logger; + public LibraryBook LibraryBook { get; protected set; } + + private ProcessBookResult _result = ProcessBookResult.None; + private ProcessBookStatus _status = ProcessBookStatus.Queued; + private string? _narrator; + private string? _author; + private string? _title; + private int _progress; + private string? _eta; + private object? _cover; + private TimeSpan _timeRemaining; + + #region Properties exposed to the view + public ProcessBookResult Result { get => _result; set { RaiseAndSetIfChanged(ref _result, value); RaisePropertyChanged(nameof(StatusText)); } } + public ProcessBookStatus Status { get => _status; set { RaiseAndSetIfChanged(ref _status, value); RaisePropertyChanged(nameof(IsFinished)); RaisePropertyChanged(nameof(IsDownloading)); RaisePropertyChanged(nameof(Queued)); } } + public string? Narrator { get => _narrator; set => RaiseAndSetIfChanged(ref _narrator, value); } + public string? Author { get => _author; set => RaiseAndSetIfChanged(ref _author, value); } + public string? Title { get => _title; set => RaiseAndSetIfChanged(ref _title, value); } + public int Progress { get => _progress; protected set => RaiseAndSetIfChanged(ref _progress, value); } + public TimeSpan TimeRemaining { get => _timeRemaining; set { RaiseAndSetIfChanged(ref _timeRemaining, value); ETA = $"ETA: {value:mm\\:ss}"; } } + public string? ETA { get => _eta; private set => RaiseAndSetIfChanged(ref _eta, value); } + public object? Cover { get => _cover; protected set => RaiseAndSetIfChanged(ref _cover, value); } + public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working; + public bool IsDownloading => Status is ProcessBookStatus.Working; + public bool Queued => Status is ProcessBookStatus.Queued; + + public string StatusText => Result switch + { + ProcessBookResult.Success => "Finished", + ProcessBookResult.Cancelled => "Cancelled", + ProcessBookResult.ValidationFail => "Validation fail", + ProcessBookResult.FailedRetry => "Error, will retry later", + ProcessBookResult.FailedSkip => "Error, Skipping", + ProcessBookResult.FailedAbort => "Error, Abort", + ProcessBookResult.LicenseDenied => "License Denied", + ProcessBookResult.LicenseDeniedPossibleOutage => "Possible Service Interruption", + _ => Status.ToString(), + }; + + #endregion + + protected Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke(); + protected void NextProcessable() => _currentProcessable = null; + private Processable? _currentProcessable; + + /// A series of Processable actions to perform on this book + protected Queue> Processes { get; } = new(); + + protected ProcessBookViewModelBase(LibraryBook libraryBook, LogMe logme) + { + LibraryBook = libraryBook; + Logger = logme; + + _title = LibraryBook.Book.TitleWithSubtitle; + _author = LibraryBook.Book.AuthorNames(); + _narrator = LibraryBook.Book.NarratorNames(); + + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._80x80)); + + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + _cover = LoadImageFromBytes(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); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } + + public async Task ProcessOneAsync() + { + string procName = CurrentProcessable.Name; + ProcessBookResult result = ProcessBookResult.None; + try + { + LinkProcessable(CurrentProcessable); + + var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true); + + if (statusHandler.IsSuccess) + result = ProcessBookResult.Success; + else if (statusHandler.Errors.Contains("Cancelled")) + { + Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}"); + result = ProcessBookResult.Cancelled; + } + else if (statusHandler.Errors.Contains("Validation failed")) + { + Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}"); + result = ProcessBookResult.ValidationFail; + } + else + { + foreach (var errorMessage in statusHandler.Errors) + Logger.Error($"{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}"); + 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}"); + result = ProcessBookResult.LicenseDenied; + } + } + catch (Exception ex) + { + Logger.Error(ex, procName); + } + finally + { + if (result == ProcessBookResult.None) + result = await GetFailureActionAsync(LibraryBook); + + var status = result switch + { + ProcessBookResult.Success => ProcessBookStatus.Completed, + ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled, + _ => ProcessBookStatus.Failed, + }; + + Status = status; + } + + Result = result; + return result; + } + + public async Task CancelAsync() + { + try + { + if (CurrentProcessable is AudioDecodable audioDecodable) + await audioDecodable.CancelAsync(); + } + catch (Exception ex) + { + Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling"); + } + } + + public ProcessBookViewModelBase AddDownloadPdf() => AddProcessable(); + public ProcessBookViewModelBase AddDownloadDecryptBook() => AddProcessable(); + public ProcessBookViewModelBase AddConvertToMp3() => AddProcessable(); + + private ProcessBookViewModelBase AddProcessable() where T : Processable, new() + { + Processes.Enqueue(() => new T()); + return this; + } + + public override string ToString() => LibraryBook.ToString(); + + #region Subscribers and Unsubscribers + + private void LinkProcessable(Processable processable) + { + processable.Begin += Processable_Begin; + processable.Completed += Processable_Completed; + processable.StreamingProgressChanged += Streamable_StreamingProgressChanged; + processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining; + + if (processable is AudioDecodable audioDecodable) + { + audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt; + audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered; + audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered; + audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered; + audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered; + } + } + + private void UnlinkProcessable(Processable processable) + { + processable.Begin -= Processable_Begin; + processable.Completed -= Processable_Completed; + processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged; + processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining; + + if (processable is AudioDecodable audioDecodable) + { + audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt; + audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered; + audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered; + audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered; + audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered; + } + } + + #endregion + + #region AudioDecodable event handlers + + private void AudioDecodable_TitleDiscovered(object? sender, string title) => Title = title; + 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); + + private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e) + { + var quality + = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null + ? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native) + : new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500); + + byte[] coverData = PictureStorage.GetPictureSynchronously(quality); + + AudioDecodable_CoverImageDiscovered(this, coverData); + return coverData; + } + + #endregion + + #region Streamable event handlers + + private void Streamable_StreamingTimeRemaining(object? sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining; + private void Streamable_StreamingProgressChanged(object? sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress) + { + if (!downloadProgress.ProgressPercentage.HasValue) + return; + + if (downloadProgress.ProgressPercentage == 0) + TimeRemaining = TimeSpan.Zero; + else + Progress = (int)downloadProgress.ProgressPercentage; + } + + #endregion + + #region Processable event handlers + + private void Processable_Begin(object? sender, LibraryBook libraryBook) + { + Status = ProcessBookStatus.Working; + + if (sender is Processable processable) + Logger.Info($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}"); + + Title = libraryBook.Book.TitleWithSubtitle; + Author = libraryBook.Book.AuthorNames(); + Narrator = libraryBook.Book.NarratorNames(); + } + + private async void Processable_Completed(object? sender, LibraryBook libraryBook) + { + if (sender is Processable processable) + { + Logger.Info($"{processable.Name} Step, Completed: {libraryBook.Book}"); + UnlinkProcessable(processable); + } + + if (Processes.Count == 0) + return; + + NextProcessable(); + LinkProcessable(CurrentProcessable); + + StatusHandler result; + try + { + result = await CurrentProcessable.ProcessSingleAsync(libraryBook, validate: true); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, $"{nameof(Processable_Completed)} error"); + + result = new StatusHandler(); + result.AddError($"{nameof(Processable_Completed)} error. See log for details. Error summary: {ex.Message}"); + } + + if (result.HasErrors) + { + foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed")) + Logger.Error(errorMessage); + } + } + + #endregion + + #region Failure Handler + + protected async Task GetFailureActionAsync(LibraryBook libraryBook) + { + const DialogResult SkipResult = DialogResult.Ignore; + Logger.Error($"ERROR. All books have not been processed. Book failed: {libraryBook.Book}"); + + DialogResult? dialogResult = Configuration.Instance.BadBook switch + { + Configuration.BadBookAction.Abort => DialogResult.Abort, + Configuration.BadBookAction.Retry => DialogResult.Retry, + Configuration.BadBookAction.Ignore => DialogResult.Ignore, + Configuration.BadBookAction.Ask or _ => await ShowRetryDialogAsync(libraryBook) + }; + + if (dialogResult == SkipResult) + { + libraryBook.UpdateBookStatus(LiberatedStatus.Error); + Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}"); + } + + return dialogResult is SkipResult ? ProcessBookResult.FailedSkip + : dialogResult is DialogResult.Abort ? ProcessBookResult.FailedAbort + : ProcessBookResult.FailedRetry; + } + + protected async Task ShowRetryDialogAsync(LibraryBook libraryBook) + { + string details; + try + { + static string trunc(string str) + => string.IsNullOrWhiteSpace(str) ? "[empty]" + : (str.Length > 50) ? $"{str.Truncate(47)}..." + : str; + + details = $""" + Title: {libraryBook.Book.TitleWithSubtitle} + ID: {libraryBook.Book.AudibleProductId} + Author: {trunc(libraryBook.Book.AuthorNames())} + Narr: {trunc(libraryBook.Book.NarratorNames())} + """; + } + catch + { + details = "[Error retrieving details]"; + } + + var skipDialogText = $""" + An error occurred while trying to process this book. + {details} + + - ABORT: Stop processing books. + + - RETRY: Skip this book for now, but retry if it is requeued. Continue processing the queued books. + + - IGNORE: Permanently ignore this book. Continue processing the queued books. (Will not try this book again later.) + + See Settings in the Download/Decrypt tab to avoid this box in the future. + """; + + const MessageBoxButtons SkipDialogButtons = MessageBoxButtons.AbortRetryIgnore; + const MessageBoxDefaultButton SkipDialogDefaultButton = MessageBoxDefaultButton.Button1; + + try + { + return await MessageBoxBase.Show(skipDialogText, "Skip this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error showing retry dialog. Defaulting to 'Retry'; action."); + return DialogResult.Retry; + } + } + + #endregion +} diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs new file mode 100644 index 00000000..a6452ceb --- /dev/null +++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs @@ -0,0 +1,235 @@ +using DataLayer; +using LibationUiBase.Forms; +using System; +using System.Collections.Generic; +using System.Linq; +using ApplicationServices; +using System.Threading.Tasks; + +#nullable enable +namespace LibationUiBase.ProcessQueue; + +public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm +{ + public abstract void WriteLine(string text); + protected abstract ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook); + + public TrackedQueue Queue { get; } + public Task? QueueRunner { get; private set; } + public bool Running => !QueueRunner?.IsCompleted ?? false; + protected LogMe Logger { get; } + + public ProcessQueueViewModelBase(ICollection? underlyingList) + { + Logger = LogMe.RegisterForm(this); + Queue = new(underlyingList); + Queue.QueuedCountChanged += Queue_QueuedCountChanged; + Queue.CompletedCountChanged += Queue_CompletedCountChanged; + } + + private int _completedCount; + private int _errorCount; + private int _queuedCount; + private string? _runningTime; + private bool _progressBarVisible; + + public int CompletedCount { get => _completedCount; private set { RaiseAndSetIfChanged(ref _completedCount, value); RaisePropertyChanged(nameof(AnyCompleted)); } } + public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); RaisePropertyChanged(nameof(AnyQueued)); } } + public int ErrorCount { get => _errorCount; private set { RaiseAndSetIfChanged(ref _errorCount, value); RaisePropertyChanged(nameof(AnyErrors)); } } + public string? RunningTime { get => _runningTime; set => RaiseAndSetIfChanged(ref _runningTime, value); } + public bool ProgressBarVisible { get => _progressBarVisible; set => RaiseAndSetIfChanged(ref _progressBarVisible, value); } + public bool AnyCompleted => CompletedCount > 0; + public bool AnyQueued => QueuedCount > 0; + public bool AnyErrors => ErrorCount > 0; + public double Progress => 100d * Queue.Completed.Count / Queue.Count; + + private void Queue_CompletedCountChanged(object? sender, int e) + { + int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); + int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); + + ErrorCount = errCount; + CompletedCount = completeCount; + RaisePropertyChanged(nameof(Progress)); + } + + private void Queue_QueuedCountChanged(object? sender, int cueCount) + { + QueuedCount = cueCount; + RaisePropertyChanged(nameof(Progress)); + } + + #region Add Books to Queue + + public bool QueueDownloadPdf(IList libraryBooks) + { + var needsPdf = libraryBooks.Where(lb => lb.NeedsPdfDownload()).ToArray(); + if (needsPdf.Length > 0) + { + Serilog.Log.Logger.Information("Begin download {count} pdfs", needsPdf.Length); + AddDownloadPdf(needsPdf); + return true; + } + return false; + } + + public bool QueueConvertToMp3(IList libraryBooks) + { + //Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing. + var preLiberated = libraryBooks.Where(lb => !lb.AbsentFromLastScan && lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product).ToArray(); + if (preLiberated.Length > 0) + { + Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length); + AddConvertMp3(preLiberated); + return true; + } + return false; + } + + public bool QueueDownloadDecrypt(IList libraryBooks) + { + if (libraryBooks.Count == 1) + { + var item = libraryBooks[0]; + + if (item.AbsentFromLastScan) + return false; + else if (item.NeedsBookDownload()) + { + RemoveCompleted(item); + Serilog.Log.Logger.Information("Begin single library book backup of {libraryBook}", item); + AddDownloadDecrypt([item]); + return true; + } + else if (item.NeedsPdfDownload()) + { + RemoveCompleted(item); + Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); + AddDownloadPdf([item]); + return true; + } + } + else + { + var toLiberate = libraryBooks.UnLiberated().ToArray(); + + if (toLiberate.Length > 0) + { + Serilog.Log.Logger.Information("Begin backup of {count} library books", toLiberate.Length); + AddDownloadDecrypt(toLiberate); + return true; + } + } + return false; + } + + private bool IsBookInQueue(LibraryBook libraryBook) + => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is not ProcessBookViewModelBase 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 + && entry.Status is ProcessBookStatus.Completed + && Queue.RemoveCompleted(entry); + + private void AddDownloadPdf(IEnumerable 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(); + } + + private void AddDownloadDecrypt(IEnumerable 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(); + } + + private void AddConvertMp3(IEnumerable 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(); + } + + private void AddToQueue(IEnumerable pbook) + { + Queue.Enqueue(pbook); + if (!Running) + QueueRunner = Task.Run(QueueLoop); + } + + #endregion + + private async Task QueueLoop() + { + try + { + Serilog.Log.Logger.Information("Begin processing queue"); + + RunningTime = string.Empty; + ProgressBarVisible = true; + var startingTime = DateTime.Now; + bool shownServiceOutageMessage = false; + + using var counterTimer = new System.Threading.Timer(_ => RunningTime = timeToStr(DateTime.Now - startingTime), null, 0, 500); + + while (Queue.MoveNext()) + { + if (Queue.Current is not ProcessBookViewModelBase nextBook) + { + Serilog.Log.Logger.Information("Current queue item is empty."); + continue; + } + + Serilog.Log.Logger.Information("Begin processing queued item: '{item_LibraryBook}'", nextBook.LibraryBook); + + var result = await nextBook.ProcessOneAsync(); + + Serilog.Log.Logger.Information("Completed processing queued item: '{item_LibraryBook}' with result: {result}", nextBook.LibraryBook, result); + + if (result == ProcessBookResult.ValidationFail) + Queue.ClearCurrent(); + else if (result == ProcessBookResult.FailedAbort) + Queue.ClearQueue(); + else if (result == ProcessBookResult.FailedSkip) + nextBook.LibraryBook.UpdateBookStatus(LiberatedStatus.Error); + else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage) + { + await MessageBoxBase.Show($""" + You were denied a content license for {nextBook.LibraryBook.Book.TitleWithSubtitle} + + This error appears to be caused by a temporary interruption of service that sometimes affects Libation's users. This type of error usually resolves itself in 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app. + """, + "Possible Interruption of Service", + MessageBoxButtons.OK, + MessageBoxIcon.Asterisk); + shownServiceOutageMessage = true; + } + } + Serilog.Log.Logger.Information("Completed processing queue"); + + Queue_CompletedCountChanged(this, 0); + ProgressBarVisible = false; + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items"); + } + + string timeToStr(TimeSpan time) + => time.TotalHours < 1 ? $"{time:mm\\:ss}" + : $"{time.TotalHours:F0}:{time:mm\\:ss}"; + } +} diff --git a/Source/LibationUiBase/ReactiveObject.cs b/Source/LibationUiBase/ReactiveObject.cs new file mode 100644 index 00000000..e86ed991 --- /dev/null +++ b/Source/LibationUiBase/ReactiveObject.cs @@ -0,0 +1,33 @@ +using Dinah.Core.Threading; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Runtime.CompilerServices; + +#nullable enable +namespace LibationUiBase; + +public class ReactiveObject : SynchronizeInvoker, INotifyPropertyChanged, INotifyPropertyChanging +{ + public event PropertyChangedEventHandler? PropertyChanged; + public event PropertyChangingEventHandler? PropertyChanging; + + public void RaisePropertyChanging(PropertyChangingEventArgs args) => Invoke(() => PropertyChanging?.Invoke(this, args)); + public void RaisePropertyChanging(string propertyName) => RaisePropertyChanging(new PropertyChangingEventArgs(propertyName)); + public void RaisePropertyChanged(PropertyChangedEventArgs args) => Invoke(() => PropertyChanged?.Invoke(this, args)); + public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName)); + + public TRet RaiseAndSetIfChanged(ref TRet backingField, TRet newValue, [CallerMemberName] string? propertyName = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName, nameof(propertyName)); + + if (!EqualityComparer.Default.Equals(backingField, newValue)) + { + RaisePropertyChanging(propertyName); + backingField = newValue; + RaisePropertyChanged(propertyName!); + } + + return newValue; + } +} diff --git a/Source/LibationUiBase/TrackedQueue[T].cs b/Source/LibationUiBase/TrackedQueue[T].cs index 51f36f0b..746f8c6b 100644 --- a/Source/LibationUiBase/TrackedQueue[T].cs +++ b/Source/LibationUiBase/TrackedQueue[T].cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; +#nullable enable namespace LibationUiBase { public enum QueuePosition @@ -34,10 +35,10 @@ namespace LibationUiBase */ public class TrackedQueue where T : class { - public event EventHandler CompletedCountChanged; - public event EventHandler QueuededCountChanged; + public event EventHandler? CompletedCountChanged; + public event EventHandler? QueuedCountChanged; - public T Current { get; private set; } + public T? Current { get; private set; } public IReadOnlyList Queued => _queued; public IReadOnlyList Completed => _completed; @@ -46,9 +47,10 @@ namespace LibationUiBase private readonly List _completed = new(); private readonly object lockObject = new(); - private readonly ICollection _underlyingList; + private readonly ICollection? _underlyingList; + public ICollection? UnderlyingList => _underlyingList; - public TrackedQueue(ICollection underlyingList = null) + public TrackedQueue(ICollection? underlyingList = null) { _underlyingList = underlyingList; } @@ -113,7 +115,7 @@ namespace LibationUiBase if (itemsRemoved) { - QueuededCountChanged?.Invoke(this, queuedCount); + QueuedCountChanged?.Invoke(this, queuedCount); RebuildSecondary(); } return itemsRemoved; @@ -149,7 +151,7 @@ namespace LibationUiBase { lock (lockObject) _queued.Clear(); - QueuededCountChanged?.Invoke(this, 0); + QueuedCountChanged?.Invoke(this, 0); RebuildSecondary(); } @@ -169,7 +171,7 @@ namespace LibationUiBase } } - public T FirstOrDefault(Func predicate) + public T? FirstOrDefault(Func predicate) { lock (lockObject) { @@ -246,7 +248,7 @@ namespace LibationUiBase { if (completedChanged) CompletedCountChanged?.Invoke(this, completedCount); - QueuededCountChanged?.Invoke(this, queuedCount); + QueuedCountChanged?.Invoke(this, queuedCount); RebuildSecondary(); } } @@ -261,7 +263,7 @@ namespace LibationUiBase } foreach (var i in item) _underlyingList?.Add(i); - QueuededCountChanged?.Invoke(this, queueCount); + QueuedCountChanged?.Invoke(this, queueCount); } private void RebuildSecondary() diff --git a/Source/LibationWinForms/Form1.Liberate.cs b/Source/LibationWinForms/Form1.Liberate.cs index 256b977e..9b905be4 100644 --- a/Source/LibationWinForms/Form1.Liberate.cs +++ b/Source/LibationWinForms/Form1.Liberate.cs @@ -1,4 +1,5 @@ using DataLayer; +using LibationUiBase; using System; using System.Linq; using System.Threading.Tasks; @@ -11,19 +12,13 @@ namespace LibationWinForms private void Configure_Liberate() { } //GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread - private void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null) + private async void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null) { try { - SetQueueCollapseState(false); - - Serilog.Log.Logger.Information("Begin backing up all library books"); - - processBookQueue1.AddDownloadDecrypt( - ApplicationServices.DbContexts - .GetLibrary_Flat_NoTracking() - .UnLiberated() - ); + var unliberated = await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray()); + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated)) + SetQueueCollapseState(false); } catch (Exception ex) { @@ -33,9 +28,8 @@ namespace LibationWinForms private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e) { - SetQueueCollapseState(false); - await Task.Run(() => processBookQueue1.AddDownloadPdf(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking() - .Where(lb => lb.Book.UserDefinedItem.PdfStatus is DataLayer.LiberatedStatus.NotLiberated))); + if (processBookQueue1.ViewModel.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) + SetQueueCollapseState(false); } private async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, EventArgs e) @@ -48,13 +42,8 @@ namespace LibationWinForms "Convert all M4b => Mp3?", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (result == DialogResult.Yes) - { + if (result == DialogResult.Yes && processBookQueue1.ViewModel.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) SetQueueCollapseState(false); - await Task.Run(() => processBookQueue1.AddConvertMp3(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking() - .Where(lb => lb.Book.UserDefinedItem.BookStatus is DataLayer.LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product))); - } - //Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing. } } } diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index f9c7ccc4..f6260e5e 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -1,6 +1,8 @@ using DataLayer; using Dinah.Core; using LibationFileManager; +using LibationUiBase; +using LibationUiBase.Forms; using LibationUiBase.GridView; using LibationWinForms.ProcessQueue; using System; @@ -14,7 +16,7 @@ namespace LibationWinForms int WidthChange = 0; private void Configure_ProcessQueue() { - processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut; + processBookQueue1.PopoutButton.Click += ProcessBookQueue1_PopOut; WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; int width = this.Width; @@ -27,49 +29,16 @@ namespace LibationWinForms { try { - if (libraryBooks.Length == 1) + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks)) + SetQueueCollapseState(false); + else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists()) { - var item = libraryBooks[0]; - - //Remove this item from the queue if it's already present and completed. - //Only do this when adding a single book at a time to prevent accidental - //extra downloads when queueing in batches. - processBookQueue1.RemoveCompleted(item); - - if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId); + if (!Go.To.File(filePath?.ShortPathName)) { - Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item); - SetQueueCollapseState(false); - processBookQueue1.AddDownloadDecrypt(item); - } - else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - { - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); - SetQueueCollapseState(false); - processBookQueue1.AddDownloadPdf(item); - } - else if (item.Book.Audio_Exists()) - { - // liberated: open explorer to file - var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId); - if (!Go.To.File(filePath?.ShortPathName)) - { - var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; - MessageBox.Show($"File not found" + suffix); - } - } - } - else - { - var toLiberate - = libraryBooks - .Where(x => x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - .ToArray(); - - if (toLiberate.Length > 0) - { - SetQueueCollapseState(false); - processBookQueue1.AddDownloadDecrypt(toLiberate); + var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; + MessageBox.Show($"File not found" + suffix); } } } @@ -83,11 +52,10 @@ namespace LibationWinForms { try { - SetQueueCollapseState(false); - Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); - processBookQueue1.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated()); + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray())) + SetQueueCollapseState(false); } catch (Exception ex) { @@ -99,13 +67,8 @@ namespace LibationWinForms { try { - var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray(); - if (preLiberated.Length > 0) - { - Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length); + if (processBookQueue1.ViewModel.QueueConvertToMp3(libraryBooks)) SetQueueCollapseState(false); - processBookQueue1.AddConvertMp3(preLiberated); - } } catch (Exception ex) { @@ -124,10 +87,14 @@ namespace LibationWinForms } else if (!collapsed && splitContainer1.Panel2Collapsed) { + if (!processBookQueue1.PopoutButton.Visible) + //Queue is in popout mode. Do nothing. + return; + Width += WidthChange; splitContainer1.Panel2.Controls.Add(processBookQueue1); splitContainer1.Panel2Collapsed = false; - processBookQueue1.popoutBtn.Visible = true; + processBookQueue1.PopoutButton.Visible = true; } Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed)); @@ -147,7 +114,7 @@ namespace LibationWinForms dockForm.FormClosing += DockForm_FormClosing; splitContainer1.Panel2.Controls.Remove(processBookQueue1); splitContainer1.Panel2Collapsed = true; - processBookQueue1.popoutBtn.Visible = false; + processBookQueue1.PopoutButton.Visible = false; dockForm.PassControl(processBookQueue1); dockForm.Show(); this.Width -= dockForm.WidthChange; @@ -164,7 +131,7 @@ namespace LibationWinForms this.Width += dockForm.WidthChange; splitContainer1.Panel2.Controls.Add(dockForm.RegainControl()); splitContainer1.Panel2Collapsed = false; - processBookQueue1.popoutBtn.Visible = true; + processBookQueue1.PopoutButton.Visible = true; dockForm.SaveSizeAndLocation(Configuration.Instance); this.Focus(); toggleQueueHideBtn.Visible = true; diff --git a/Source/LibationWinForms/Form1.VisibleBooks.cs b/Source/LibationWinForms/Form1.VisibleBooks.cs index ac24d058..fd248c3b 100644 --- a/Source/LibationWinForms/Form1.VisibleBooks.cs +++ b/Source/LibationWinForms/Form1.VisibleBooks.cs @@ -5,6 +5,7 @@ using System.Windows.Forms; using ApplicationServices; using DataLayer; using Dinah.Core.Threading; +using LibationUiBase; using LibationWinForms.Dialogs; namespace LibationWinForms @@ -25,9 +26,17 @@ namespace LibationWinForms } private async void setLiberatedVisibleMenuItemAsync(object _, object __) => await Task.Run(setLiberatedVisibleMenuItem); + + private static DateTime lastVisibleCountUpdated; void setLiberatedVisibleMenuItem() { + //Assume that all calls to update arrive in order, + //Only display results of the latest book count. + var updaterTime = lastVisibleCountUpdated = DateTime.UtcNow; var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible()); + if (updaterTime < lastVisibleCountUpdated) + return; + this.UIThreadSync(() => { if (libraryStats.HasPendingBooks) @@ -50,15 +59,8 @@ namespace LibationWinForms { try { - SetQueueCollapseState(false); - - Serilog.Log.Logger.Information("Begin backing up visible library books"); - - processBookQueue1.AddDownloadDecrypt( - productsDisplay - .GetVisible() - .UnLiberated() - ); + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray())) + SetQueueCollapseState(false); } catch (Exception ex) { diff --git a/Source/LibationWinForms/MessageBoxLib.cs b/Source/LibationWinForms/MessageBoxLib.cs index 62a7f9f8..e189ef1f 100644 --- a/Source/LibationWinForms/MessageBoxLib.cs +++ b/Source/LibationWinForms/MessageBoxLib.cs @@ -23,7 +23,8 @@ namespace LibationWinForms { // for development and debugging, show me what broke! if (System.Diagnostics.Debugger.IsAttached) - throw exception; + //Wrap the exception to preserve its stack trace. + throw new Exception("An unhandled exception was encountered", exception); try { diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBook.cs b/Source/LibationWinForms/ProcessQueue/ProcessBook.cs deleted file mode 100644 index ce09658a..00000000 --- a/Source/LibationWinForms/ProcessQueue/ProcessBook.cs +++ /dev/null @@ -1,410 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Drawing; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using System.Windows.Forms; -using ApplicationServices; -using AudibleApi.Common; -using AudibleApi; -using DataLayer; -using Dinah.Core; -using Dinah.Core.ErrorHandling; -using FileLiberator; -using LibationFileManager; -using LibationUiBase; - -namespace LibationWinForms.ProcessQueue -{ - public enum ProcessBookResult - { - None, - Success, - Cancelled, - ValidationFail, - FailedRetry, - FailedSkip, - FailedAbort, - LicenseDenied, - LicenseDeniedPossibleOutage - } - - public enum ProcessBookStatus - { - Queued, - Cancelled, - Working, - Completed, - Failed - } - - /// - /// This is the viewmodel for queued processables - /// - public class ProcessBook : INotifyPropertyChanged - { - public event EventHandler Completed; - public event PropertyChangedEventHandler PropertyChanged; - - private ProcessBookResult _result = ProcessBookResult.None; - private ProcessBookStatus _status = ProcessBookStatus.Queued; - private string _bookText; - private int _progress; - private TimeSpan _timeRemaining; - private Image _cover; - - public ProcessBookResult Result { get => _result; private set { _result = value; NotifyPropertyChanged(); } } - public ProcessBookStatus Status { get => _status; private set { _status = value; NotifyPropertyChanged(); } } - public string BookText { get => _bookText; private set { _bookText = value; NotifyPropertyChanged(); } } - public int Progress { get => _progress; private set { _progress = value; NotifyPropertyChanged(); } } - public TimeSpan TimeRemaining { get => _timeRemaining; private set { _timeRemaining = value; NotifyPropertyChanged(); } } - public Image Cover { get => _cover; private set { _cover = value; NotifyPropertyChanged(); } } - - public LibraryBook LibraryBook { get; private set; } - private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke(); - private Processable NextProcessable() => _currentProcessable = null; - private Processable _currentProcessable; - private readonly Queue> Processes = new(); - private readonly LogMe Logger; - - public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - - public ProcessBook(LibraryBook libraryBook, LogMe logme) - { - LibraryBook = libraryBook; - Logger = logme; - - title = LibraryBook.Book.TitleWithSubtitle; - authorNames = LibraryBook.Book.AuthorNames(); - narratorNames = LibraryBook.Book.NarratorNames(); - _bookText = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}"; - - (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._80x80)); - - if (isDefault) - PictureStorage.PictureCached += PictureStorage_PictureCached; - _cover = WinFormsUtil.TryLoadImageOrDefault(picture, PictureSize._80x80); ; - - } - - private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) - { - if (e.Definition.PictureId == LibraryBook.Book.PictureId) - { - Cover = WinFormsUtil.TryLoadImageOrDefault(e.Picture, PictureSize._80x80); - PictureStorage.PictureCached -= PictureStorage_PictureCached; - } - } - - public async Task ProcessOneAsync() - { - string procName = CurrentProcessable.Name; - try - { - LinkProcessable(CurrentProcessable); - - var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true); - - if (statusHandler.IsSuccess) - return Result = ProcessBookResult.Success; - else if (statusHandler.Errors.Contains("Cancelled")) - { - Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}"); - return Result = ProcessBookResult.Cancelled; - } - else if (statusHandler.Errors.Contains("Validation failed")) - { - Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}"); - return Result = ProcessBookResult.ValidationFail; - } - - foreach (var errorMessage in statusHandler.Errors) - Logger.Error($"{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}"); - return 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}"); - return Result = ProcessBookResult.LicenseDenied; - } - } - catch (Exception ex) - { - Logger.Error(ex, procName); - } - finally - { - if (Result == ProcessBookResult.None) - Result = showRetry(LibraryBook); - - Status = Result switch - { - ProcessBookResult.Success => ProcessBookStatus.Completed, - ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled, - _ => ProcessBookStatus.Failed, - }; - } - - return Result; - } - - public async Task CancelAsync() - { - try - { - if (CurrentProcessable is AudioDecodable audioDecodable) - await audioDecodable.CancelAsync(); - } - catch (Exception ex) - { - Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling"); - } - } - - public void AddDownloadPdf() => AddProcessable(); - public void AddDownloadDecryptBook() => AddProcessable(); - public void AddConvertToMp3() => AddProcessable(); - - private void AddProcessable() where T : Processable, new() - { - Processes.Enqueue(() => new T()); - } - - public override string ToString() => LibraryBook.ToString(); - - #region Subscribers and Unsubscribers - - private void LinkProcessable(Processable processable) - { - processable.Begin += Processable_Begin; - processable.Completed += Processable_Completed; - processable.StreamingProgressChanged += Streamable_StreamingProgressChanged; - processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining; - - if (processable is AudioDecodable audioDecodable) - { - audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt; - audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered; - audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered; - audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered; - audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered; - } - } - - private void UnlinkProcessable(Processable processable) - { - processable.Begin -= Processable_Begin; - processable.Completed -= Processable_Completed; - processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged; - processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining; - - if (processable is AudioDecodable audioDecodable) - { - audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt; - audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered; - audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered; - audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered; - audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered; - } - } - - #endregion - - #region AudioDecodable event handlers - - private string title; - private string authorNames; - private string narratorNames; - private void AudioDecodable_TitleDiscovered(object sender, string title) - { - this.title = title; - updateBookInfo(); - } - - private void AudioDecodable_AuthorsDiscovered(object sender, string authors) - { - authorNames = authors; - updateBookInfo(); - } - - private void AudioDecodable_NarratorsDiscovered(object sender, string narrators) - { - narratorNames = narrators; - updateBookInfo(); - } - - private void updateBookInfo() - { - BookText = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}"; - } - - private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e) - { - var quality - = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null - ? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native) - : new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500); - - byte[] coverData = PictureStorage.GetPictureSynchronously(quality); - - AudioDecodable_CoverImageDiscovered(this, coverData); - return coverData; - } - - private void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt) - { - Cover = WinFormsUtil.TryLoadImageOrDefault(coverArt, PictureSize._80x80); - } - - #endregion - - #region Streamable event handlers - private void Streamable_StreamingTimeRemaining(object sender, TimeSpan timeRemaining) - { - TimeRemaining = timeRemaining; - } - - private void Streamable_StreamingProgressChanged(object sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress) - { - if (!downloadProgress.ProgressPercentage.HasValue) - return; - - if (downloadProgress.ProgressPercentage == 0) - TimeRemaining = TimeSpan.Zero; - else - Progress = (int)downloadProgress.ProgressPercentage; - } - - #endregion - - #region Processable event handlers - - private void Processable_Begin(object sender, LibraryBook libraryBook) - { - Status = ProcessBookStatus.Working; - - Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}"); - - title = libraryBook.Book.TitleWithSubtitle; - authorNames = libraryBook.Book.AuthorNames(); - narratorNames = libraryBook.Book.NarratorNames(); - updateBookInfo(); - } - - private async void Processable_Completed(object sender, LibraryBook libraryBook) - { - Logger.Info($"{((Processable)sender).Name} Step, Completed: {libraryBook.Book}"); - UnlinkProcessable((Processable)sender); - - if (Processes.Count == 0) - { - Completed?.Invoke(this, EventArgs.Empty); - return; - } - - NextProcessable(); - LinkProcessable(CurrentProcessable); - - StatusHandler result; - try - { - result = await CurrentProcessable.ProcessSingleAsync(libraryBook, validate: true); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, $"{nameof(Processable_Completed)} error"); - - result = new StatusHandler(); - result.AddError($"{nameof(Processable_Completed)} error. See log for details. Error summary: {ex.Message}"); - } - - if (result.HasErrors) - { - foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed")) - Logger.Error(errorMessage); - - Completed?.Invoke(this, EventArgs.Empty); - } - } - - #endregion - - #region Failure Handler - - private ProcessBookResult showRetry(LibraryBook libraryBook) - { - Logger.Error("ERROR. All books have not been processed. Most recent book: processing failed"); - - DialogResult? dialogResult = Configuration.Instance.BadBook switch - { - Configuration.BadBookAction.Abort => DialogResult.Abort, - Configuration.BadBookAction.Retry => DialogResult.Retry, - Configuration.BadBookAction.Ignore => DialogResult.Ignore, - Configuration.BadBookAction.Ask => null, - _ => null - }; - - string details; - try - { - static string trunc(string str) - => string.IsNullOrWhiteSpace(str) ? "[empty]" - : (str.Length > 50) ? $"{str.Truncate(47)}..." - : str; - - details = -$@" Title: {libraryBook.Book.TitleWithSubtitle} - ID: {libraryBook.Book.AudibleProductId} - Author: {trunc(libraryBook.Book.AuthorNames())} - Narr: {trunc(libraryBook.Book.NarratorNames())}"; - } - catch - { - details = "[Error retrieving details]"; - } - - // if null then ask user - dialogResult ??= MessageBox.Show(string.Format(SkipDialogText + "\r\n\r\nSee Settings to avoid this box in the future.", details), "Skip importing this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton); - - if (dialogResult == DialogResult.Abort) - return ProcessBookResult.FailedAbort; - - if (dialogResult == SkipResult) - { - libraryBook.UpdateBookStatus(LiberatedStatus.Error); - - Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}"); - - return ProcessBookResult.FailedSkip; - } - - return ProcessBookResult.FailedRetry; - } - - - private string SkipDialogText => @" -An error occurred while trying to process this book. -{0} - -- ABORT: Stop processing books. - -- RETRY: retry this book later. Just skip it for now. Continue processing books. (Will try this book again later.) - -- IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.) -".Trim(); - private MessageBoxButtons SkipDialogButtons => MessageBoxButtons.AbortRetryIgnore; - private MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button1; - private DialogResult SkipResult => DialogResult.Ignore; - } - - #endregion -} diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs index 9f840338..8bcfd6a2 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs @@ -1,4 +1,5 @@ -using System; +using LibationUiBase.ProcessQueue; +using System; using System.Drawing; using System.Windows.Forms; @@ -6,45 +7,74 @@ namespace LibationWinForms.ProcessQueue { internal partial class ProcessBookControl : UserControl { - private static int ControlNumberCounter = 0; - - /// - /// The contol's position within - /// - public int ControlNumber { get; } - private ProcessBookStatus Status { get; set; } = ProcessBookStatus.Queued; private readonly int CancelBtnDistanceFromEdge; private readonly int ProgressBarDistanceFromEdge; - public static Color FailedColor = Color.LightCoral; - public static Color CancelledColor = Color.Khaki; - public static Color QueuedColor = SystemColors.Control; - public static Color SuccessColor = Color.PaleGreen; + 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(); - statusLbl.Text = "Queued"; remainingTimeLbl.Visible = false; progressBar1.Visible = false; etaLbl.Visible = false; CancelBtnDistanceFromEdge = Width - cancelBtn.Location.X; ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width; - ControlNumber = ControlNumberCounter++; } - public void SetCover(Image cover) + private void OnContextChanging() { - pictureBox1.Image = cover; + if (Context is not null) + Context.PropertyChanged -= Context_PropertyChanged; } - public void SetBookInfo(string title) + private void OnContextChanged() { - bookInfoLbl.Text = title; + Context.PropertyChanged += Context_PropertyChanged; + Context_PropertyChanged(this, new System.ComponentModel.PropertyChangedEventArgs(null)); } - public void SetProgrss(int progress) + 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); + ResumeLayout(); + } + + 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}"; + + private void SetProgress(int progress) { //Disable slow fill //https://stackoverflow.com/a/5332770/3335599 @@ -53,34 +83,9 @@ namespace LibationWinForms.ProcessQueue progressBar1.Value = progress; } - public void SetRemainingTime(TimeSpan remaining) + private void SetStatus(ProcessBookStatus status, string statusText) { - remainingTimeLbl.Text = $"{remaining:mm\\:ss}"; - } - - public void SetResult(ProcessBookResult result) - { - (string statusText, ProcessBookStatus status) = result switch - { - ProcessBookResult.Success => ("Finished", ProcessBookStatus.Completed), - ProcessBookResult.Cancelled => ("Cancelled", ProcessBookStatus.Cancelled), - ProcessBookResult.FailedRetry => ("Error, will retry later", ProcessBookStatus.Failed), - ProcessBookResult.FailedSkip => ("Error, Skipping", ProcessBookStatus.Failed), - ProcessBookResult.FailedAbort => ("Error, Abort", ProcessBookStatus.Failed), - ProcessBookResult.ValidationFail => ("Validation fail", ProcessBookStatus.Failed), - ProcessBookResult.LicenseDenied => ("License Denied", ProcessBookStatus.Failed), - ProcessBookResult.LicenseDeniedPossibleOutage => ("Possible Service Interruption", ProcessBookStatus.Failed), - _ => ("UNKNOWN", ProcessBookStatus.Failed), - }; - - SetStatus(status, statusText); - } - - public void SetStatus(ProcessBookStatus status, string statusText = null) - { - Status = status; - - Color backColor = Status switch + Color backColor = status switch { ProcessBookStatus.Completed => SuccessColor, ProcessBookStatus.Cancelled => CancelledColor, @@ -89,27 +94,25 @@ namespace LibationWinForms.ProcessQueue _ => FailedColor }; - SuspendLayout(); - - cancelBtn.Visible = Status is ProcessBookStatus.Queued or ProcessBookStatus.Working; - moveLastBtn.Visible = Status == ProcessBookStatus.Queued; - moveDownBtn.Visible = Status == ProcessBookStatus.Queued; - moveUpBtn.Visible = Status == ProcessBookStatus.Queued; - moveFirstBtn.Visible = Status == ProcessBookStatus.Queued; - remainingTimeLbl.Visible = Status == ProcessBookStatus.Working; - progressBar1.Visible = Status == ProcessBookStatus.Working; - etaLbl.Visible = Status == ProcessBookStatus.Working; - statusLbl.Visible = Status != ProcessBookStatus.Working; - statusLbl.Text = statusText ?? Status.ToString(); + cancelBtn.Visible = status is ProcessBookStatus.Queued or ProcessBookStatus.Working; + moveLastBtn.Visible = status == ProcessBookStatus.Queued; + moveDownBtn.Visible = status == ProcessBookStatus.Queued; + moveUpBtn.Visible = status == ProcessBookStatus.Queued; + moveFirstBtn.Visible = status == ProcessBookStatus.Queued; + remainingTimeLbl.Visible = status == ProcessBookStatus.Working; + progressBar1.Visible = status == ProcessBookStatus.Working; + etaLbl.Visible = status == ProcessBookStatus.Working; + statusLbl.Visible = status != ProcessBookStatus.Working; + statusLbl.Text = statusText; BackColor = backColor; int deltaX = Width - cancelBtn.Location.X - CancelBtnDistanceFromEdge; - if (Status is ProcessBookStatus.Queued or ProcessBookStatus.Working && deltaX != 0) + if (status is ProcessBookStatus.Queued or ProcessBookStatus.Working && deltaX != 0) { //If the last book to occupy this control before resizing was not //queued, the buttons were not Visible so the Anchor property was - //ignored. Manually resize and reposition everyhting + //ignored. Manually resize and reposition everything cancelBtn.Location = new Point(cancelBtn.Location.X + deltaX, cancelBtn.Location.Y); moveFirstBtn.Location = new Point(moveFirstBtn.Location.X + deltaX, moveFirstBtn.Location.Y); @@ -129,13 +132,8 @@ namespace LibationWinForms.ProcessQueue { bookInfoLbl.Width = moveLastBtn.Location.X - bookInfoLbl.Location.X - bookInfoLbl.Padding.Left + moveLastBtn.Padding.Right; } - - ResumeLayout(); } - public override string ToString() - { - return bookInfoLbl.Text ?? "[NO TITLE]"; - } + public override string ToString() => bookInfoLbl.Text ?? "[NO TITLE]"; } } diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs new file mode 100644 index 00000000..f302b4b8 --- /dev/null +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs @@ -0,0 +1,14 @@ +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); +} diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs index d9a97565..85fdb856 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs @@ -55,7 +55,6 @@ this.panel2 = new System.Windows.Forms.Panel(); this.logCopyBtn = new System.Windows.Forms.Button(); this.clearLogBtn = new System.Windows.Forms.Button(); - this.counterTimer = new System.Windows.Forms.Timer(this.components); this.statusStrip1.SuspendLayout(); this.tabControl1.SuspendLayout(); this.tabPage1.SuspendLayout(); @@ -163,7 +162,6 @@ this.virtualFlowControl2.Name = "virtualFlowControl2"; this.virtualFlowControl2.Size = new System.Drawing.Size(390, 424); this.virtualFlowControl2.TabIndex = 3; - this.virtualFlowControl2.VirtualControlCount = 0; // // panel1 // @@ -329,11 +327,6 @@ this.clearLogBtn.UseVisualStyleBackColor = true; this.clearLogBtn.Click += new System.EventHandler(this.clearLogBtn_Click); // - // counterTimer - // - this.counterTimer.Interval = 950; - this.counterTimer.Tick += new System.EventHandler(this.CounterTimer_Tick); - // // ProcessQueueControl // this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); @@ -377,7 +370,6 @@ private System.Windows.Forms.Panel panel3; private System.Windows.Forms.Panel panel4; private System.Windows.Forms.ToolStripStatusLabel runningTimeLbl; - private System.Windows.Forms.Timer counterTimer; private System.Windows.Forms.DataGridView logDGV; private System.Windows.Forms.DataGridViewTextBoxColumn timestampColumn; private System.Windows.Forms.DataGridViewTextBoxColumn logEntryColumn; diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index 5f10bc04..7a67d31f 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -1,499 +1,220 @@ -using System; -using System.Collections.Generic; +using LibationFileManager; +using LibationUiBase; +using System; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Drawing; using System.Linq; -using System.Threading.Tasks; using System.Windows.Forms; -using ApplicationServices; -using LibationFileManager; -using LibationUiBase; -namespace LibationWinForms.ProcessQueue +#nullable enable +namespace LibationWinForms.ProcessQueue; + +internal partial class ProcessQueueControl : UserControl { - internal partial class ProcessQueueControl : UserControl, ILogForm + public ProcessQueueViewModel ViewModel { get; } = new(); + public ToolStripButton PopoutButton { get; } = new() { - private TrackedQueue Queue = new(); - private readonly LogMe Logger; - private int QueuedCount - { - set - { - queueNumberLbl.Text = value.ToString(); - queueNumberLbl.Visible = value > 0; - } - } - private int ErrorCount - { - set - { - errorNumberLbl.Text = value.ToString(); - errorNumberLbl.Visible = value > 0; - } - } + DisplayStyle = ToolStripItemDisplayStyle.Text, + Name = nameof(PopoutButton), + Text = "Pop Out", + TextAlign = ContentAlignment.MiddleCenter, + Alignment = ToolStripItemAlignment.Right, + Anchor = AnchorStyles.Bottom | AnchorStyles.Right, + }; - private int CompletedCount - { - set - { - completedNumberLbl.Text = value.ToString(); - completedNumberLbl.Visible = value > 0; - } - } + public ProcessQueueControl() + { + InitializeComponent(); - public Task QueueRunner { get; private set; } - public bool Running => !QueueRunner?.IsCompleted ?? false; - public ToolStripButton popoutBtn = new(); + var speedLimitMBps = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; + numericUpDown1.Value = speedLimitMBps > numericUpDown1.Maximum || speedLimitMBps < numericUpDown1.Minimum ? 0 : speedLimitMBps; + statusStrip1.Items.Add(PopoutButton); - public ProcessQueueControl() - { - InitializeComponent(); + virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked; - var speedLimitMBps = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; - numericUpDown1.Value = speedLimitMBps > numericUpDown1.Maximum || speedLimitMBps < numericUpDown1.Minimum ? 0 : speedLimitMBps; + ViewModel.LogWritten += (_, text) => WriteLine(text); + ViewModel.PropertyChanged += ProcessQueue_PropertyChanged; + virtualFlowControl2.Items = ViewModel.Items; + Load += ProcessQueueControl_Load; + } - popoutBtn.DisplayStyle = ToolStripItemDisplayStyle.Text; - popoutBtn.Name = "popoutBtn"; - popoutBtn.Text = "Pop Out"; - popoutBtn.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; - popoutBtn.Alignment = ToolStripItemAlignment.Right; - popoutBtn.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; - statusStrip1.Items.Add(popoutBtn); + private void ProcessQueueControl_Load(object? sender, EventArgs e) + { + if (DesignMode) return; + ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null)); + } - Logger = LogMe.RegisterForm(this); + public void WriteLine(string text) + { + if (!IsDisposed) + logDGV.Rows.Add(DateTime.Now, text.Trim()); + } - virtualFlowControl2.RequestData += VirtualFlowControl1_RequestData; - virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked; + 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(); + } - Queue.QueuededCountChanged += Queue_QueuededCountChanged; - Queue.CompletedCountChanged += Queue_CompletedCountChanged; - - Load += ProcessQueueControl_Load; - } - - private void ProcessQueueControl_Load(object sender, EventArgs e) - { - if (DesignMode) return; + private void btnClearFinished_Click(object? sender, EventArgs e) + { + ViewModel.Queue.ClearCompleted(); + virtualFlowControl2.RefreshDisplay(); + if (!ViewModel.Running) runningTimeLbl.Text = string.Empty; - QueuedCount = 0; - ErrorCount = 0; - CompletedCount = 0; - } + } - private bool isBookInQueue(DataLayer.LibraryBook libraryBook) + private void clearLogBtn_Click(object? sender, EventArgs e) + { + logDGV.Rows.Clear(); + } + + private void LogCopyBtn_Click(object? sender, EventArgs e) + { + string logText = string.Join("\r\n", logDGV.Rows.Cast().Select(r => $"{r.Cells[0].Value}\t{r.Cells[1].Value}")); + Clipboard.SetDataObject(logText, false, 5, 150); + } + + private void LogDGV_Resize(object? sender, EventArgs e) + { + logDGV.Columns[1].Width = logDGV.Width - logDGV.Columns[0].Width; + } + + #region View-Model update event handling + + private void ProcessQueue_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName is null or nameof(ViewModel.QueuedCount)) { - var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); - if (entry == null) - return false; - else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed) - return !Queue.RemoveCompleted(entry); - else - return true; + queueNumberLbl.Text = ViewModel.QueuedCount.ToString(); + queueNumberLbl.Visible = ViewModel.QueuedCount > 0; + virtualFlowControl2.RefreshDisplay(); } - - public bool RemoveCompleted(DataLayer.LibraryBook libraryBook) - => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBook entry - && entry.Status is ProcessBookStatus.Completed - && Queue.RemoveCompleted(entry); - - public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) - => AddDownloadPdf(new List() { libraryBook }); - - public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook) - => AddDownloadDecrypt(new List() { libraryBook }); - - public void AddConvertMp3(DataLayer.LibraryBook libraryBook) - => AddConvertMp3(new List() { libraryBook }); - - public void AddDownloadPdf(IEnumerable entries) + if (e.PropertyName is null or nameof(ViewModel.ErrorCount)) { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - ProcessBook pbook = new(entry, Logger); - pbook.PropertyChanged += Pbook_DataAvailable; - pbook.AddDownloadPdf(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); - AddToQueue(procs); + errorNumberLbl.Text = ViewModel.ErrorCount.ToString(); + errorNumberLbl.Visible = ViewModel.ErrorCount > 0; } - - public void AddDownloadDecrypt(IEnumerable entries) + if (e.PropertyName is null or nameof(ViewModel.CompletedCount)) { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - ProcessBook pbook = new(entry, Logger); - pbook.PropertyChanged += Pbook_DataAvailable; - pbook.AddDownloadDecryptBook(); - pbook.AddDownloadPdf(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); - AddToQueue(procs); + completedNumberLbl.Text = ViewModel.CompletedCount.ToString(); + completedNumberLbl.Visible = ViewModel.CompletedCount > 0; } - - public void AddConvertMp3(IEnumerable entries) + if (e.PropertyName is null or nameof(ViewModel.Progress)) { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - ProcessBook pbook = new(entry, Logger); - pbook.PropertyChanged += Pbook_DataAvailable; - pbook.AddConvertToMp3(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); - AddToQueue(procs); + toolStripProgressBar1.Maximum = ViewModel.Queue.Count; + toolStripProgressBar1.Value = ViewModel.Queue.Completed.Count; } - private void AddToQueue(IEnumerable pbook) + if (e.PropertyName is null or nameof(ViewModel.ProgressBarVisible)) { - BeginInvoke(() => - { - Queue.Enqueue(pbook); - if (!Running) - QueueRunner = QueueLoop(); - }); + toolStripProgressBar1.Visible = ViewModel.ProgressBarVisible; } - - DateTime StartingTime; - private async Task QueueLoop() + if (e.PropertyName is null or nameof(ViewModel.RunningTime)) { - try - { - Serilog.Log.Logger.Information("Begin processing queue"); - - StartingTime = DateTime.Now; - counterTimer.Start(); - - bool shownServiceOutageMessage = false; - - while (Queue.MoveNext()) - { - var nextBook = Queue.Current; - - Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook?.LibraryBook); - - var result = await nextBook.ProcessOneAsync(); - - Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook?.LibraryBook, result); - - if (result == ProcessBookResult.ValidationFail) - Queue.ClearCurrent(); - else if (result == ProcessBookResult.FailedAbort) - Queue.ClearQueue(); - else if (result == ProcessBookResult.FailedSkip) - nextBook.LibraryBook.UpdateBookStatus(DataLayer.LiberatedStatus.Error); - else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage) - { - MessageBox.Show(@$" -You were denied a content license for {nextBook.LibraryBook.Book.TitleWithSubtitle} - -This error appears to be caused by a temporary interruption of service that sometimes affects Libation's users. This type of error usually resolves itself in 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app. -", - "Possible Interruption of Service", - MessageBoxButtons.OK, - MessageBoxIcon.Asterisk); - shownServiceOutageMessage = true; - } - } - Serilog.Log.Logger.Information("Completed processing queue"); - - Queue_CompletedCountChanged(this, 0); - counterTimer.Stop(); - virtualFlowControl2.VirtualControlCount = Queue.Count; - UpdateAllControls(); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items"); - } - } - - public void WriteLine(string text) - { - if (IsDisposed) return; - - var timeStamp = DateTime.Now; - Invoke(() => logDGV.Rows.Add(timeStamp, text.Trim())); - } - - #region Control event handlers - - private void Queue_CompletedCountChanged(object sender, int e) - { - int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); - int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); - - ErrorCount = errCount; - CompletedCount = completeCount; - UpdateProgressBar(); - } - private void Queue_QueuededCountChanged(object sender, int cueCount) - { - QueuedCount = cueCount; - virtualFlowControl2.VirtualControlCount = Queue.Count; - UpdateProgressBar(); - } - private void UpdateProgressBar() - { - toolStripProgressBar1.Maximum = Queue.Count; - toolStripProgressBar1.Value = Queue.Completed.Count; - } - - private async void cancelAllBtn_Click(object sender, EventArgs e) - { - Queue.ClearQueue(); - if (Queue.Current is not null) - await Queue.Current.CancelAsync(); - virtualFlowControl2.VirtualControlCount = Queue.Count; - UpdateAllControls(); - } - - private void btnClearFinished_Click(object sender, EventArgs e) - { - Queue.ClearCompleted(); - virtualFlowControl2.VirtualControlCount = Queue.Count; - UpdateAllControls(); - - if (!Running) - runningTimeLbl.Text = string.Empty; - } - - private void CounterTimer_Tick(object sender, EventArgs e) - { - string timeToStr(TimeSpan time) - { - string minsSecs = $"{time:mm\\:ss}"; - if (time.TotalHours >= 1) - return $"{time.TotalHours:F0}:{minsSecs}"; - return minsSecs; - } - - if (Running) - runningTimeLbl.Text = timeToStr(DateTime.Now - StartingTime); - } - - private void clearLogBtn_Click(object sender, EventArgs e) - { - logDGV.Rows.Clear(); - } - - private void LogCopyBtn_Click(object sender, EventArgs e) - { - string logText = string.Join("\r\n", logDGV.Rows.Cast().Select(r => $"{r.Cells[0].Value}\t{r.Cells[1].Value}")); - Clipboard.SetDataObject(logText, false, 5, 150); - } - - private void LogDGV_Resize(object sender, EventArgs e) - { - logDGV.Columns[1].Width = logDGV.Width - logDGV.Columns[0].Width; - } - - #endregion - - #region View-Model update event handling - - /// - /// Index of the first visible in the - /// - private int FirstVisible = 0; - /// - /// Number of visible in the - /// - private int NumVisible = 0; - /// - /// Controls displaying the state, starting with - /// - private IReadOnlyList Panels; - - /// - /// Updates the display of a single at within - /// - /// index of the within the - /// The nme of the property that needs updating. If null, all properties are updated. - private void UpdateControl(int queueIndex, string propertyName = null) - { - try - { - int i = queueIndex - FirstVisible; - - if (i > NumVisible || i < 0) return; - - var proc = Queue[queueIndex]; - - Invoke(() => - { - Panels[i].SuspendLayout(); - if (propertyName is null or nameof(proc.Cover)) - Panels[i].SetCover(proc.Cover); - if (propertyName is null or nameof(proc.BookText)) - Panels[i].SetBookInfo(proc.BookText); - - if (proc.Result != ProcessBookResult.None) - { - Panels[i].SetResult(proc.Result); - return; - } - - if (propertyName is null or nameof(proc.Status)) - Panels[i].SetStatus(proc.Status); - if (propertyName is null or nameof(proc.Progress)) - Panels[i].SetProgrss(proc.Progress); - if (propertyName is null or nameof(proc.TimeRemaining)) - Panels[i].SetRemainingTime(proc.TimeRemaining); - Panels[i].ResumeLayout(); - }); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error updating the queued item's display."); - } - } - - private void UpdateAllControls() - { - int numToShow = Math.Min(NumVisible, Queue.Count - FirstVisible); - - for (int i = 0; i < numToShow; i++) - UpdateControl(FirstVisible + i); - } - - - /// - /// View notified the model that a botton was clicked - /// - /// index of the within - /// The clicked control to update - private async void VirtualFlowControl2_ButtonClicked(int queueIndex, string buttonName, ProcessBookControl panelClicked) - { - try - { - ProcessBook item = Queue[queueIndex]; - if (buttonName == nameof(panelClicked.cancelBtn)) - { - if (item is not null) - await item.CancelAsync(); - Queue.RemoveQueued(item); - virtualFlowControl2.VirtualControlCount = Queue.Count; - } - else if (buttonName == nameof(panelClicked.moveFirstBtn)) - { - Queue.MoveQueuePosition(item, QueuePosition.Fisrt); - UpdateAllControls(); - } - else if (buttonName == nameof(panelClicked.moveUpBtn)) - { - Queue.MoveQueuePosition(item, QueuePosition.OneUp); - UpdateControl(queueIndex); - if (queueIndex > 0) - UpdateControl(queueIndex - 1); - } - else if (buttonName == nameof(panelClicked.moveDownBtn)) - { - Queue.MoveQueuePosition(item, QueuePosition.OneDown); - UpdateControl(queueIndex); - if (queueIndex + 1 < Queue.Count) - UpdateControl(queueIndex + 1); - } - else if (buttonName == nameof(panelClicked.moveLastBtn)) - { - Queue.MoveQueuePosition(item, QueuePosition.Last); - UpdateAllControls(); - } - } - catch(Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error handling button click from queued item"); - } - } - - /// - /// View needs updating - /// - private void VirtualFlowControl1_RequestData(int firstIndex, int numVisible, IReadOnlyList panelsToFill) - { - FirstVisible = firstIndex; - NumVisible = numVisible; - Panels = panelsToFill; - UpdateAllControls(); - } - - /// - /// Model updates the view - /// - private void Pbook_DataAvailable(object sender, PropertyChangedEventArgs e) - { - int index = Queue.IndexOf((ProcessBook)sender); - UpdateControl(index, e.PropertyName); - } - - #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; + runningTimeLbl.Text = ViewModel.RunningTime; } } - public class NumericUpDownSuffix : NumericUpDown + + /// + /// View notified the model that a botton was clicked + /// + /// the whose button was clicked + /// The name of the button clicked + private async void VirtualFlowControl2_ButtonClicked(object? sender, string buttonName) { - [Description("Suffix displayed after numeric value."), Category("Data")] - [Browsable(true)] - [EditorBrowsable(EditorBrowsableState.Always)] - [DisallowNull] - public string Suffix + if (sender is not ProcessBookControl control || control.Context is not ProcessBookViewModel item) + return; + + try { - get => _suffix; - set + if (buttonName is nameof(ProcessBookControl.cancelBtn)) { - base.Text = string.IsNullOrEmpty(_suffix) ? base.Text : base.Text.Replace(_suffix, value); - _suffix = value; - ChangingText = true; + await item.CancelAsync(); + ViewModel.Queue.RemoveQueued(item); + virtualFlowControl2.RefreshDisplay(); + } + else + { + QueuePosition? position = buttonName switch + { + nameof(ProcessBookControl.moveFirstBtn) => QueuePosition.Fisrt, + nameof(ProcessBookControl.moveUpBtn) => QueuePosition.OneUp, + nameof(ProcessBookControl.moveDownBtn) => QueuePosition.OneDown, + nameof(ProcessBookControl.moveLastBtn) => QueuePosition.Last, + _ => null + }; + + if (position is not null) + { + ViewModel.Queue.MoveQueuePosition(item, position.Value); + virtualFlowControl2.RefreshDisplay(); + } } } - private string _suffix = string.Empty; - public override string Text + catch(Exception ex) { - get => string.IsNullOrEmpty(Suffix) ? base.Text : base.Text.Replace(Suffix, string.Empty); - set - { - if (Value == Minimum) - base.Text = "∞"; - else - base.Text = value + Suffix; - } + Serilog.Log.Logger.Error(ex, "Error handling button click from queued item"); + } + } + + #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; + } +} + +public class NumericUpDownSuffix : NumericUpDown +{ + [Description("Suffix displayed after numeric value."), Category("Data")] + [Browsable(true)] + [EditorBrowsable(EditorBrowsableState.Always)] + [DisallowNull] + public string Suffix + { + get => _suffix; + set + { + base.Text = string.IsNullOrEmpty(_suffix) ? base.Text : base.Text.Replace(_suffix, value); + _suffix = value; + ChangingText = true; + } + } + private string _suffix = string.Empty; + + [AllowNull] + public override string Text + { + get => string.IsNullOrEmpty(Suffix) ? base.Text : base.Text.Replace(Suffix, string.Empty); + set + { + if (Value == Minimum) + base.Text = "∞"; + else + base.Text = value + Suffix; } } } diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs new file mode 100644 index 00000000..e8fb4d1e --- /dev/null +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs @@ -0,0 +1,24 @@ +using DataLayer; +using LibationUiBase.ProcessQueue; +using System; +using System.Collections.Generic; + +#nullable enable +namespace LibationWinForms.ProcessQueue; + +internal class ProcessQueueViewModel : ProcessQueueViewModelBase +{ + public event EventHandler? LogWritten; + public List Items { get; } + + public ProcessQueueViewModel() : base(new List()) + { + Items = Queue.UnderlyingList as List + ?? 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); +} diff --git a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs index e0cadbc7..5733ba48 100644 --- a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs +++ b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs @@ -1,44 +1,42 @@ -using System; +using LibationUiBase.ProcessQueue; +using System; using System.Collections.Generic; using System.Drawing; using System.Windows.Forms; namespace LibationWinForms.ProcessQueue { - - internal delegate void RequestDataDelegate(int queueIndex, int numVisible, IReadOnlyList panelsToFill); - internal delegate void ControlButtonClickedDelegate(int queueIndex, string buttonName, ProcessBookControl panelClicked); internal partial class VirtualFlowControl : UserControl { - /// - /// Triggered when the needs to update the displayed s - /// - public event RequestDataDelegate RequestData; /// /// Triggered when one of the 's buttons has been clicked /// - public event ControlButtonClickedDelegate ButtonClicked; + public event EventHandler ButtonClicked; + + private List m_Items; + public List Items + { + get => m_Items; + set + { + m_Items = value; + if (m_Items is not null) + RefreshDisplay(); + } + } + + public void RefreshDisplay() + { + AdjustScrollBar(); + DoVirtualScroll(); + } #region Dynamic Properties /// /// The number of virtual s in the /// - public int VirtualControlCount - { - get => _virtualControlCount; - set - { - if (_virtualControlCount == 0) - vScrollBar1.Value = 0; - - _virtualControlCount = value; - AdjustScrollBar(); - DoVirtualScroll(); - } - } - - private int _virtualControlCount; + public int VirtualControlCount => Items?.Count ?? 0; int ScrollValue => Math.Max(vScrollBar1.Value, 0); /// @@ -100,12 +98,7 @@ namespace LibationWinForms.ProcessQueue { InitializeComponent(); - panel1.Resize += (_, _) => - { - AdjustScrollBar(); - DoVirtualScroll(); - }; - + panel1.Resize += (_, _) => RefreshDisplay(); var control = InitControl(0); VirtualControlHeight = this.DpiUnscale(control.Height + control.Margin.Top + control.Margin.Bottom); @@ -151,9 +144,7 @@ namespace LibationWinForms.ProcessQueue while (form is not ProcessBookControl) form = form.Parent; - int clickedIndex = BookControls.IndexOf((ProcessBookControl)form); - - ButtonClicked?.Invoke(FirstVisibleVirtualIndex + clickedIndex, button.Name, BookControls[clickedIndex]); + ButtonClicked?.Invoke(form, button.Name); } /// @@ -174,19 +165,20 @@ namespace LibationWinForms.ProcessQueue else { vScrollBar1.Enabled = true; - vScrollBar1.LargeChange = LargeScrollChange; //https://stackoverflow.com/a/2882878/3335599 - int newMaximum = VirtualHeight + vScrollBar1.LargeChange - 1; + int newMaximum = VirtualHeight + LargeScrollChange - 1; if (newMaximum < vScrollBar1.Maximum) vScrollBar1.Value = Math.Max(vScrollBar1.Value - (vScrollBar1.Maximum - newMaximum), 0); vScrollBar1.Maximum = newMaximum; + vScrollBar1.LargeChange = LargeScrollChange; } } /// /// Calculated the virtual controls that are in view at the currrent scroll position and windows size, - /// positions to simulate scroll activity, then fires to notify the model to update all visible controls + /// positions to simulate scroll activity, then fires updates the controls with + /// the context corresponding to the virtual scroll position /// private void DoVirtualScroll() { @@ -203,10 +195,15 @@ namespace LibationWinForms.ProcessQueue numVisible = Math.Min(numVisible, VirtualControlCount); numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible); - RequestData?.Invoke(firstVisible, numVisible, BookControls); + for (int i = 0; i < numVisible; i++) + { + BookControls[i].Context = Items[firstVisible + i]; + } for (int i = 0; i < BookControls.Count; i++) + { BookControls[i].Visible = i < numVisible; + } } /// diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 35aa0224..ba1416f0 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -22,6 +22,7 @@ namespace LibationWinForms static void Main() { Task> libraryLoadTask; + try { //// Uncomment to see Console. Must be called before anything writes to Console. @@ -86,7 +87,31 @@ namespace LibationWinForms var form1 = new Form1(); form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask); + LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = ShowMessageBox; Application.Run(form1); + + #region Message Box Handler for LibationUiBase + Task ShowMessageBox( + object owner, + string message, + string caption, + LibationUiBase.Forms.MessageBoxButtons buttons, + LibationUiBase.Forms.MessageBoxIcon icon, + LibationUiBase.Forms.MessageBoxDefaultButton defaultButton, + bool _) + { + var result = form1.Invoke(() => + MessageBox.Show( + owner as IWin32Window ?? form1, + message, + caption, + (MessageBoxButtons)buttons, + (MessageBoxIcon)icon, + (MessageBoxDefaultButton)defaultButton)); + + return Task.FromResult((LibationUiBase.Forms.DialogResult)result); + } + #endregion; } private static void RunInstaller(Configuration config) diff --git a/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj b/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj index f3171f1f..a5f48815 100644 --- a/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj +++ b/Source/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj @@ -26,7 +26,7 @@ - + diff --git a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj index b6127939..8054b41c 100644 --- a/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj +++ b/Source/_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj @@ -6,10 +6,10 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj index 5d6132c2..7e51effb 100644 --- a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj +++ b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj @@ -6,10 +6,10 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj index c5ceeb4d..287477bc 100644 --- a/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj +++ b/Source/_Tests/FileManager.Tests/FileManager.Tests.csproj @@ -7,10 +7,10 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj index 2a68084e..c51dd03a 100644 --- a/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj +++ b/Source/_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj @@ -7,10 +7,10 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 282a8354..4f92c27d 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -117,7 +117,6 @@ namespace TemplatesTests [DataRow(" 42 ", "", "", "1 8 1 8")] [DataRow(" - ", "", "", "1 8 - 1 8")] [DataRow("4 - 4", "", "", "1 8 - 1 8")] - [DataRow("4 - 4", "", "", "1 8 - 1 8")] [DataRow("", "", "", "100")] [DataRow(" ", "", "", "100")] [DataRow(" - - ", "", "", "- 100 -")] diff --git a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj index d6e52ffc..88e9775e 100644 --- a/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj +++ b/Source/_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj @@ -7,10 +7,10 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs b/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs index bc3702a7..492627f6 100644 --- a/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs +++ b/Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs @@ -61,9 +61,6 @@ namespace SearchEngineTests [DataRow("AudibleProductId:B000000123", "audibleproductid:b000000123")] [DataRow("ProductId:B000000123", "productid:b000000123")] - // bool keyword. Append :True - [DataRow("israted", "israted:True")] - // bool keyword with [:bool]. Do not add :True [DataRow("israted:True", "israted:True")] [DataRow("isRated:false", "israted:false")]