From ec497f4f811f4f573c268ca2651df4a123cc638d Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Mon, 19 May 2025 10:40:41 -0600 Subject: [PATCH 01/21] Use virtualized list to improve large queue performance --- Source/LibationAvalonia/Views/ProcessQueueControl.axaml | 5 +++++ 1 file changed, 5 insertions(+) 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"> + + + + + From 944645379e1ce884e2d9150325cd179370d8254c Mon Sep 17 00:00:00 2001 From: MBucari Date: Mon, 14 Jul 2025 12:19:26 -0600 Subject: [PATCH 02/21] Fix message box text truncation when there is no icon (#1294) --- .../Dialogs/MessageBoxWindow.axaml | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) 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 @@ - - From 9f8075041bea23a2685298cfaa7665d18a4b1dd3 Mon Sep 17 00:00:00 2001 From: MBucari Date: Mon, 14 Jul 2025 12:38:29 -0600 Subject: [PATCH 03/21] Only remove a LibraryBook from queue if we are trying to re-download. --- .../ViewModels/MainVM.ProcessQueue.cs | 16 ++++++++++------ Source/LibationWinForms/Form1.ProcessQueue.cs | 16 ++++++++++------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs index b964e6b9..0b53df4f 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs @@ -41,21 +41,25 @@ namespace LibationAvalonia.ViewModels { 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); + void initiateSingleDownload() + { + //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); + setQueueCollapseState(false); + } if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) { + initiateSingleDownload(); 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) { + initiateSingleDownload(); Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); - setQueueCollapseState(false); ProcessQueue.AddDownloadPdf(item); } else if (item.Book.Audio_Exists()) diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index f9c7ccc4..a19ce0b6 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -31,21 +31,25 @@ namespace LibationWinForms { 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); + void initiateSingleDownload() + { + //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); + SetQueueCollapseState(false); + } if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) { + initiateSingleDownload(); 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) { + initiateSingleDownload(); Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); - SetQueueCollapseState(false); processBookQueue1.AddDownloadPdf(item); } else if (item.Book.Audio_Exists()) From 9b1ce8c1d7419590410f898cab9dacf3b88dc933 Mon Sep 17 00:00:00 2001 From: MBucari Date: Mon, 14 Jul 2025 12:43:53 -0600 Subject: [PATCH 04/21] Update dependencies --- Source/AaxDecrypter/AaxDecrypter.csproj | 2 +- .../ApplicationServices/ApplicationServices.csproj | 4 ++-- Source/AudibleUtilities/AudibleUtilities.csproj | 2 +- Source/DataLayer/DataLayer.csproj | 6 +++--- Source/FileManager/FileManager.csproj | 2 +- Source/HangoverAvalonia/HangoverAvalonia.csproj | 10 +++++----- Source/LibationAvalonia/LibationAvalonia.csproj | 14 +++++++------- .../LibationFileManager/LibationFileManager.csproj | 2 +- Source/LibationUiBase/LibationUiBase.csproj | 2 +- .../WindowsConfigApp/WindowsConfigApp.csproj | 2 +- .../AudibleUtilities.Tests.csproj | 8 ++++---- .../FileLiberator.Tests/FileLiberator.Tests.csproj | 8 ++++---- .../FileManager.Tests/FileManager.Tests.csproj | 8 ++++---- .../LibationFileManager.Tests.csproj | 8 ++++---- .../LibationSearchEngine.Tests.csproj | 8 ++++---- 15 files changed, 43 insertions(+), 43 deletions(-) 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/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/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/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/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/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/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/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 From 5b9bf2fbb015a479133e946de66e5a8bcfb92e8c Mon Sep 17 00:00:00 2001 From: MBucari Date: Mon, 14 Jul 2025 12:53:47 -0600 Subject: [PATCH 05/21] Remove duplicate tests --- Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs | 1 - Source/_Tests/LibationSearchEngine.Tests/SearchEngineTests.cs | 3 --- 2 files changed, 4 deletions(-) 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/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")] From 2191c1536d580a23db335da26f52a1eebeace012 Mon Sep 17 00:00:00 2001 From: MBucari Date: Mon, 14 Jul 2025 13:17:48 -0600 Subject: [PATCH 06/21] Prepare Libation for win-arm64 releases Also add support for four-part version numbers in releases. --- .github/workflows/build-windows.yml | 25 ++++++++++++++++++------- .github/workflows/build.yml | 4 ++++ .github/workflows/release.yml | 2 +- .releaseindex.json | 16 ++++++++-------- 4 files changed, 31 insertions(+), 16 deletions(-) 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/.github/workflows/release.yml b/.github/workflows/release.yml index 50f6909e..421ef3a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: id: release uses: softprops/action-gh-release@v2 with: - name: Libation ${{ needs.prerelease.outputs.version }} + name: Libation v${{ needs.prerelease.outputs.version }} body: token: ${{ secrets.GITHUB_TOKEN }} draft: true 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" } From c9af2bba4bef9a35d376df19af58e8611b883e8a Mon Sep 17 00:00:00 2001 From: MBucari Date: Mon, 14 Jul 2025 14:43:48 -0600 Subject: [PATCH 07/21] Reduce GitHub API calls when no upgrades are available --- Source/AppScaffolding/LibationScaffolding.cs | 36 +++++++++----------- 1 file changed, 16 insertions(+), 20 deletions(-) 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))); } } From 38cc3e9725b70d4dac2e3aca1aee108db9df5693 Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 08:54:22 -0600 Subject: [PATCH 08/21] Revert change to release title --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 421ef3a8..50f6909e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: id: release uses: softprops/action-gh-release@v2 with: - name: Libation v${{ needs.prerelease.outputs.version }} + name: Libation ${{ needs.prerelease.outputs.version }} body: token: ${{ secrets.GITHUB_TOKEN }} draft: true From 0e49df06b82aef943b7b9b6161bf5c9603f6447f Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 10:31:53 -0600 Subject: [PATCH 09/21] Add message box handler to LibationUiBase --- Source/LibationAvalonia/App.axaml.cs | 5 + Source/LibationAvalonia/AvaloniaUtils.cs | 1 + .../Controls/Settings/Audio.axaml.cs | 1 + .../Settings/DownloadDecrypt.axaml.cs | 1 + .../Dialogs/AboutDialog.axaml.cs | 1 + .../Dialogs/AccountsDialog.axaml.cs | 1 + .../Dialogs/BookRecordsDialog.axaml.cs | 1 + .../LibationAvalonia/Dialogs/DialogWindow.cs | 1 + .../Dialogs/EditTemplateDialog.axaml.cs | 1 + .../Dialogs/ImageDisplayDialog.axaml.cs | 1 + .../Dialogs/LibationFilesDialog.axaml.cs | 1 + .../Dialogs/Login/AvaloniaLoginCallback.cs | 1 + .../Dialogs/Login/AvaloniaLoginChoiceEager.cs | 1 + .../Dialogs/Login/MfaDialog.axaml.cs | 1 + .../Dialogs/Login/WebLoginDialog.axaml.cs | 1 + .../MessageBoxAlertAdminDialog.axaml.cs | 1 + .../Dialogs/MessageBoxWindow.axaml.cs | 1 + .../Dialogs/ScanAccountsDialog.axaml.cs | 2 +- .../Dialogs/SettingsDialog.axaml.cs | 1 + .../Dialogs/SetupDialog.axaml.cs | 1 + .../Dialogs/ThemePickerDialog.axaml.cs | 1 + .../UpgradeNotificationDialog.axaml.cs | 1 + Source/LibationAvalonia/MessageBox.cs | 50 +------ .../ViewModels/Dialogs/MessageBoxViewModel.cs | 3 +- .../ViewModels/MainVM.Filters.cs | 1 + .../ViewModels/MainVM.Import.cs | 1 + .../ViewModels/MainVM.Liberate.cs | 1 + .../ViewModels/MainVM.VisibleBooks.cs | 1 + .../ViewModels/ProcessBookViewModel.cs | 2 +- .../ViewModels/ProcessQueueViewModel.cs | 1 + .../ViewModels/ProductsDisplayViewModel.cs | 2 +- .../Views/MainWindow.axaml.cs | 1 + .../Views/ProductsDisplay.axaml.cs | 2 +- Source/LibationAvalonia/Walkthrough.cs | 1 + Source/LibationUiBase/MessageBoxBase.cs | 133 ++++++++++++++++++ Source/LibationWinForms/Program.cs | 4 + 36 files changed, 178 insertions(+), 52 deletions(-) create mode 100644 Source/LibationUiBase/MessageBoxBase.cs 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/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/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.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/MessageBox.cs b/Source/LibationAvalonia/MessageBox.cs index 0142bc41..7519606d 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,54 +14,9 @@ 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) @@ -71,8 +27,8 @@ namespace LibationAvalonia => 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); + 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); 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..c5710980 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using System.Threading.Tasks; using DataLayer; +using LibationUiBase.Forms; #nullable enable namespace LibationAvalonia.ViewModels diff --git a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs index f9a2aeb7..75d59a18 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs @@ -5,6 +5,7 @@ using DataLayer; using Avalonia.Threading; using LibationAvalonia.Dialogs; using ReactiveUI; +using LibationUiBase.Forms; #nullable enable namespace LibationAvalonia.ViewModels diff --git a/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs index 908ee707..8e750812 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs @@ -1,7 +1,6 @@ using ApplicationServices; using AudibleApi; using AudibleApi.Common; -using Avalonia.Media; using Avalonia.Media.Imaging; using Avalonia.Threading; using DataLayer; @@ -10,6 +9,7 @@ using Dinah.Core.ErrorHandling; using FileLiberator; using LibationFileManager; using LibationUiBase; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.Collections.Generic; diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index 29620384..ed5b6501 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -5,6 +5,7 @@ using Avalonia.Threading; using DataLayer; using LibationFileManager; using LibationUiBase; +using LibationUiBase.Forms; using ReactiveUI; using System; using System.Collections.Generic; 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/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/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/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 35aa0224..01af7fa9 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -22,6 +22,10 @@ namespace LibationWinForms static void Main() { Task> libraryLoadTask; + + LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) => + Task.FromResult((LibationUiBase.Forms.DialogResult)MessageBox.Show(owner as IWin32Window, message, caption, (MessageBoxButtons)buttons, (MessageBoxIcon)icon, (MessageBoxDefaultButton)defaultButton)); + try { //// Uncomment to see Console. Must be called before anything writes to Console. From abd18d74b00cc85add401edb19725890e0dea5b6 Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 11:44:45 -0600 Subject: [PATCH 10/21] Fix crash when setting drive root as custom directory (#1300) --- .../Controls/DirectoryOrCustomSelectControl.axaml.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) From 3d50643ab04ae812791e44bbc29d576eeaaac075 Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 11:48:19 -0600 Subject: [PATCH 11/21] Fix visible book counts being incorrect on startup If quick filters are applied on startup, a race condition was created between the initial library load book counting and the visible books counting. Only display results of the latest book count. --- Source/LibationWinForms/Form1.VisibleBooks.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Source/LibationWinForms/Form1.VisibleBooks.cs b/Source/LibationWinForms/Form1.VisibleBooks.cs index ac24d058..aeaf4be6 100644 --- a/Source/LibationWinForms/Form1.VisibleBooks.cs +++ b/Source/LibationWinForms/Form1.VisibleBooks.cs @@ -25,9 +25,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) From b65b1e819be93acb0a250736dc62ebf0f3fe366e Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 13:27:05 -0600 Subject: [PATCH 12/21] Consolidate queue commands into UI base --- .../ViewModels/MainVM.Liberate.cs | 27 +++--- .../ViewModels/MainVM.ProcessQueue.cs | 75 ++++------------- .../ViewModels/MainVM.VisibleBooks.cs | 13 +-- .../ViewModels/ProcessQueueViewModel.cs | 11 +-- Source/LibationUiBase/IProcessQueue.cs | 82 +++++++++++++++++++ Source/LibationWinForms/Form1.Liberate.cs | 27 ++---- Source/LibationWinForms/Form1.ProcessQueue.cs | 67 +++------------ Source/LibationWinForms/Form1.VisibleBooks.cs | 12 +-- .../ProcessQueue/ProcessQueueControl.cs | 14 +--- 9 files changed, 141 insertions(+), 187 deletions(-) create mode 100644 Source/LibationUiBase/IProcessQueue.cs diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs index c5710980..267dc00b 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Threading.Tasks; using DataLayer; using LibationUiBase.Forms; +using LibationUiBase; +using System.Collections.Generic; #nullable enable namespace LibationAvalonia.ViewModels @@ -13,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) { @@ -33,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() @@ -49,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 0b53df4f..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,54 +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]; - - void initiateSingleDownload() + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId); + if (!Go.To.File(filePath?.ShortPathName)) { - //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); - setQueueCollapseState(false); - } - - if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) - { - initiateSingleDownload(); - Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item); - ProcessQueue.AddDownloadDecrypt(item); - } - else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - { - initiateSingleDownload(); - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); - 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); } } } @@ -98,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) { @@ -114,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 75d59a18..728a0b1f 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs @@ -6,6 +6,8 @@ using Avalonia.Threading; using LibationAvalonia.Dialogs; using ReactiveUI; using LibationUiBase.Forms; +using System.Linq; +using LibationUiBase; #nullable enable namespace LibationAvalonia.ViewModels @@ -72,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/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index ed5b6501..7c9430aa 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -17,7 +17,7 @@ using System.Threading.Tasks; namespace LibationAvalonia.ViewModels { - public class ProcessQueueViewModel : ViewModelBase, ILogForm + public class ProcessQueueViewModel : ViewModelBase, ILogForm, IProcessQueue { public ObservableCollection LogEntries { get; } = new(); public AvaloniaList Items { get; } = new(); @@ -136,15 +136,6 @@ namespace LibationAvalonia.ViewModels && 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(); diff --git a/Source/LibationUiBase/IProcessQueue.cs b/Source/LibationUiBase/IProcessQueue.cs new file mode 100644 index 00000000..84b21e0f --- /dev/null +++ b/Source/LibationUiBase/IProcessQueue.cs @@ -0,0 +1,82 @@ +using DataLayer; +using System.Collections.Generic; +using System.Linq; + +namespace LibationUiBase; + +public interface IProcessQueue +{ + bool RemoveCompleted(LibraryBook libraryBook); + void AddDownloadPdf(IEnumerable entries); + void AddConvertMp3(IEnumerable entries); + void AddDownloadDecrypt(IEnumerable entries); +} + +public static class ProcessQueueExtensions +{ + + public static bool QueueDownloadPdf(this IProcessQueue queue, IList libraryBooks) + { + var needsPdf = libraryBooks.Where(lb => !lb.AbsentFromLastScan && lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated).ToArray(); + if (needsPdf.Length > 0) + { + Serilog.Log.Logger.Information("Begin download {count} pdfs", needsPdf.Length); + queue.AddDownloadPdf(needsPdf); + return true; + } + return false; + } + + public static bool QueueConvertToMp3(this IProcessQueue queue, 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); + queue.AddConvertMp3(preLiberated); + return true; + } + return false; + } + + public static bool QueueDownloadDecrypt(this IProcessQueue queue, IList libraryBooks) + { + if (libraryBooks.Count == 1) + { + var item = libraryBooks[0]; + + if (item.AbsentFromLastScan) + return false; + else if(item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + { + queue.RemoveCompleted(item); + Serilog.Log.Logger.Information("Begin single library book backup of {libraryBook}", item); + queue.AddDownloadDecrypt([item]); + return true; + } + else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + { + queue.RemoveCompleted(item); + Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); + queue.AddDownloadPdf([item]); + return true; + } + } + else + { + var toLiberate + = libraryBooks + .Where(x => !x.AbsentFromLastScan && x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + .ToArray(); + + if (toLiberate.Length > 0) + { + Serilog.Log.Logger.Information("Begin backup of {count} library books", toLiberate.Length); + queue.AddDownloadDecrypt(toLiberate); + return true; + } + } + return false; + } +} diff --git a/Source/LibationWinForms/Form1.Liberate.cs b/Source/LibationWinForms/Form1.Liberate.cs index 256b977e..2bda3e52 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.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.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.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 a19ce0b6..41d28080 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; @@ -27,53 +29,16 @@ namespace LibationWinForms { try { - if (libraryBooks.Length == 1) + if (processBookQueue1.QueueDownloadDecrypt(libraryBooks)) + SetQueueCollapseState(false); + else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists()) { - var item = libraryBooks[0]; - - void initiateSingleDownload() + // liberated: open explorer to file + var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId); + if (!Go.To.File(filePath?.ShortPathName)) { - //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); - SetQueueCollapseState(false); - } - - if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) - { - initiateSingleDownload(); - Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item); - processBookQueue1.AddDownloadDecrypt(item); - } - else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - { - initiateSingleDownload(); - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); - 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); } } } @@ -87,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.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray())) + SetQueueCollapseState(false); } catch (Exception ex) { @@ -103,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.QueueConvertToMp3(libraryBooks)) SetQueueCollapseState(false); - processBookQueue1.AddConvertMp3(preLiberated); - } } catch (Exception ex) { diff --git a/Source/LibationWinForms/Form1.VisibleBooks.cs b/Source/LibationWinForms/Form1.VisibleBooks.cs index aeaf4be6..a72bb40b 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 @@ -58,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.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray())) + SetQueueCollapseState(false); } catch (Exception ex) { diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index 5f10bc04..fce5420a 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -11,7 +11,7 @@ using LibationUiBase; namespace LibationWinForms.ProcessQueue { - internal partial class ProcessQueueControl : UserControl, ILogForm + internal partial class ProcessQueueControl : UserControl, ILogForm, IProcessQueue { private TrackedQueue Queue = new(); private readonly LogMe Logger; @@ -97,15 +97,6 @@ namespace LibationWinForms.ProcessQueue && 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) { List procs = new(); @@ -162,6 +153,9 @@ namespace LibationWinForms.ProcessQueue } private void AddToQueue(IEnumerable pbook) { + if (!IsHandleCreated) + CreateControl(); + BeginInvoke(() => { Queue.Enqueue(pbook); From 1cf889eed71a1194d91ca050e6a70cef987541ba Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 15:01:16 -0600 Subject: [PATCH 13/21] Move ProcessBookViewModel logic into LiationUiBase --- .../Controls/ThemePreviewControl.axaml.cs | 4 +- .../ViewModels/ProcessBookViewModel.cs | 421 +----------------- .../ViewModels/ProcessQueueViewModel.cs | 1 + .../Views/ProcessBookControl.axaml.cs | 1 + .../Views/ProcessQueueControl.axaml.cs | 1 + .../ProcessQueue/ProcessBookViewModelBase.cs | 420 +++++++++++++++++ Source/LibationUiBase/ReactiveObject.cs | 33 ++ .../ProcessQueue/ProcessBook.cs | 410 ----------------- .../ProcessQueue/ProcessBookControl.cs | 3 +- .../ProcessQueue/ProcessBookViewModel.cs | 16 + .../ProcessQueue/ProcessQueueControl.cs | 38 +- 11 files changed, 504 insertions(+), 844 deletions(-) create mode 100644 Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs create mode 100644 Source/LibationUiBase/ReactiveObject.cs delete mode 100644 Source/LibationWinForms/ProcessQueue/ProcessBook.cs create mode 100644 Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs 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/ViewModels/ProcessBookViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs index 8e750812..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.Imaging; -using Avalonia.Threading; -using DataLayer; -using Dinah.Core; -using Dinah.Core.ErrorHandling; -using FileLiberator; +using DataLayer; using LibationFileManager; using LibationUiBase; -using LibationUiBase.Forms; -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 7c9430aa..7eed8118 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -6,6 +6,7 @@ using DataLayer; using LibationFileManager; using LibationUiBase; using LibationUiBase.Forms; +using LibationUiBase.ProcessQueue; using ReactiveUI; using System; using System.Collections.Generic; 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.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs index de001556..8eda2373 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; diff --git a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs new file mode 100644 index 00000000..1b535168 --- /dev/null +++ b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs @@ -0,0 +1,420 @@ +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 +{ + public event EventHandler? Completed; + + 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 Processable? NextProcessable() => _currentProcessable = null; + private Processable? _currentProcessable; + protected readonly Queue> Processes = 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 showRetry(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 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) + { + Cover = LoadImageFromBytes(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; + + 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 + + protected 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 MessageBoxBase.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 + +} diff --git a/Source/LibationUiBase/ReactiveObject.cs b/Source/LibationUiBase/ReactiveObject.cs new file mode 100644 index 00000000..d7c8b723 --- /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) => this.UIThreadSync(() => PropertyChanging?.Invoke(this, args)); + public void RaisePropertyChanging(string propertyName) => RaisePropertyChanging(new PropertyChangingEventArgs(propertyName)); + public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => 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/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..07d24012 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; diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs new file mode 100644 index 00000000..80d4f405 --- /dev/null +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs @@ -0,0 +1,16 @@ +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); + + public string BookText => $"{Title}\r\nBy {Author}\r\nNarrated by {Narrator}"; +} diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index fce5420a..2251ee37 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -2,18 +2,20 @@ using System.Collections.Generic; 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; +using LibationUiBase.ProcessQueue; namespace LibationWinForms.ProcessQueue { internal partial class ProcessQueueControl : UserControl, ILogForm, IProcessQueue { - private TrackedQueue Queue = new(); + private TrackedQueue Queue = new(); private readonly LogMe Logger; private int QueuedCount { @@ -93,19 +95,19 @@ namespace LibationWinForms.ProcessQueue } public bool RemoveCompleted(DataLayer.LibraryBook libraryBook) - => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBook entry + => Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModel entry && entry.Status is ProcessBookStatus.Completed && Queue.RemoveCompleted(entry); public void AddDownloadPdf(IEnumerable entries) { - List procs = new(); + List procs = new(); foreach (var entry in entries) { if (isBookInQueue(entry)) continue; - ProcessBook pbook = new(entry, Logger); + ProcessBookViewModel pbook = new(entry, Logger); pbook.PropertyChanged += Pbook_DataAvailable; pbook.AddDownloadPdf(); procs.Add(pbook); @@ -117,13 +119,13 @@ namespace LibationWinForms.ProcessQueue public void AddDownloadDecrypt(IEnumerable entries) { - List procs = new(); + List procs = new(); foreach (var entry in entries) { if (isBookInQueue(entry)) continue; - ProcessBook pbook = new(entry, Logger); + ProcessBookViewModel pbook = new(entry, Logger); pbook.PropertyChanged += Pbook_DataAvailable; pbook.AddDownloadDecryptBook(); pbook.AddDownloadPdf(); @@ -136,13 +138,13 @@ namespace LibationWinForms.ProcessQueue public void AddConvertMp3(IEnumerable entries) { - List procs = new(); + List procs = new(); foreach (var entry in entries) { if (isBookInQueue(entry)) continue; - ProcessBook pbook = new(entry, Logger); + ProcessBookViewModel pbook = new(entry, Logger); pbook.PropertyChanged += Pbook_DataAvailable; pbook.AddConvertToMp3(); procs.Add(pbook); @@ -151,7 +153,7 @@ namespace LibationWinForms.ProcessQueue Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); AddToQueue(procs); } - private void AddToQueue(IEnumerable pbook) + private void AddToQueue(IEnumerable pbook) { if (!IsHandleCreated) CreateControl(); @@ -303,22 +305,22 @@ This error appears to be caused by a temporary interruption of service that some #region View-Model update event handling /// - /// Index of the first visible in the + /// Index of the first visible in the /// private int FirstVisible = 0; /// - /// Number of visible in the + /// Number of visible in the /// private int NumVisible = 0; /// - /// Controls displaying the state, starting with + /// Controls displaying the state, starting with /// private IReadOnlyList Panels; /// /// Updates the display of a single at within /// - /// index of the within the + /// 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) { @@ -334,8 +336,8 @@ This error appears to be caused by a temporary interruption of service that some { 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].SetCover(proc.Cover as Image); + if (propertyName is null or nameof(proc.Title) or nameof(proc.Author) or nameof(proc.Narrator)) Panels[i].SetBookInfo(proc.BookText); if (proc.Result != ProcessBookResult.None) @@ -371,13 +373,13 @@ This error appears to be caused by a temporary interruption of service that some /// /// View notified the model that a botton was clicked /// - /// index of the within + /// 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]; + ProcessBookViewModel item = Queue[queueIndex]; if (buttonName == nameof(panelClicked.cancelBtn)) { if (item is not null) @@ -432,7 +434,7 @@ This error appears to be caused by a temporary interruption of service that some /// private void Pbook_DataAvailable(object sender, PropertyChangedEventArgs e) { - int index = Queue.IndexOf((ProcessBook)sender); + int index = Queue.IndexOf((ProcessBookViewModel)sender); UpdateControl(index, e.PropertyName); } From 4dab16837e5c89f0edbe030a9e947a6398eb9215 Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 22:01:54 -0600 Subject: [PATCH 14/21] Move ProcessQueueViewModel logic into LibationUiBase Fix UI bug in classic when queue is in popped-out mode. --- .../ViewModels/ProcessQueueViewModel.cs | 290 +------ .../Views/ProcessQueueControl.axaml.cs | 2 +- Source/LibationUiBase/IProcessQueue.cs | 82 -- .../ProcessQueue/ProcessQueueViewModelBase.cs | 324 ++++++++ Source/LibationUiBase/TrackedQueue[T].cs | 14 +- Source/LibationWinForms/Form1.Liberate.cs | 6 +- Source/LibationWinForms/Form1.ProcessQueue.cs | 18 +- Source/LibationWinForms/Form1.VisibleBooks.cs | 2 +- .../ProcessQueue/ProcessBookViewModel.cs | 2 - .../ProcessQueueControl.Designer.cs | 7 - .../ProcessQueue/ProcessQueueControl.cs | 730 +++++++----------- .../ProcessQueue/ProcessQueueViewModel.cs | 74 ++ 12 files changed, 723 insertions(+), 828 deletions(-) delete mode 100644 Source/LibationUiBase/IProcessQueue.cs create mode 100644 Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs create mode 100644 Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index 7eed8118..498dfd47 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -1,282 +1,40 @@ -using ApplicationServices; -using Avalonia.Collections; +using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Threading; using DataLayer; using LibationFileManager; -using LibationUiBase; -using LibationUiBase.Forms; using LibationUiBase.ProcessQueue; -using ReactiveUI; 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 class ProcessQueueViewModel : ProcessQueueViewModelBase { - - public class ProcessQueueViewModel : ViewModelBase, ILogForm, IProcessQueue + public override void WriteLine(string text) { - 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; - - private readonly LogMe Logger; - - public ProcessQueueViewModel() - { - 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; - } - - 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 - { - get + Dispatcher.UIThread.Invoke(() => + LogEntries.Add(new() { - return _speedLimit; - } - set - { - var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024)); - var config = Configuration.Instance; - config.DownloadSpeedLimit = newValue; - - _speedLimit - = config.DownloadSpeedLimit <= newValue ? value - : value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024 - : 0; - - config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024); - - SpeedLimitIncrement = _speedLimit > 100 ? 10 - : _speedLimit > 10 ? 1 - : _speedLimit > 1 ? 0.1m - : 0.01m; - - 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(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); - } + LogDate = DateTime.Now, + LogMessage = text.Trim() + })); } - public class LogEntry + public ProcessQueueViewModel() : base(CreateEmptyList()) { - public DateTime LogDate { get; init; } - public string LogDateString => LogDate.ToShortTimeString(); - public string? LogMessage { get; init; } + Items = Queue.UnderlyingList as AvaloniaList + ?? throw new ArgumentNullException(nameof(Queue.UnderlyingList)); + } + + public AvaloniaList Items { get; } + protected override ProcessBookViewModelBase CreateNewBook(LibraryBook libraryBook) + => new ProcessBookViewModel(libraryBook, Logger); + + private static AvaloniaList CreateEmptyList() + { + if (Design.IsDesignMode) + _ = Configuration.Instance.LibationFiles; + return new AvaloniaList(); } } diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs index 8eda2373..97ae56f9 100644 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs +++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs @@ -17,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/LibationUiBase/IProcessQueue.cs b/Source/LibationUiBase/IProcessQueue.cs deleted file mode 100644 index 84b21e0f..00000000 --- a/Source/LibationUiBase/IProcessQueue.cs +++ /dev/null @@ -1,82 +0,0 @@ -using DataLayer; -using System.Collections.Generic; -using System.Linq; - -namespace LibationUiBase; - -public interface IProcessQueue -{ - bool RemoveCompleted(LibraryBook libraryBook); - void AddDownloadPdf(IEnumerable entries); - void AddConvertMp3(IEnumerable entries); - void AddDownloadDecrypt(IEnumerable entries); -} - -public static class ProcessQueueExtensions -{ - - public static bool QueueDownloadPdf(this IProcessQueue queue, IList libraryBooks) - { - var needsPdf = libraryBooks.Where(lb => !lb.AbsentFromLastScan && lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated).ToArray(); - if (needsPdf.Length > 0) - { - Serilog.Log.Logger.Information("Begin download {count} pdfs", needsPdf.Length); - queue.AddDownloadPdf(needsPdf); - return true; - } - return false; - } - - public static bool QueueConvertToMp3(this IProcessQueue queue, 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); - queue.AddConvertMp3(preLiberated); - return true; - } - return false; - } - - public static bool QueueDownloadDecrypt(this IProcessQueue queue, IList libraryBooks) - { - if (libraryBooks.Count == 1) - { - var item = libraryBooks[0]; - - if (item.AbsentFromLastScan) - return false; - else if(item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) - { - queue.RemoveCompleted(item); - Serilog.Log.Logger.Information("Begin single library book backup of {libraryBook}", item); - queue.AddDownloadDecrypt([item]); - return true; - } - else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - { - queue.RemoveCompleted(item); - Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); - queue.AddDownloadPdf([item]); - return true; - } - } - else - { - var toLiberate - = libraryBooks - .Where(x => !x.AbsentFromLastScan && x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - .ToArray(); - - if (toLiberate.Length > 0) - { - Serilog.Log.Logger.Information("Begin backup of {count} library books", toLiberate.Length); - queue.AddDownloadDecrypt(toLiberate); - return true; - } - } - return false; - } -} diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs new file mode 100644 index 00000000..40ff8da8 --- /dev/null +++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs @@ -0,0 +1,324 @@ +using DataLayer; +using LibationFileManager; +using LibationUiBase.Forms; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using ApplicationServices; +using System.Threading.Tasks; + +#nullable enable +namespace LibationUiBase.ProcessQueue; + +public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm +{ + public abstract void WriteLine(string text); + + protected abstract ProcessBookViewModelBase CreateNewBook(LibraryBook libraryBook); + + public ObservableCollection LogEntries { get; } = new(); + public TrackedQueue Queue { get; } + public ProcessBookViewModelBase? SelectedItem { get; set; } + public Task? QueueRunner { get; private set; } + public bool Running => !QueueRunner?.IsCompleted ?? false; + + protected readonly LogMe Logger; + + public ProcessQueueViewModelBase(ICollection? underlyingList) + { + Logger = LogMe.RegisterForm(this); + Queue = new(underlyingList); + Queue.QueuededCountChanged += Queue_QueuededCountChanged; + Queue.CompletedCountChanged += Queue_CompletedCountChanged; + SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; + } + + 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 { 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; + + public decimal SpeedLimit + { + get + { + return _speedLimit; + } + set + { + var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024)); + var config = Configuration.Instance; + config.DownloadSpeedLimit = newValue; + + _speedLimit + = config.DownloadSpeedLimit <= newValue ? value + : value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024 + : 0; + + config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024); + + SpeedLimitIncrement = _speedLimit > 100 ? 10 + : _speedLimit > 10 ? 1 + : _speedLimit > 1 ? 0.1m + : 0.01m; + + RaisePropertyChanged(nameof(SpeedLimitIncrement)); + RaisePropertyChanged(nameof(SpeedLimit)); + } + } + + public 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; + RaisePropertyChanged(nameof(Progress)); + } + private void Queue_QueuededCountChanged(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.AbsentFromLastScan && lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated).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.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + { + RemoveCompleted(item); + Serilog.Log.Logger.Information("Begin single library book backup of {libraryBook}", item); + AddDownloadDecrypt([item]); + return true; + } + else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + { + RemoveCompleted(item); + Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); + AddDownloadPdf([item]); + return true; + } + } + else + { + var toLiberate + = libraryBooks + .Where(x => !x.AbsentFromLastScan && x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + .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) + { + 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; + } + + 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) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + var pbook = CreateNewBook(entry); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + private void AddDownloadDecrypt(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + var pbook = CreateNewBook(entry); + pbook.AddDownloadDecryptBook(); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + private void AddConvertMp3(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + var pbook = CreateNewBook(entry); + pbook.AddConvertToMp3(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + private void AddToQueue(IEnumerable pbook) + { + Invoke(() => + { + Queue.Enqueue(pbook); + if (!Running) + QueueRunner = QueueLoop(); + }); + } + + #endregion + + private 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 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}\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 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"); + } + } + + 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); + } +} + +public class LogEntry +{ + public DateTime LogDate { get; init; } + public string LogDateString => LogDate.ToShortTimeString(); + public string? LogMessage { get; init; } +} diff --git a/Source/LibationUiBase/TrackedQueue[T].cs b/Source/LibationUiBase/TrackedQueue[T].cs index 51f36f0b..e36dac4b 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? QueuededCountChanged; - 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; } @@ -169,7 +171,7 @@ namespace LibationUiBase } } - public T FirstOrDefault(Func predicate) + public T? FirstOrDefault(Func predicate) { lock (lockObject) { diff --git a/Source/LibationWinForms/Form1.Liberate.cs b/Source/LibationWinForms/Form1.Liberate.cs index 2bda3e52..9b905be4 100644 --- a/Source/LibationWinForms/Form1.Liberate.cs +++ b/Source/LibationWinForms/Form1.Liberate.cs @@ -17,7 +17,7 @@ namespace LibationWinForms try { var unliberated = await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray()); - if (processBookQueue1.QueueDownloadDecrypt(unliberated)) + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated)) SetQueueCollapseState(false); } catch (Exception ex) @@ -28,7 +28,7 @@ namespace LibationWinForms private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e) { - if (processBookQueue1.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) + if (processBookQueue1.ViewModel.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) SetQueueCollapseState(false); } @@ -42,7 +42,7 @@ namespace LibationWinForms "Convert all M4b => Mp3?", MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (result == DialogResult.Yes && processBookQueue1.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) + if (result == DialogResult.Yes && processBookQueue1.ViewModel.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()))) SetQueueCollapseState(false); } } diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index 41d28080..f6260e5e 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -16,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; @@ -29,7 +29,7 @@ namespace LibationWinForms { try { - if (processBookQueue1.QueueDownloadDecrypt(libraryBooks)) + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks)) SetQueueCollapseState(false); else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists()) { @@ -54,7 +54,7 @@ namespace LibationWinForms { Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); - if (processBookQueue1.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray())) + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray())) SetQueueCollapseState(false); } catch (Exception ex) @@ -67,7 +67,7 @@ namespace LibationWinForms { try { - if (processBookQueue1.QueueConvertToMp3(libraryBooks)) + if (processBookQueue1.ViewModel.QueueConvertToMp3(libraryBooks)) SetQueueCollapseState(false); } catch (Exception ex) @@ -87,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)); @@ -110,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; @@ -127,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 a72bb40b..fd248c3b 100644 --- a/Source/LibationWinForms/Form1.VisibleBooks.cs +++ b/Source/LibationWinForms/Form1.VisibleBooks.cs @@ -59,7 +59,7 @@ namespace LibationWinForms { try { - if (processBookQueue1.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray())) + if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray())) SetQueueCollapseState(false); } catch (Exception ex) diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs index 80d4f405..f302b4b8 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs @@ -11,6 +11,4 @@ public class ProcessBookViewModel : ProcessBookViewModelBase protected override object LoadImageFromBytes(byte[] bytes, PictureSize pictureSize) => WinFormsUtil.TryLoadImageOrDefault(bytes, PictureSize._80x80); - - public string BookText => $"{Title}\r\nBy {Author}\r\nNarrated by {Narrator}"; } diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs index d9a97565..477c8a39 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(); @@ -329,11 +328,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 +371,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 2251ee37..a6b3a48d 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -1,495 +1,319 @@ -using System; +using LibationFileManager; +using LibationUiBase; +using LibationUiBase.ProcessQueue; +using System; +using System.Collections; using System.Collections.Generic; 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; -using LibationUiBase.ProcessQueue; -namespace LibationWinForms.ProcessQueue +#nullable enable +namespace LibationWinForms.ProcessQueue; + +internal partial class ProcessQueueControl : UserControl { - internal partial class ProcessQueueControl : UserControl, ILogForm, IProcessQueue + 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.RequestData += VirtualFlowControl1_RequestData; + 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; + ViewModel.BookPropertyChanged += ProcessBook_PropertyChanged; + 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.VirtualControlCount = ViewModel.Queue.Count; + UpdateAllControls(); + } - 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.VirtualControlCount = ViewModel.Queue.Count; + UpdateAllControls(); + 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 ProcessBook_PropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is not ProcessBookViewModel pbvm) + return; + + int index = ViewModel.Queue.IndexOf(pbvm); + UpdateControl(index, e.PropertyName); + } + + 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.VirtualControlCount = ViewModel.Queue.Count; } - - public bool RemoveCompleted(DataLayer.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(IEnumerable entries) + if (e.PropertyName is null or nameof(ViewModel.ErrorCount)) { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - ProcessBookViewModel 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; - - ProcessBookViewModel 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; - - ProcessBookViewModel 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)) { - if (!IsHandleCreated) - CreateControl(); - - 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 as Image); - if (propertyName is null or nameof(proc.Title) or nameof(proc.Author) or nameof(proc.Narrator)) - 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 - { - ProcessBookViewModel 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((ProcessBookViewModel)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 + + /// + /// 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) { - [Description("Suffix displayed after numeric value."), Category("Data")] - [Browsable(true)] - [EditorBrowsable(EditorBrowsableState.Always)] - [DisallowNull] - public string Suffix + try { - get => _suffix; - set + int i = queueIndex - FirstVisible; + + if (Panels is null || i > NumVisible || i < 0) return; + + var proc = ViewModel.Queue[queueIndex]; + + Invoke(() => { - base.Text = string.IsNullOrEmpty(_suffix) ? base.Text : base.Text.Replace(_suffix, value); - _suffix = value; - ChangingText = true; + Panels[i].SuspendLayout(); + if (propertyName is null or nameof(proc.Cover)) + Panels[i].SetCover(proc.Cover as Image); + if (propertyName is null or nameof(proc.Title) or nameof(proc.Author) or nameof(proc.Narrator)) + Panels[i].SetBookInfo($"{proc.Title}\r\nBy {proc.Author}\r\nNarrated by {proc.Narrator}"); + + 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, ViewModel.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 + { + var item = ViewModel.Queue[queueIndex]; + if (buttonName == nameof(panelClicked.cancelBtn)) + { + if (item is not null) + { + await item.CancelAsync(); + if (ViewModel.Queue.RemoveQueued(item)) + virtualFlowControl2.VirtualControlCount = ViewModel.Queue.Count; + } + } + else if (buttonName == nameof(panelClicked.moveFirstBtn)) + { + ViewModel.Queue.MoveQueuePosition(item, QueuePosition.Fisrt); + UpdateAllControls(); + } + else if (buttonName == nameof(panelClicked.moveUpBtn)) + { + ViewModel.Queue.MoveQueuePosition(item, QueuePosition.OneUp); + UpdateControl(queueIndex); + if (queueIndex > 0) + UpdateControl(queueIndex - 1); + } + else if (buttonName == nameof(panelClicked.moveDownBtn)) + { + ViewModel.Queue.MoveQueuePosition(item, QueuePosition.OneDown); + UpdateControl(queueIndex); + if (queueIndex + 1 < ViewModel.Queue.Count) + UpdateControl(queueIndex + 1); + } + else if (buttonName == nameof(panelClicked.moveLastBtn)) + { + ViewModel.Queue.MoveQueuePosition(item, QueuePosition.Last); + UpdateAllControls(); } } - 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"); + } + } + + /// + /// View needs updating + /// + private void VirtualFlowControl1_RequestData(int firstIndex, int numVisible, IReadOnlyList panelsToFill) + { + FirstVisible = firstIndex; + NumVisible = numVisible; + Panels = panelsToFill; + UpdateAllControls(); + } + + #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..d94eb845 --- /dev/null +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs @@ -0,0 +1,74 @@ +using DataLayer; +using LibationUiBase.ProcessQueue; +using System; +using System.Collections; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +#nullable enable +namespace LibationWinForms.ProcessQueue; + +internal class ProcessQueueViewModel : ProcessQueueViewModelBase +{ + public event EventHandler? LogWritten; + /// + /// Fires when a ProcessBookViewModelBase in the queue has a property changed + /// + public event EventHandler? BookPropertyChanged; + private ObservableCollection Items { get; } + + public ProcessQueueViewModel() : base(CreateEmptyList()) + { + Items = Queue.UnderlyingList as ObservableCollection + ?? throw new ArgumentNullException(nameof(Queue.UnderlyingList)); + Items.CollectionChanged += Items_CollectionChanged; + } + + private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + subscribe(e.NewItems); + break; + case NotifyCollectionChangedAction.Remove: + unubscribe(e.OldItems); + break; + } + + void subscribe(IList? items) + { + foreach (var item in e.NewItems?.OfType() ?? []) + item.PropertyChanged += Item_PropertyChanged; + } + + void unubscribe(IList? items) + { + foreach (var item in e.NewItems?.OfType() ?? []) + item.PropertyChanged -= Item_PropertyChanged; + } + } + + private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) + => BookPropertyChanged?.Invoke(sender, e); + + public override void WriteLine(string text) => Invoke(() => LogWritten?.Invoke(this, text.Trim())); + + protected override ProcessBookViewModelBase CreateNewBook(LibraryBook libraryBook) + => new ProcessBookViewModel(libraryBook, Logger); + + private static ObservableCollection CreateEmptyList() + => new ProcessBookCollection(); + + private class ProcessBookCollection : ObservableCollection + { + protected override void ClearItems() + { + //ObservableCollection doesn't raise Remove for each item on Clear, so we need to do it ourselves. + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, this)); + base.ClearItems(); + } + } +} From 35ca6f26213fb30f8dcc5ff7286a050c5edf8d9e Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 22:50:28 -0600 Subject: [PATCH 15/21] Use built-in comparer and ReactiveObject types --- Source/LibationUiBase/GridView/EntryStatus.cs | 5 +-- .../GridView/GridEntry[TStatus].cs | 35 ++++++------------- .../GridView/ObjectComparer[T].cs | 10 ------ 3 files changed, 12 insertions(+), 38 deletions(-) delete mode 100644 Source/LibationUiBase/GridView/ObjectComparer[T].cs diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs index 1e8bfc0d..95a5dc17 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) 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); - } -} From ced4ea6c17d3a20d2136723b18e6efcc46938322 Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 22:50:53 -0600 Subject: [PATCH 16/21] Improve sorting by Liberate status by grouping books with PDFs --- Source/LibationUiBase/GridView/EntryStatus.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Source/LibationUiBase/GridView/EntryStatus.cs b/Source/LibationUiBase/GridView/EntryStatus.cs index 95a5dc17..e2354372 100644 --- a/Source/LibationUiBase/GridView/EntryStatus.cs +++ b/Source/LibationUiBase/GridView/EntryStatus.cs @@ -101,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() From a3734c76b17e0333fc263191dcc2189c3869f489 Mon Sep 17 00:00:00 2001 From: MBucari Date: Tue, 15 Jul 2025 23:02:42 -0600 Subject: [PATCH 17/21] Use SynchronizeInvoker's Invoke() method. --- Source/LibationUiBase/ReactiveObject.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Source/LibationUiBase/ReactiveObject.cs b/Source/LibationUiBase/ReactiveObject.cs index d7c8b723..e86ed991 100644 --- a/Source/LibationUiBase/ReactiveObject.cs +++ b/Source/LibationUiBase/ReactiveObject.cs @@ -12,9 +12,9 @@ public class ReactiveObject : SynchronizeInvoker, INotifyPropertyChanged, INotif public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangingEventHandler? PropertyChanging; - public void RaisePropertyChanging(PropertyChangingEventArgs args) => this.UIThreadSync(() => PropertyChanging?.Invoke(this, args)); + public void RaisePropertyChanging(PropertyChangingEventArgs args) => Invoke(() => PropertyChanging?.Invoke(this, args)); public void RaisePropertyChanging(string propertyName) => RaisePropertyChanging(new PropertyChangingEventArgs(propertyName)); - public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args)); + 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) From 4b7939541a634129ee959f92165a83d4cb3d2b62 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Wed, 16 Jul 2025 11:28:37 -0600 Subject: [PATCH 18/21] Code cleanup and refactoring for clarity --- .../QueryObjects/LibraryBookQueries.cs | 14 +- Source/LibationAvalonia/MessageBox.cs | 41 ++--- .../ViewModels/ProcessQueueViewModel.cs | 56 ++++-- .../ProcessQueue/ProcessBookViewModelBase.cs | 116 ++++++------ .../ProcessQueue/ProcessQueueViewModelBase.cs | 173 +++++------------- Source/LibationUiBase/TrackedQueue[T].cs | 10 +- Source/LibationWinForms/MessageBoxLib.cs | 3 +- .../ProcessQueue/ProcessBookControl.cs | 29 +-- .../ProcessQueue/ProcessQueueControl.cs | 13 +- .../ProcessQueue/ProcessQueueViewModel.cs | 2 +- 10 files changed, 179 insertions(+), 278 deletions(-) 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/LibationAvalonia/MessageBox.cs b/Source/LibationAvalonia/MessageBox.cs index 7519606d..ae243330 100644 --- a/Source/LibationAvalonia/MessageBox.cs +++ b/Source/LibationAvalonia/MessageBox.cs @@ -20,41 +20,39 @@ namespace LibationAvalonia 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); + => 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); - + => 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); } /// @@ -94,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 { @@ -131,7 +130,6 @@ Libation. tbx.MinWidth = vm.TextBlockMinWidth; tbx.Text = message; - var thisScreen = owner.Screens?.ScreenFromVisual(owner); var maxSize @@ -185,6 +183,5 @@ Libation. return await toDisplay.ShowDialog(owner); } } - } } diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index 498dfd47..de329357 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -5,30 +5,64 @@ using DataLayer; using LibationFileManager; using LibationUiBase.ProcessQueue; using System; +using System.Collections.ObjectModel; #nullable enable namespace LibationAvalonia.ViewModels; +public record LogEntry(DateTime LogDate, string? LogMessage) +{ + public string LogDateString => LogDate.ToShortTimeString(); +} + public class ProcessQueueViewModel : ProcessQueueViewModelBase { - public override void WriteLine(string text) - { - Dispatcher.UIThread.Invoke(() => - LogEntries.Add(new() - { - LogDate = DateTime.Now, - LogMessage = text.Trim() - })); - } - public ProcessQueueViewModel() : base(CreateEmptyList()) { Items = Queue.UnderlyingList as AvaloniaList ?? throw new ArgumentNullException(nameof(Queue.UnderlyingList)); + + SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; } + private decimal _speedLimit; + public decimal SpeedLimitIncrement { get; private set; } + public ObservableCollection LogEntries { get; } = new(); public AvaloniaList Items { get; } - protected override ProcessBookViewModelBase CreateNewBook(LibraryBook libraryBook) + + public decimal SpeedLimit + { + get + { + return _speedLimit; + } + set + { + var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024)); + var config = Configuration.Instance; + config.DownloadSpeedLimit = newValue; + + _speedLimit + = config.DownloadSpeedLimit <= newValue ? value + : value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024 + : 0; + + config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024); + + SpeedLimitIncrement = _speedLimit > 100 ? 10 + : _speedLimit > 10 ? 1 + : _speedLimit > 1 ? 0.1m + : 0.01m; + + RaisePropertyChanged(nameof(SpeedLimitIncrement)); + RaisePropertyChanged(nameof(SpeedLimit)); + } + } + + public override void WriteLine(string text) + => Dispatcher.UIThread.Invoke(() => LogEntries.Add(new(DateTime.Now, text.Trim()))); + + protected override ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook) => new ProcessBookViewModel(libraryBook, Logger); private static AvaloniaList CreateEmptyList() diff --git a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs index 1b535168..ca794be9 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs @@ -42,8 +42,6 @@ public enum ProcessBookStatus /// public abstract class ProcessBookViewModelBase : ReactiveObject { - public event EventHandler? Completed; - private readonly LogMe Logger; public LibraryBook LibraryBook { get; protected set; } @@ -86,12 +84,12 @@ public abstract class ProcessBookViewModelBase : ReactiveObject #endregion - protected Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke(); - protected Processable? NextProcessable() => _currentProcessable = null; + protected void NextProcessable() => _currentProcessable = null; private Processable? _currentProcessable; - protected readonly Queue> Processes = new(); + /// A series of Processable actions to perform on this book + protected Queue> Processes { get; } = new(); protected ProcessBookViewModelBase(LibraryBook libraryBook, LogMe logme) { @@ -120,6 +118,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject PictureStorage.PictureCached -= PictureStorage_PictureCached; } } + public async Task ProcessOneAsync() { string procName = CurrentProcessable.Name; @@ -168,7 +167,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject finally { if (result == ProcessBookResult.None) - result = await showRetry(LibraryBook); + result = await GetFailureActionAsync(LibraryBook); var status = result switch { @@ -197,13 +196,14 @@ public abstract class ProcessBookViewModelBase : ReactiveObject } } - public void AddDownloadPdf() => AddProcessable(); - public void AddDownloadDecryptBook() => AddProcessable(); - public void AddConvertToMp3() => AddProcessable(); + public ProcessBookViewModelBase AddDownloadPdf() => AddProcessable(); + public ProcessBookViewModelBase AddDownloadDecryptBook() => AddProcessable(); + public ProcessBookViewModelBase AddConvertToMp3() => AddProcessable(); - private void AddProcessable() where T : Processable, new() + private ProcessBookViewModelBase AddProcessable() where T : Processable, new() { Processes.Enqueue(() => new T()); + return this; } public override string ToString() => LibraryBook.ToString(); @@ -246,16 +246,13 @@ public abstract class ProcessBookViewModelBase : ReactiveObject #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) { @@ -270,17 +267,11 @@ public abstract class ProcessBookViewModelBase : ReactiveObject return coverData; } - private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt) - { - Cover = LoadImageFromBytes(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) @@ -317,10 +308,7 @@ public abstract class ProcessBookViewModelBase : ReactiveObject } if (Processes.Count == 0) - { - Completed?.Invoke(this, EventArgs.Empty); return; - } NextProcessable(); LinkProcessable(CurrentProcessable); @@ -342,28 +330,39 @@ public abstract class ProcessBookViewModelBase : ReactiveObject { foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed")) Logger.Error(errorMessage); - - Completed?.Invoke(this, EventArgs.Empty); } } #endregion - #region Failure Handler + #region Failure Handler - protected async Task showRetry(LibraryBook libraryBook) + protected async Task GetFailureActionAsync(LibraryBook libraryBook) { - Logger.Error("ERROR. All books have not been processed. Most recent book: processing failed"); + 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 => null, - _ => null + 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 { @@ -372,49 +371,36 @@ public abstract class ProcessBookViewModelBase : ReactiveObject : (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())}"; + 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 MessageBoxBase.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); + 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.) - if (dialogResult == DialogResult.Abort) - return ProcessBookResult.FailedAbort; + See Settings in the Download/Decrypt tab to avoid this box in the future. + """; - if (dialogResult == SkipResult) - { - libraryBook.UpdateBookStatus(LiberatedStatus.Error); + const MessageBoxButtons SkipDialogButtons = MessageBoxButtons.AbortRetryIgnore; + const MessageBoxDefaultButton SkipDialogDefaultButton = MessageBoxDefaultButton.Button1; - Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}"); - - return ProcessBookResult.FailedSkip; - } - - return ProcessBookResult.FailedRetry; + return await MessageBoxBase.Show(skipDialogText, "Skip this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton); } - 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 - } diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs index 40ff8da8..a6452ceb 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs @@ -1,9 +1,7 @@ using DataLayer; -using LibationFileManager; using LibationUiBase.Forms; using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using ApplicationServices; using System.Threading.Tasks; @@ -14,24 +12,19 @@ namespace LibationUiBase.ProcessQueue; public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm { public abstract void WriteLine(string text); + protected abstract ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook); - protected abstract ProcessBookViewModelBase CreateNewBook(LibraryBook libraryBook); - - public ObservableCollection LogEntries { get; } = new(); public TrackedQueue Queue { get; } - public ProcessBookViewModelBase? SelectedItem { get; set; } public Task? QueueRunner { get; private set; } public bool Running => !QueueRunner?.IsCompleted ?? false; - - protected readonly LogMe Logger; + protected LogMe Logger { get; } public ProcessQueueViewModelBase(ICollection? underlyingList) { Logger = LogMe.RegisterForm(this); Queue = new(underlyingList); - Queue.QueuededCountChanged += Queue_QueuededCountChanged; + Queue.QueuedCountChanged += Queue_QueuedCountChanged; Queue.CompletedCountChanged += Queue_CompletedCountChanged; - SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; } private int _completedCount; @@ -39,7 +32,6 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm private int _queuedCount; private string? _runningTime; private bool _progressBarVisible; - private decimal _speedLimit; public int CompletedCount { get => _completedCount; private set { RaiseAndSetIfChanged(ref _completedCount, value); RaisePropertyChanged(nameof(AnyCompleted)); } } public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); RaisePropertyChanged(nameof(AnyQueued)); } } @@ -51,37 +43,6 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm public bool AnyErrors => ErrorCount > 0; public double Progress => 100d * Queue.Completed.Count / Queue.Count; - public decimal SpeedLimit - { - get - { - return _speedLimit; - } - set - { - var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024)); - var config = Configuration.Instance; - config.DownloadSpeedLimit = newValue; - - _speedLimit - = config.DownloadSpeedLimit <= newValue ? value - : value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024 - : 0; - - config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024); - - SpeedLimitIncrement = _speedLimit > 100 ? 10 - : _speedLimit > 10 ? 1 - : _speedLimit > 1 ? 0.1m - : 0.01m; - - RaisePropertyChanged(nameof(SpeedLimitIncrement)); - RaisePropertyChanged(nameof(SpeedLimit)); - } - } - - public 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); @@ -91,7 +52,8 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm CompletedCount = completeCount; RaisePropertyChanged(nameof(Progress)); } - private void Queue_QueuededCountChanged(object? sender, int cueCount) + + private void Queue_QueuedCountChanged(object? sender, int cueCount) { QueuedCount = cueCount; RaisePropertyChanged(nameof(Progress)); @@ -101,7 +63,7 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm public bool QueueDownloadPdf(IList libraryBooks) { - var needsPdf = libraryBooks.Where(lb => !lb.AbsentFromLastScan && lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated).ToArray(); + var needsPdf = libraryBooks.Where(lb => lb.NeedsPdfDownload()).ToArray(); if (needsPdf.Length > 0) { Serilog.Log.Logger.Information("Begin download {count} pdfs", needsPdf.Length); @@ -132,14 +94,14 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm if (item.AbsentFromLastScan) return false; - else if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) + 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.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) + else if (item.NeedsPdfDownload()) { RemoveCompleted(item); Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item); @@ -149,10 +111,7 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm } else { - var toLiberate - = libraryBooks - .Where(x => !x.AbsentFromLastScan && x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated) - .ToArray(); + var toLiberate = libraryBooks.UnLiberated().ToArray(); if (toLiberate.Length > 0) { @@ -164,16 +123,10 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm return false; } - 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; - } + 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 @@ -182,69 +135,43 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm private void AddDownloadPdf(IEnumerable entries) { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - var pbook = CreateNewBook(entry); - pbook.AddDownloadPdf(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + 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) { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - var pbook = CreateNewBook(entry); - pbook.AddDownloadDecryptBook(); - pbook.AddDownloadPdf(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + 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) { - List procs = new(); - foreach (var entry in entries) - { - if (isBookInQueue(entry)) - continue; - - var pbook = CreateNewBook(entry); - pbook.AddConvertToMp3(); - procs.Add(pbook); - } - - Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + 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) { - Invoke(() => - { - Queue.Enqueue(pbook); - if (!Running) - QueueRunner = QueueLoop(); - }); + Queue.Enqueue(pbook); + if (!Running) + QueueRunner = Task.Run(QueueLoop); } #endregion - private DateTime StartingTime; private async Task QueueLoop() { try @@ -253,12 +180,11 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm RunningTime = string.Empty; ProgressBarVisible = true; - StartingTime = DateTime.Now; - - using var counterTimer = new System.Threading.Timer(CounterTimer_Tick, null, 0, 500); - + 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) @@ -267,11 +193,11 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm continue; } - Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook.LibraryBook); + 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); + Serilog.Log.Logger.Information("Completed processing queued item: '{item_LibraryBook}' with result: {result}", nextBook.LibraryBook, result); if (result == ProcessBookResult.ValidationFail) Queue.ClearCurrent(); @@ -281,11 +207,11 @@ public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm 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} + 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. -", + 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); @@ -301,24 +227,9 @@ This error appears to be caused by a temporary interruption of service that some { 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); + => time.TotalHours < 1 ? $"{time:mm\\:ss}" + : $"{time.TotalHours:F0}:{time:mm\\:ss}"; } } - -public class LogEntry -{ - public DateTime LogDate { get; init; } - public string LogDateString => LogDate.ToShortTimeString(); - public string? LogMessage { get; init; } -} diff --git a/Source/LibationUiBase/TrackedQueue[T].cs b/Source/LibationUiBase/TrackedQueue[T].cs index e36dac4b..746f8c6b 100644 --- a/Source/LibationUiBase/TrackedQueue[T].cs +++ b/Source/LibationUiBase/TrackedQueue[T].cs @@ -36,7 +36,7 @@ namespace LibationUiBase public class TrackedQueue where T : class { public event EventHandler? CompletedCountChanged; - public event EventHandler? QueuededCountChanged; + public event EventHandler? QueuedCountChanged; public T? Current { get; private set; } @@ -115,7 +115,7 @@ namespace LibationUiBase if (itemsRemoved) { - QueuededCountChanged?.Invoke(this, queuedCount); + QueuedCountChanged?.Invoke(this, queuedCount); RebuildSecondary(); } return itemsRemoved; @@ -151,7 +151,7 @@ namespace LibationUiBase { lock (lockObject) _queued.Clear(); - QueuededCountChanged?.Invoke(this, 0); + QueuedCountChanged?.Invoke(this, 0); RebuildSecondary(); } @@ -248,7 +248,7 @@ namespace LibationUiBase { if (completedChanged) CompletedCountChanged?.Invoke(this, completedCount); - QueuededCountChanged?.Invoke(this, queuedCount); + QueuedCountChanged?.Invoke(this, queuedCount); RebuildSecondary(); } } @@ -263,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/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/ProcessBookControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs index 07d24012..89bcf31c 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs @@ -10,7 +10,7 @@ namespace LibationWinForms.ProcessQueue private static int ControlNumberCounter = 0; /// - /// The contol's position within + /// The control's position within /// public int ControlNumber { get; } private ProcessBookStatus Status { get; set; } = ProcessBookStatus.Queued; @@ -25,7 +25,6 @@ namespace LibationWinForms.ProcessQueue public ProcessBookControl() { InitializeComponent(); - statusLbl.Text = "Queued"; remainingTimeLbl.Visible = false; progressBar1.Visible = false; etaLbl.Visible = false; @@ -45,7 +44,7 @@ namespace LibationWinForms.ProcessQueue bookInfoLbl.Text = title; } - public void SetProgrss(int progress) + public void SetProgress(int progress) { //Disable slow fill //https://stackoverflow.com/a/5332770/3335599 @@ -59,25 +58,7 @@ namespace LibationWinForms.ProcessQueue 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) + public void SetStatus(ProcessBookStatus status, string statusText) { Status = status; @@ -101,7 +82,7 @@ namespace LibationWinForms.ProcessQueue progressBar1.Visible = Status == ProcessBookStatus.Working; etaLbl.Visible = Status == ProcessBookStatus.Working; statusLbl.Visible = Status != ProcessBookStatus.Working; - statusLbl.Text = statusText ?? Status.ToString(); + statusLbl.Text = statusText; BackColor = backColor; int deltaX = Width - cancelBtn.Location.X - CancelBtnDistanceFromEdge; @@ -110,7 +91,7 @@ namespace LibationWinForms.ProcessQueue { //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); diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index a6b3a48d..50bc57c9 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -169,17 +169,10 @@ internal partial class ProcessQueueControl : UserControl Panels[i].SetCover(proc.Cover as Image); if (propertyName is null or nameof(proc.Title) or nameof(proc.Author) or nameof(proc.Narrator)) Panels[i].SetBookInfo($"{proc.Title}\r\nBy {proc.Author}\r\nNarrated by {proc.Narrator}"); - - 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.Status) or nameof(proc.StatusText)) + Panels[i].SetStatus(proc.Status, proc.StatusText); if (propertyName is null or nameof(proc.Progress)) - Panels[i].SetProgrss(proc.Progress); + Panels[i].SetProgress(proc.Progress); if (propertyName is null or nameof(proc.TimeRemaining)) Panels[i].SetRemainingTime(proc.TimeRemaining); Panels[i].ResumeLayout(); diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs index d94eb845..6c4afd63 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs @@ -56,7 +56,7 @@ internal class ProcessQueueViewModel : ProcessQueueViewModelBase public override void WriteLine(string text) => Invoke(() => LogWritten?.Invoke(this, text.Trim())); - protected override ProcessBookViewModelBase CreateNewBook(LibraryBook libraryBook) + protected override ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook) => new ProcessBookViewModel(libraryBook, Logger); private static ObservableCollection CreateEmptyList() From 7e79e98771a4f21314dbb0b1e536699261ae7dac Mon Sep 17 00:00:00 2001 From: MBucari Date: Wed, 16 Jul 2025 22:57:25 -0600 Subject: [PATCH 19/21] Fix possible cross-threading errors with MessageBoxBase --- Source/LibationAvalonia/MessageBox.cs | 6 ++--- .../ProcessQueue/ProcessBookViewModelBase.cs | 10 ++++++- Source/LibationWinForms/Program.cs | 27 ++++++++++++++++--- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Source/LibationAvalonia/MessageBox.cs b/Source/LibationAvalonia/MessageBox.cs index ae243330..290b254f 100644 --- a/Source/LibationAvalonia/MessageBox.cs +++ b/Source/LibationAvalonia/MessageBox.cs @@ -107,12 +107,12 @@ namespace LibationAvalonia } 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) { diff --git a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs index ca794be9..62b00e7d 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs @@ -399,7 +399,15 @@ public abstract class ProcessBookViewModelBase : ReactiveObject const MessageBoxButtons SkipDialogButtons = MessageBoxButtons.AbortRetryIgnore; const MessageBoxDefaultButton SkipDialogDefaultButton = MessageBoxDefaultButton.Button1; - return await MessageBoxBase.Show(skipDialogText, "Skip this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton); + 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/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 01af7fa9..ba1416f0 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -23,9 +23,6 @@ namespace LibationWinForms { Task> libraryLoadTask; - LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) => - Task.FromResult((LibationUiBase.Forms.DialogResult)MessageBox.Show(owner as IWin32Window, message, caption, (MessageBoxButtons)buttons, (MessageBoxIcon)icon, (MessageBoxDefaultButton)defaultButton)); - try { //// Uncomment to see Console. Must be called before anything writes to Console. @@ -90,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) From 747451d24385b03a7c3c8188941e08c0392fcf4a Mon Sep 17 00:00:00 2001 From: MBucari Date: Wed, 16 Jul 2025 22:58:03 -0600 Subject: [PATCH 20/21] Refactor Classic process queue The queue is now more MVVM-like. --- .../ProcessQueue/ProcessBookControl.cs | 43 +++++ .../ProcessQueueControl.Designer.cs | 1 - .../ProcessQueue/ProcessQueueControl.cs | 150 ++++-------------- .../ProcessQueue/ProcessQueueViewModel.cs | 58 +------ .../ProcessQueue/VirtualFlowControl.cs | 67 ++++---- 5 files changed, 108 insertions(+), 211 deletions(-) diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs index 89bcf31c..3045d2e2 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs @@ -22,6 +22,49 @@ namespace LibationWinForms.ProcessQueue public static Color QueuedColor = SystemColors.Control; public static Color SuccessColor = Color.PaleGreen; + private ProcessBookViewModelBase m_Context; + public ProcessBookViewModelBase Context + { + get => m_Context; + set + { + if (m_Context != value) + { + OnContextChanging(); + m_Context = value; + OnContextChanged(); + } + } + } + + private void OnContextChanging() + { + if (Context is not null) + Context.PropertyChanged -= Context_PropertyChanged; + } + + private void OnContextChanged() + { + Context.PropertyChanged += Context_PropertyChanged; + Context_PropertyChanged(this, new System.ComponentModel.PropertyChangedEventArgs(null)); + } + + 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(); + } + public ProcessBookControl() { InitializeComponent(); diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs index 477c8a39..85fdb856 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.Designer.cs @@ -162,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 // diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index 50bc57c9..7a67d31f 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -1,9 +1,6 @@ using LibationFileManager; using LibationUiBase; -using LibationUiBase.ProcessQueue; using System; -using System.Collections; -using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Drawing; @@ -34,12 +31,11 @@ internal partial class ProcessQueueControl : UserControl numericUpDown1.Value = speedLimitMBps > numericUpDown1.Maximum || speedLimitMBps < numericUpDown1.Minimum ? 0 : speedLimitMBps; statusStrip1.Items.Add(PopoutButton); - virtualFlowControl2.RequestData += VirtualFlowControl1_RequestData; virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked; ViewModel.LogWritten += (_, text) => WriteLine(text); ViewModel.PropertyChanged += ProcessQueue_PropertyChanged; - ViewModel.BookPropertyChanged += ProcessBook_PropertyChanged; + virtualFlowControl2.Items = ViewModel.Items; Load += ProcessQueueControl_Load; } @@ -60,15 +56,13 @@ internal partial class ProcessQueueControl : UserControl ViewModel.Queue.ClearQueue(); if (ViewModel.Queue.Current is not null) await ViewModel.Queue.Current.CancelAsync(); - virtualFlowControl2.VirtualControlCount = ViewModel.Queue.Count; - UpdateAllControls(); + virtualFlowControl2.RefreshDisplay(); } private void btnClearFinished_Click(object? sender, EventArgs e) { ViewModel.Queue.ClearCompleted(); - virtualFlowControl2.VirtualControlCount = ViewModel.Queue.Count; - UpdateAllControls(); + virtualFlowControl2.RefreshDisplay(); if (!ViewModel.Running) runningTimeLbl.Text = string.Empty; @@ -92,22 +86,13 @@ internal partial class ProcessQueueControl : UserControl #region View-Model update event handling - private void ProcessBook_PropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (sender is not ProcessBookViewModel pbvm) - return; - - int index = ViewModel.Queue.IndexOf(pbvm); - UpdateControl(index, e.PropertyName); - } - private void ProcessQueue_PropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is null or nameof(ViewModel.QueuedCount)) { queueNumberLbl.Text = ViewModel.QueuedCount.ToString(); queueNumberLbl.Visible = ViewModel.QueuedCount > 0; - virtualFlowControl2.VirtualControlCount = ViewModel.Queue.Count; + virtualFlowControl2.RefreshDisplay(); } if (e.PropertyName is null or nameof(ViewModel.ErrorCount)) { @@ -134,107 +119,41 @@ internal partial class ProcessQueueControl : UserControl } } - /// - /// 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 (Panels is null || i > NumVisible || i < 0) return; - - var proc = ViewModel.Queue[queueIndex]; - - Invoke(() => - { - Panels[i].SuspendLayout(); - if (propertyName is null or nameof(proc.Cover)) - Panels[i].SetCover(proc.Cover as Image); - if (propertyName is null or nameof(proc.Title) or nameof(proc.Author) or nameof(proc.Narrator)) - Panels[i].SetBookInfo($"{proc.Title}\r\nBy {proc.Author}\r\nNarrated by {proc.Narrator}"); - if (propertyName is null or nameof(proc.Status) or nameof(proc.StatusText)) - Panels[i].SetStatus(proc.Status, proc.StatusText); - if (propertyName is null or nameof(proc.Progress)) - Panels[i].SetProgress(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, ViewModel.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) + /// the whose button was clicked + /// The name of the button clicked + private async void VirtualFlowControl2_ButtonClicked(object? sender, string buttonName) { + if (sender is not ProcessBookControl control || control.Context is not ProcessBookViewModel item) + return; + try { - var item = ViewModel.Queue[queueIndex]; - if (buttonName == nameof(panelClicked.cancelBtn)) + if (buttonName is nameof(ProcessBookControl.cancelBtn)) { - if (item is not null) + await item.CancelAsync(); + ViewModel.Queue.RemoveQueued(item); + virtualFlowControl2.RefreshDisplay(); + } + else + { + QueuePosition? position = buttonName switch { - await item.CancelAsync(); - if (ViewModel.Queue.RemoveQueued(item)) - virtualFlowControl2.VirtualControlCount = ViewModel.Queue.Count; + 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(); } } - else if (buttonName == nameof(panelClicked.moveFirstBtn)) - { - ViewModel.Queue.MoveQueuePosition(item, QueuePosition.Fisrt); - UpdateAllControls(); - } - else if (buttonName == nameof(panelClicked.moveUpBtn)) - { - ViewModel.Queue.MoveQueuePosition(item, QueuePosition.OneUp); - UpdateControl(queueIndex); - if (queueIndex > 0) - UpdateControl(queueIndex - 1); - } - else if (buttonName == nameof(panelClicked.moveDownBtn)) - { - ViewModel.Queue.MoveQueuePosition(item, QueuePosition.OneDown); - UpdateControl(queueIndex); - if (queueIndex + 1 < ViewModel.Queue.Count) - UpdateControl(queueIndex + 1); - } - else if (buttonName == nameof(panelClicked.moveLastBtn)) - { - ViewModel.Queue.MoveQueuePosition(item, QueuePosition.Last); - UpdateAllControls(); - } } catch(Exception ex) { @@ -242,17 +161,6 @@ internal partial class ProcessQueueControl : UserControl } } - /// - /// View needs updating - /// - private void VirtualFlowControl1_RequestData(int firstIndex, int numVisible, IReadOnlyList panelsToFill) - { - FirstVisible = firstIndex; - NumVisible = numVisible; - Panels = panelsToFill; - UpdateAllControls(); - } - #endregion private void numericUpDown1_ValueChanged(object? sender, EventArgs e) diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs index 6c4afd63..e8fb4d1e 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueViewModel.cs @@ -1,11 +1,7 @@ using DataLayer; using LibationUiBase.ProcessQueue; using System; -using System.Collections; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Linq; +using System.Collections.Generic; #nullable enable namespace LibationWinForms.ProcessQueue; @@ -13,62 +9,16 @@ namespace LibationWinForms.ProcessQueue; internal class ProcessQueueViewModel : ProcessQueueViewModelBase { public event EventHandler? LogWritten; - /// - /// Fires when a ProcessBookViewModelBase in the queue has a property changed - /// - public event EventHandler? BookPropertyChanged; - private ObservableCollection Items { get; } + public List Items { get; } - public ProcessQueueViewModel() : base(CreateEmptyList()) + public ProcessQueueViewModel() : base(new List()) { - Items = Queue.UnderlyingList as ObservableCollection + Items = Queue.UnderlyingList as List ?? throw new ArgumentNullException(nameof(Queue.UnderlyingList)); - Items.CollectionChanged += Items_CollectionChanged; } - private void Items_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - subscribe(e.NewItems); - break; - case NotifyCollectionChangedAction.Remove: - unubscribe(e.OldItems); - break; - } - - void subscribe(IList? items) - { - foreach (var item in e.NewItems?.OfType() ?? []) - item.PropertyChanged += Item_PropertyChanged; - } - - void unubscribe(IList? items) - { - foreach (var item in e.NewItems?.OfType() ?? []) - item.PropertyChanged -= Item_PropertyChanged; - } - } - - private void Item_PropertyChanged(object? sender, PropertyChangedEventArgs e) - => BookPropertyChanged?.Invoke(sender, e); - public override void WriteLine(string text) => Invoke(() => LogWritten?.Invoke(this, text.Trim())); protected override ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook) => new ProcessBookViewModel(libraryBook, Logger); - - private static ObservableCollection CreateEmptyList() - => new ProcessBookCollection(); - - private class ProcessBookCollection : ObservableCollection - { - protected override void ClearItems() - { - //ObservableCollection doesn't raise Remove for each item on Clear, so we need to do it ourselves. - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, this)); - base.ClearItems(); - } - } } diff --git a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs index e0cadbc7..4149476b 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); } /// @@ -186,7 +177,8 @@ namespace LibationWinForms.ProcessQueue /// /// 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; + } } /// From 8d7872a376e557f8ab0a795258017ffb44e6234e Mon Sep 17 00:00:00 2001 From: MBucari Date: Wed, 16 Jul 2025 23:31:34 -0600 Subject: [PATCH 21/21] UI tweak and optimization --- .../ProcessQueue/ProcessBookControl.cs | 93 +++++++------------ .../ProcessQueue/VirtualFlowControl.cs | 4 +- 2 files changed, 35 insertions(+), 62 deletions(-) diff --git a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs index 3045d2e2..8bcfd6a2 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessBookControl.cs @@ -7,20 +7,13 @@ namespace LibationWinForms.ProcessQueue { internal partial class ProcessBookControl : UserControl { - private static int ControlNumberCounter = 0; - - /// - /// The control'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 @@ -37,6 +30,17 @@ namespace LibationWinForms.ProcessQueue } } + public ProcessBookControl() + { + InitializeComponent(); + remainingTimeLbl.Visible = false; + progressBar1.Visible = false; + etaLbl.Visible = false; + + CancelBtnDistanceFromEdge = Width - cancelBtn.Location.X; + ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width; + } + private void OnContextChanging() { if (Context is not null) @@ -65,29 +69,12 @@ namespace LibationWinForms.ProcessQueue ResumeLayout(); } - public ProcessBookControl() - { - InitializeComponent(); - remainingTimeLbl.Visible = false; - progressBar1.Visible = false; - etaLbl.Visible = false; + 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}"; - CancelBtnDistanceFromEdge = Width - cancelBtn.Location.X; - ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width; - ControlNumber = ControlNumberCounter++; - } - - public void SetCover(Image cover) - { - pictureBox1.Image = cover; - } - - public void SetBookInfo(string title) - { - bookInfoLbl.Text = title; - } - - public void SetProgress(int progress) + private void SetProgress(int progress) { //Disable slow fill //https://stackoverflow.com/a/5332770/3335599 @@ -96,16 +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 SetStatus(ProcessBookStatus status, string statusText) - { - Status = status; - - Color backColor = Status switch + Color backColor = status switch { ProcessBookStatus.Completed => SuccessColor, ProcessBookStatus.Cancelled => CancelledColor, @@ -114,23 +94,21 @@ 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; + 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 @@ -154,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/VirtualFlowControl.cs b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs index 4149476b..5733ba48 100644 --- a/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs +++ b/Source/LibationWinForms/ProcessQueue/VirtualFlowControl.cs @@ -165,13 +165,13 @@ 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; } }