Merge pull request #1299 from Mbucari/master

Bugfixes and minor improvements
This commit is contained in:
rmcrackan 2025-07-17 08:04:43 -04:00 committed by GitHub
commit 554b308364
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 1504 additions and 2051 deletions

View File

@ -15,6 +15,10 @@ on:
description: "Skip running unit tests" description: "Skip running unit tests"
required: false required: false
default: true default: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env: env:
DOTNET_CONFIGURATION: "Release" DOTNET_CONFIGURATION: "Release"
@ -22,8 +26,11 @@ env:
jobs: jobs:
build: build:
name: "${{ matrix.os }}-${{ matrix.release_name }}" name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
runs-on: windows-latest runs-on: windows-latest
env:
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
RUNTIME_ID: "win-${{ inputs.architecture }}"
strategy: strategy:
matrix: matrix:
os: [Windows] os: [Windows]
@ -63,38 +70,42 @@ jobs:
run: | run: |
dotnet publish ` dotnet publish `
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj ` Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --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 -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish ` dotnet publish `
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj ` LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --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 -p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish ` dotnet publish `
LibationCli/LibationCli.csproj ` LibationCli/LibationCli.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` --output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:DefineConstants="${{ matrix.release_name }}" ` -p:DefineConstants="${{ matrix.release_name }}" `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish ` dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj ` Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --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 -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact - name: Zip artifact
id: zip id: zip
working-directory: ./Source/bin/Publish working-directory: ./Source/bin/Publish
run: | run: |
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\" $bin_dir = "${{ env.OUTPUT_NAME }}\"
$delfiles = @( $delfiles = @(
"WindowsConfigApp.exe", "WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json", "WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json" "WindowsConfigApp.deps.json"
) )
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } } 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 "artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip" Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"

View File

@ -18,10 +18,14 @@ on:
jobs: jobs:
windows: windows:
strategy:
matrix:
architecture: [x64]
uses: ./.github/workflows/build-windows.yml uses: ./.github/workflows/build-windows.yml
with: with:
version_override: ${{ inputs.version_override }} version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }} run_unit_tests: ${{ inputs.run_unit_tests }}
architecture: ${{ matrix.architecture }}
linux: linux:
strategy: strategy:

View File

@ -1,10 +1,10 @@
{ {
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip", "WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip", "WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb", "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm", "LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz", "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb", "LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm", "LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz" "MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
} }

View File

@ -13,7 +13,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.2" /> <PackageReference Include="AAXClean.Codecs" Version="2.0.1.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -290,33 +290,24 @@ namespace AppScaffolding
public static UpgradeProperties GetLatestRelease() public static UpgradeProperties GetLatestRelease()
{ {
// timed out // 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) if (version is null || 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)
return null; return null;
// we have an update // we have an update
var zipUrl = zip?.BrowserDownloadUrl; var zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new Log.Logger.Information("Update available: {@DebugInfo}", new
{ {
latestRelease = latestRelease.ToString(), latestRelease = version.ToString(),
latest.HtmlUrl, latest.HtmlUrl,
zipUrl 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 try
{ {
@ -330,15 +321,23 @@ namespace AppScaffolding
{ {
Log.Logger.Error(aggEx, "Checking for new version too often"); 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 ownerAccount = "rmcrackan";
const string repoName = "Libation"; const string repoName = "Libation";
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName)); 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 //Download the release index
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json"); var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts)); 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); 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 return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
} }
} }

View File

@ -5,8 +5,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="NPOI" Version="2.7.3" /> <PackageReference Include="NPOI" Version="2.7.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -6,7 +6,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.1.1" /> <PackageReference Include="AudibleApi" Version="9.4.1.1" />
<PackageReference Include="Google.Protobuf" Version="3.30.2" /> <PackageReference Include="Google.Protobuf" Version="3.31.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -12,12 +12,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.1.1" /> <PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" /> <PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.4"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@ -103,13 +103,11 @@ namespace DataLayer
) == true ) == true
).ToList(); ).ToList();
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<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList) public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
=> bookList => bookList.Where(lb => lb.NeedsPdfDownload() || lb.NeedsBookDownload());
.Where(
lb =>
!lb.AbsentFromLastScan &&
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
);
} }
} }

View File

@ -6,7 +6,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.1.1" /> <PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Polly" Version="8.5.2" /> <PackageReference Include="Polly" Version="8.6.2" />
</ItemGroup> </ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@ -71,12 +71,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.0" /> <PackageReference Include="Avalonia" Version="11.3.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.0" /> <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" /> <ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@ -16,6 +16,8 @@ using Dinah.Core;
using LibationAvalonia.Themes; using LibationAvalonia.Themes;
using Avalonia.Data.Core.Plugins; using Avalonia.Data.Core.Plugins;
using System.Linq; using System.Linq;
using LibationUiBase.Forms;
using Avalonia.Controls;
#nullable enable #nullable enable
namespace LibationAvalonia namespace LibationAvalonia
@ -42,6 +44,9 @@ namespace LibationAvalonia
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) 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. // Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation(); DisableAvaloniaDataAnnotationValidation();

View File

@ -4,6 +4,7 @@ using Avalonia.Markup.Xaml.MarkupExtensions;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable #nullable enable

View File

@ -1,5 +1,6 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Dinah.Core; using Dinah.Core;
using LibationFileManager; using LibationFileManager;
using ReactiveUI; using ReactiveUI;
@ -90,7 +91,7 @@ namespace LibationAvalonia.Controls
var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options); 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) private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)

View File

@ -4,6 +4,7 @@ using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings; using LibationAvalonia.ViewModels.Settings;
using LibationFileManager; using LibationFileManager;
using LibationFileManager.Templates; using LibationFileManager.Templates;
using LibationUiBase.Forms;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@ -3,6 +3,7 @@ using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings; using LibationAvalonia.ViewModels.Settings;
using LibationFileManager; using LibationFileManager;
using LibationFileManager.Templates; using LibationFileManager.Templates;
using LibationUiBase.Forms;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace LibationAvalonia.Controls.Settings namespace LibationAvalonia.Controls.Settings

View File

@ -1,12 +1,10 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media.Imaging;
using DataLayer; using DataLayer;
using Dinah.Core.ErrorHandling; using Dinah.Core.ErrorHandling;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationFileManager; using LibationFileManager;
using NPOI.Util.Collections; using LibationUiBase.ProcessQueue;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;

View File

@ -3,6 +3,7 @@ using LibationAvalonia.Controls;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationFileManager; using LibationFileManager;
using LibationUiBase; using LibationUiBase;
using LibationUiBase.Forms;
using ReactiveUI; using ReactiveUI;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;

View File

@ -3,6 +3,7 @@ using AudibleUtilities;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LibationUiBase.Forms;
using ReactiveUI; using ReactiveUI;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;

View File

@ -7,6 +7,7 @@ using Avalonia.Platform.Storage;
using Avalonia.Threading; using Avalonia.Threading;
using DataLayer; using DataLayer;
using FileLiberator; using FileLiberator;
using LibationUiBase.Forms;
using ReactiveUI; using ReactiveUI;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;

View File

@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Styling; using Avalonia.Styling;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@ -6,6 +6,7 @@ using Avalonia.Styling;
using Dinah.Core; using Dinah.Core;
using LibationFileManager; using LibationFileManager;
using LibationFileManager.Templates; using LibationFileManager.Templates;
using LibationUiBase.Forms;
using ReactiveUI; using ReactiveUI;
using System; using System;
using System.IO; using System.IO;

View File

@ -1,5 +1,6 @@
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LibationUiBase.Forms;
using ReactiveUI; using ReactiveUI;
using System; using System;
using System.ComponentModel; using System.ComponentModel;

View File

@ -1,4 +1,5 @@
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using System.Collections.Generic; using System.Collections.Generic;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs

View File

@ -1,5 +1,6 @@
using AudibleApi; using AudibleApi;
using AudibleUtilities; using AudibleUtilities;
using LibationUiBase.Forms;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs.Login namespace LibationAvalonia.Dialogs.Login

View File

@ -1,6 +1,7 @@
using AudibleApi; using AudibleApi;
using AudibleUtilities; using AudibleUtilities;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@ -1,6 +1,7 @@
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Data; using Avalonia.Data;
using LibationUiBase.Forms;
using ReactiveUI; using ReactiveUI;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;

View File

@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Dinah.Core; using Dinah.Core;
using LibationUiBase.Forms;
using System; using System;
namespace LibationAvalonia.Dialogs.Login namespace LibationAvalonia.Dialogs.Login

View File

@ -1,6 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using Dinah.Core; using Dinah.Core;
using FileManager; using FileManager;
using LibationUiBase.Forms;
using System; using System;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs

View File

@ -1,12 +1,13 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels" xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Dialogs"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110" mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110"
MinWidth="265" MinHeight="110" MinWidth="265" MinHeight="110"
x:DataType="vm:MessageBoxViewModel"
x:Class="LibationAvalonia.Dialogs.MessageBoxWindow" x:Class="LibationAvalonia.Dialogs.MessageBoxWindow"
Title="{Binding Caption}" ShowInTaskbar="True"> Title="{CompiledBinding Caption}" ShowInTaskbar="True">
<Grid ColumnDefinitions="*" RowDefinitions="*,Auto"> <Grid ColumnDefinitions="*" RowDefinitions="*,Auto">
@ -15,13 +16,21 @@
VerticalAlignment="Top"> VerticalAlignment="Top">
<Panel Height="32" Width="32" Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top"> <Panel Height="32" Width="32" Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
<Image IsVisible="{Binding IsAsterisk}" Stretch="Uniform" Source="/Assets/MBIcons/Asterisk_64.png"/> <Panel.IsVisible>
<Image IsVisible="{Binding IsError}" Stretch="Uniform" Source="/Assets/MBIcons/Error_64.png"/> <MultiBinding Converter="{x:Static BoolConverters.Or}">
<Image IsVisible="{Binding IsQuestion}" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/> <CompiledBinding Path="IsAsterisk" />
<Image IsVisible="{Binding IsExclamation}" Stretch="Uniform" Source="/Assets/MBIcons/Exclamation_64.png"/> <CompiledBinding Path="IsError" />
<CompiledBinding Path="IsQuestion" />
<CompiledBinding Path="IsExclamation" />
</MultiBinding>
</Panel.IsVisible>
<Image IsVisible="{CompiledBinding IsAsterisk}" Stretch="Uniform" Source="/Assets/MBIcons/Asterisk_64.png"/>
<Image IsVisible="{CompiledBinding IsError}" Stretch="Uniform" Source="/Assets/MBIcons/Error_64.png"/>
<Image IsVisible="{CompiledBinding IsQuestion}" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
<Image IsVisible="{CompiledBinding IsExclamation}" Stretch="Uniform" Source="/Assets/MBIcons/Exclamation_64.png"/>
</Panel> </Panel>
<TextBlock Margin="5,0,0,0" Name="messageTextBlock" MinHeight="45" MinWidth="193" TextWrapping="WrapWithOverflow" HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" Text="{Binding Message}" /> <TextBlock Margin="5,0,0,0" Name="messageTextBlock" MinHeight="45" MinWidth="193" TextWrapping="WrapWithOverflow" HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" Text="{CompiledBinding Message}" />
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>
@ -35,13 +44,13 @@
</DockPanel.Styles> </DockPanel.Styles>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5"> <Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button1Text}"/> <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button1Text}"/>
</Button> </Button>
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5"> <Button Grid.Column="1" IsVisible="{CompiledBinding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button2Text}"/> <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button2Text}"/>
</Button> </Button>
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Margin="5"> <Button Grid.Column="2" IsVisible="{CompiledBinding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Margin="5">
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button3Text}"/> <TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button3Text}"/>
</Button> </Button>
</StackPanel> </StackPanel>
</DockPanel> </DockPanel>

View File

@ -1,4 +1,5 @@
using LibationAvalonia.ViewModels.Dialogs; using LibationAvalonia.ViewModels.Dialogs;
using LibationUiBase.Forms;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs
{ {

View File

@ -1,6 +1,6 @@
using AudibleUtilities; using AudibleUtilities;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using LibationUiBase.Forms;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;

View File

@ -1,6 +1,7 @@
using Avalonia.Controls; using Avalonia.Controls;
using LibationAvalonia.ViewModels.Settings; using LibationAvalonia.ViewModels.Settings;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs

View File

@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs
{ {

View File

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using LibationUiBase.Forms;
#nullable enable #nullable enable
namespace LibationAvalonia.Dialogs; namespace LibationAvalonia.Dialogs;

View File

@ -1,6 +1,7 @@
using AppScaffolding; using AppScaffolding;
using Avalonia.Controls; using Avalonia.Controls;
using Dinah.Core; using Dinah.Core;
using LibationUiBase.Forms;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs
{ {

View File

@ -73,13 +73,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.0" /> <PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.2" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" /> <PackageReference Include="Avalonia.Diagnostics" Version="11.3.2" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.3.0" /> <PackageReference Include="Avalonia" Version="11.3.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -6,6 +6,7 @@ using DataLayer;
using Dinah.Core.Logging; using Dinah.Core.Logging;
using LibationAvalonia.Dialogs; using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Dialogs; using LibationAvalonia.ViewModels.Dialogs;
using LibationUiBase.Forms;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -13,54 +14,9 @@ using System.Threading.Tasks;
namespace LibationAvalonia 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 class MessageBox
{ {
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton)
=> ShowCoreAsync(null, text, caption, buttons, icon, defaultButton); => ShowCoreAsync(null, text, caption, buttons, icon, defaultButton);
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, bool saveAndRestorePosition = true) public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, bool saveAndRestorePosition = true)
@ -71,9 +27,8 @@ namespace LibationAvalonia
=> ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); => ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static Task<DialogResult> Show(string text) public static Task<DialogResult> 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<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
=> ShowCoreAsync(owner, text, caption, buttons, icon, defaultButton); => ShowCoreAsync(owner, text, caption, buttons, icon, defaultButton, saveAndRestorePosition);
public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon) public static Task<DialogResult> 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<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons) public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons)
@ -83,22 +38,21 @@ namespace LibationAvalonia
public static Task<DialogResult> Show(Window owner, string text) public static Task<DialogResult> Show(Window owner, string text)
=> ShowCoreAsync(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); => ShowCoreAsync(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static async Task VerboseLoggingWarning_ShowIfTrue() public static async Task VerboseLoggingWarning_ShowIfTrue()
{ {
// when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured // when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured
if (Serilog.Log.Logger.IsVerboseEnabled()) if (Serilog.Log.Logger.IsVerboseEnabled())
await Show(@" await Show("""
Warning: verbose logging is enabled. Warning: verbose logging is enabled.
This should be used for debugging only. It creates many This should be used for debugging only. It creates many
more logs and debug files, neither of which are as more logs and debug files, neither of which are as
strictly anonymous. strictly anonymous.
When you are finished debugging, it's highly recommended When you are finished debugging, it's highly recommended
to set your debug MinimumLevel to Information and restart to set your debug MinimumLevel to Information and restart
Libation. Libation.
".Trim(), "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning); """, "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
} }
/// <summary> /// <summary>
@ -138,7 +92,8 @@ Libation.
{ {
// for development and debugging, show me what broke! // for development and debugging, show me what broke!
if (System.Diagnostics.Debugger.IsAttached) 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 try
{ {
@ -152,12 +107,12 @@ Libation.
} }
private static async Task<DialogResult> ShowCoreAsync(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) private static async Task<DialogResult> 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; 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); return await DisplayWindow(dialog, owner);
} });
private static MessageBoxWindow CreateMessageBox(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true) private static MessageBoxWindow CreateMessageBox(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
{ {
@ -175,7 +130,6 @@ Libation.
tbx.MinWidth = vm.TextBlockMinWidth; tbx.MinWidth = vm.TextBlockMinWidth;
tbx.Text = message; tbx.Text = message;
var thisScreen = owner.Screens?.ScreenFromVisual(owner); var thisScreen = owner.Screens?.ScreenFromVisual(owner);
var maxSize var maxSize
@ -229,6 +183,5 @@ Libation.
return await toDisplay.ShowDialog<DialogResult>(owner); return await toDisplay.ShowDialog<DialogResult>(owner);
} }
} }
} }
} }

View File

@ -1,4 +1,5 @@
using System; using LibationUiBase.Forms;
using System;
namespace LibationAvalonia.ViewModels.Dialogs namespace LibationAvalonia.ViewModels.Dialogs
{ {

View File

@ -4,6 +4,7 @@ using Avalonia.Controls;
using Avalonia.Data; using Avalonia.Data;
using Avalonia.Input; using Avalonia.Input;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using ReactiveUI; using ReactiveUI;
using System; using System;
using System.Linq; using System.Linq;

View File

@ -7,6 +7,7 @@ using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Input; using Avalonia.Input;
using LibationUiBase.Forms;
#nullable enable #nullable enable
namespace LibationAvalonia.ViewModels namespace LibationAvalonia.ViewModels

View File

@ -4,6 +4,9 @@ using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DataLayer; using DataLayer;
using LibationUiBase.Forms;
using LibationUiBase;
using System.Collections.Generic;
#nullable enable #nullable enable
namespace LibationAvalonia.ViewModels namespace LibationAvalonia.ViewModels
@ -12,19 +15,14 @@ namespace LibationAvalonia.ViewModels
{ {
public void Configure_Liberate() { } public void Configure_Liberate() { }
public void BackupAllBooks() public async Task BackupAllBooks()
{ {
try try
{ {
var unliberated = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
if (ProcessQueue.QueueDownloadDecrypt(unliberated))
setQueueCollapseState(false); setQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all library books");
ProcessQueue.AddDownloadDecrypt(
DbContexts
.GetLibrary_Flat_NoTracking()
.UnLiberated()
);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -32,10 +30,10 @@ namespace LibationAvalonia.ViewModels
} }
} }
public void BackupAllPdfs() public async Task BackupAllPdfs()
{ {
if (ProcessQueue.QueueDownloadPdf(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
setQueueCollapseState(false); setQueueCollapseState(false);
ProcessQueue.AddDownloadPdf(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated));
} }
public async Task ConvertAllToMp3Async() public async Task ConvertAllToMp3Async()
@ -48,12 +46,8 @@ namespace LibationAvalonia.ViewModels
"Convert all M4b => Mp3?", "Convert all M4b => Mp3?",
MessageBoxButtons.YesNo, MessageBoxButtons.YesNo,
MessageBoxIcon.Warning); MessageBoxIcon.Warning);
if (result == DialogResult.Yes) if (result == DialogResult.Yes && ProcessQueue.QueueConvertToMp3(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
{
setQueueCollapseState(false); 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) private void setQueueCollapseState(bool collapsed)

View File

@ -1,10 +1,11 @@
using LibationFileManager; using DataLayer;
using System;
using System.Linq;
using DataLayer;
using Dinah.Core; using Dinah.Core;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using ReactiveUI; using ReactiveUI;
using System;
using System.Linq;
#nullable enable #nullable enable
namespace LibationAvalonia.ViewModels namespace LibationAvalonia.ViewModels
@ -37,32 +38,12 @@ namespace LibationAvalonia.ViewModels
{ {
try try
{ {
if (libraryBooks.Length == 1) if (ProcessQueue.QueueDownloadDecrypt(libraryBooks))
{
var item = libraryBooks[0];
//Remove this item from the queue if it's already present and completed.
//Only do this when adding a single book at a time to prevent accidental
//extra downloads when queueing in batches.
ProcessQueue.RemoveCompleted(item);
if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
{
Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item);
setQueueCollapseState(false); setQueueCollapseState(false);
ProcessQueue.AddDownloadDecrypt(item); else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists())
}
else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
{
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
setQueueCollapseState(false);
ProcessQueue.AddDownloadPdf(item);
}
else if (item.Book.Audio_Exists())
{ {
// liberated: open explorer to file // liberated: open explorer to file
var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId); var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId);
if (!Go.To.File(filePath?.ShortPathName)) if (!Go.To.File(filePath?.ShortPathName))
{ {
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
@ -70,20 +51,6 @@ namespace LibationAvalonia.ViewModels
} }
} }
} }
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);
}
}
}
catch (Exception ex) catch (Exception ex)
{ {
Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks);
@ -94,11 +61,10 @@ namespace LibationAvalonia.ViewModels
{ {
try try
{ {
setQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); 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) catch (Exception ex)
{ {
@ -110,13 +76,8 @@ namespace LibationAvalonia.ViewModels
{ {
try try
{ {
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray(); if (ProcessQueue.QueueConvertToMp3(libraryBooks))
if (preLiberated.Length > 0)
{
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
setQueueCollapseState(false); setQueueCollapseState(false);
ProcessQueue.AddConvertMp3(preLiberated);
}
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -5,6 +5,9 @@ using DataLayer;
using Avalonia.Threading; using Avalonia.Threading;
using LibationAvalonia.Dialogs; using LibationAvalonia.Dialogs;
using ReactiveUI; using ReactiveUI;
using LibationUiBase.Forms;
using System.Linq;
using LibationUiBase;
#nullable enable #nullable enable
namespace LibationAvalonia.ViewModels namespace LibationAvalonia.ViewModels
@ -71,15 +74,8 @@ namespace LibationAvalonia.ViewModels
{ {
try try
{ {
if (ProcessQueue.QueueDownloadDecrypt(ProductsDisplay.GetVisibleBookEntries().UnLiberated().ToArray()))
setQueueCollapseState(false); setQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up visible library books");
ProcessQueue.AddDownloadDecrypt(
ProductsDisplay
.GetVisibleBookEntries()
.UnLiberated()
);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -1,420 +1,17 @@
using ApplicationServices; using DataLayer;
using AudibleApi;
using AudibleApi.Common;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileLiberator;
using LibationFileManager; using LibationFileManager;
using LibationUiBase; using LibationUiBase;
using ReactiveUI; using LibationUiBase.ProcessQueue;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
#nullable enable #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 public ProcessBookViewModel(LibraryBook libraryBook, LogMe logme) : base(libraryBook, logme) { }
{
Queued,
Cancelled,
Working,
Completed,
Failed
}
/// <summary> protected override object? LoadImageFromBytes(byte[] bytes, PictureSize pictureSize)
/// This is the viewmodel for queued processables => AvaloniaUtils.TryLoadImageOrDefault(bytes, pictureSize);
/// </summary>
public class ProcessBookViewModel : ViewModelBase
{
public event EventHandler? Completed;
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<Func<Processable>> 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<ProcessBookResult> 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<DownloadPdf>();
public void AddDownloadDecryptBook() => AddProcessable<DownloadDecryptBook>();
public void AddConvertToMp3() => AddProcessable<ConvertToMp3>();
private void AddProcessable<T>() 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<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 ??= 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
} }

View File

@ -1,61 +1,34 @@
using ApplicationServices; using Avalonia.Collections;
using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using DataLayer; using DataLayer;
using LibationFileManager; using LibationFileManager;
using LibationUiBase; using LibationUiBase.ProcessQueue;
using ReactiveUI;
using System; using System;
using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
#nullable enable #nullable enable
namespace LibationAvalonia.ViewModels namespace LibationAvalonia.ViewModels;
public record LogEntry(DateTime LogDate, string? LogMessage)
{ {
public string LogDateString => LogDate.ToShortTimeString();
}
public class ProcessQueueViewModel : ViewModelBase, ILogForm public class ProcessQueueViewModel : ProcessQueueViewModelBase
{
public ProcessQueueViewModel() : base(CreateEmptyList())
{ {
public ObservableCollection<LogEntry> LogEntries { get; } = new(); Items = Queue.UnderlyingList as AvaloniaList<ProcessBookViewModelBase>
public AvaloniaList<ProcessBookViewModel> Items { get; } = new(); ?? throw new ArgumentNullException(nameof(Queue.UnderlyingList));
public TrackedQueue<ProcessBookViewModel> 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; SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
} }
private int _completedCount;
private int _errorCount;
private int _queuedCount;
private string? _runningTime;
private bool _progressBarVisible;
private decimal _speedLimit; private decimal _speedLimit;
public decimal SpeedLimitIncrement { get; private set; }
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); } public ObservableCollection<LogEntry> LogEntries { get; } = new();
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); } public AvaloniaList<ProcessBookViewModelBase> Items { get; }
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 public decimal SpeedLimit
{ {
@ -81,209 +54,21 @@ namespace LibationAvalonia.ViewModels
: _speedLimit > 1 ? 0.1m : _speedLimit > 1 ? 0.1m
: 0.01m; : 0.01m;
Dispatcher.UIThread.Invoke(() => RaisePropertyChanged(nameof(SpeedLimitIncrement));
{ RaisePropertyChanged(nameof(SpeedLimit));
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
this.RaisePropertyChanged();
});
} }
} }
public decimal SpeedLimitIncrement { get; private set; } public override void WriteLine(string text)
=> Dispatcher.UIThread.Invoke(() => LogEntries.Add(new(DateTime.Now, text.Trim())));
private void Queue_CompletedCountChanged(object? sender, int e) protected override ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook)
=> new ProcessBookViewModel(libraryBook, Logger);
private static AvaloniaList<ProcessBookViewModelBase> CreateEmptyList()
{ {
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); if (Design.IsDesignMode)
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); _ = Configuration.Instance.LibationFiles;
return new AvaloniaList<ProcessBookViewModelBase>();
ErrorCount = errCount;
CompletedCount = completeCount;
Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress)));
}
private void Queue_QueuededCountChanged(object? sender, int cueCount)
{
QueuedCount = cueCount;
Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress)));
}
public void WriteLine(string text)
{
Dispatcher.UIThread.Invoke(() =>
LogEntries.Add(new()
{
LogDate = DateTime.Now,
LogMessage = text.Trim()
}));
}
#region Add Books to Queue
private bool isBookInQueue(LibraryBook libraryBook)
{
var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId);
if (entry == null)
return false;
else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed)
return !Queue.RemoveCompleted(entry);
else
return true;
}
public bool RemoveCompleted(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModel entry
&& entry.Status is ProcessBookStatus.Completed
&& Queue.RemoveCompleted(entry);
public void AddDownloadPdf(LibraryBook libraryBook)
=> AddDownloadPdf(new List<LibraryBook>() { libraryBook });
public void AddDownloadDecrypt(LibraryBook libraryBook)
=> AddDownloadDecrypt(new List<LibraryBook>() { libraryBook });
public void AddConvertMp3(LibraryBook libraryBook)
=> AddConvertMp3(new List<LibraryBook>() { libraryBook });
public void AddDownloadPdf(IEnumerable<LibraryBook> entries)
{
List<ProcessBookViewModel> 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<LibraryBook> entries)
{
List<ProcessBookViewModel> 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<LibraryBook> entries)
{
List<ProcessBookViewModel> 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<ProcessBookViewModel> 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);
}
}
public class LogEntry
{
public DateTime LogDate { get; init; }
public string LogDateString => LogDate.ToShortTimeString();
public string? LogMessage { get; init; }
} }
} }

View File

@ -5,8 +5,8 @@ using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using DataLayer; using DataLayer;
using Dinah.Core.Collections.Generic; using Dinah.Core.Collections.Generic;
using LibationAvalonia.Dialogs.Login;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using ReactiveUI; using ReactiveUI;
using System; using System;

View File

@ -7,6 +7,7 @@ using FileManager;
using LibationAvalonia.Dialogs; using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using ReactiveUI; using ReactiveUI;
using System; using System;

View File

@ -4,6 +4,7 @@ using Avalonia.Controls;
using DataLayer; using DataLayer;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationUiBase; using LibationUiBase;
using LibationUiBase.ProcessQueue;
namespace LibationAvalonia.Views namespace LibationAvalonia.Views
{ {

View File

@ -35,6 +35,11 @@
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
AllowAutoHide="False"> AllowAutoHide="False">
<ItemsControl ItemsSource="{Binding Items}"> <ItemsControl ItemsSource="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<views:ProcessBookControl DataContext="{Binding}" /> <views:ProcessBookControl DataContext="{Binding}" />

View File

@ -5,6 +5,7 @@ using Avalonia.Data.Converters;
using DataLayer; using DataLayer;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationUiBase; using LibationUiBase;
using LibationUiBase.ProcessQueue;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
@ -16,7 +17,7 @@ namespace LibationAvalonia.Views
{ {
public partial class ProcessQueueControl : UserControl public partial class ProcessQueueControl : UserControl
{ {
private TrackedQueue<ProcessBookViewModel>? Queue => _viewModel?.Queue; private TrackedQueue<ProcessBookViewModelBase>? Queue => _viewModel?.Queue;
private ProcessQueueViewModel? _viewModel => DataContext as ProcessQueueViewModel; private ProcessQueueViewModel? _viewModel => DataContext as ProcessQueueViewModel;
public ProcessQueueControl() public ProcessQueueControl()

View File

@ -2,7 +2,6 @@ using ApplicationServices;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input.Platform; using Avalonia.Input.Platform;
using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Styling; using Avalonia.Styling;
using DataLayer; using DataLayer;
@ -13,6 +12,7 @@ using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationFileManager; using LibationFileManager;
using LibationFileManager.Templates; using LibationFileManager.Templates;
using LibationUiBase.Forms;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using ReactiveUI; using ReactiveUI;
using System; using System;

View File

@ -9,6 +9,7 @@ using Dinah.Core.StepRunner;
using LibationAvalonia.Dialogs; using LibationAvalonia.Dialogs;
using LibationAvalonia.Views; using LibationAvalonia.Views;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.Forms;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@ -5,7 +5,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.7" />
<PackageReference Include="NameParserSharp" Version="1.5.0" /> <PackageReference Include="NameParserSharp" Version="1.5.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" /> <PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
</ItemGroup> </ItemGroup>

View File

@ -16,9 +16,8 @@ namespace LibationUiBase.GridView
//This Class holds all book entry status info to help the grid properly render entries. //This Class holds all book entry status info to help the grid properly render entries.
//The reason this info is in here instead of GridEntry is because all of this info is needed //The reason this info is in here instead of GridEntry is because all of this info is needed
//for the "Liberate" column's display and sorting functions. //for the "Liberate" column's display and sorting functions.
public abstract class EntryStatus : SynchronizeInvoker, IComparable, INotifyPropertyChanged public abstract class EntryStatus : ReactiveObject, IComparable
{ {
public event PropertyChangedEventHandler PropertyChanged;
public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book); public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book);
public LiberatedStatus BookStatus public LiberatedStatus BookStatus
{ {
@ -81,8 +80,6 @@ namespace LibationUiBase.GridView
internal protected abstract object LoadImage(byte[] picture); internal protected abstract object LoadImage(byte[] picture);
protected abstract object GetResourceImage(string rescName); 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));
/// <summary>Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names.</summary> /// <summary>Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names.</summary>
public void Invalidate(params string[] properties) public void Invalidate(params string[] properties)
@ -104,7 +101,13 @@ namespace LibationUiBase.GridView
else if (!IsUnavailable && second.IsUnavailable) return -1; 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 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() private object GetLiberateIcon()

View File

@ -22,7 +22,7 @@ namespace LibationUiBase.GridView
} }
/// <summary>The View Model base for the DataGridView</summary> /// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry<TStatus> : SynchronizeInvoker, IGridEntry where TStatus : IEntryStatus public abstract class GridEntry<TStatus> : ReactiveObject, IGridEntry where TStatus : IEntryStatus
{ {
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId; [Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; } [Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
@ -183,19 +183,6 @@ namespace LibationUiBase.GridView
} }
} }
private TRet RaiseAndSetIfChanged<TRet>(ref TRet backingField, TRet newValue, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<TRet>.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 #endregion
#region Sorting #region Sorting
@ -228,16 +215,16 @@ namespace LibationUiBase.GridView
// Instantiate comparers for every exposed member object type. // Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> memberTypeComparers = new() private static readonly Dictionary<Type, IComparer> memberTypeComparers = new()
{ {
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() }, { typeof(RemoveStatus), Comparer<RemoveStatus>.Default },
{ typeof(string), new ObjectComparer<string>() }, { typeof(string), Comparer<string>.Default },
{ typeof(int), new ObjectComparer<int>() }, { typeof(int), Comparer <int>.Default },
{ typeof(float), new ObjectComparer<float>() }, { typeof(float), Comparer<float >.Default },
{ typeof(bool), new ObjectComparer<bool>() }, { typeof(bool), Comparer<bool>.Default },
{ typeof(Rating), new ObjectComparer<Rating>() }, { typeof(Rating), Comparer<Rating>.Default },
{ typeof(DateTime), new ObjectComparer<DateTime>() }, { typeof(DateTime), Comparer<DateTime>.Default },
{ typeof(EntryStatus), new ObjectComparer<EntryStatus>() }, { typeof(EntryStatus), Comparer<EntryStatus>.Default },
{ typeof(SeriesOrder), new ObjectComparer<SeriesOrder>() }, { typeof(SeriesOrder), Comparer<SeriesOrder>.Default },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() }, { typeof(LastDownloadStatus), Comparer<LastDownloadStatus>.Default },
}; };
#endregion #endregion

View File

@ -1,10 +0,0 @@
using System;
using System.Collections;
namespace LibationUiBase.GridView
{
public class ObjectComparer<T> : IComparer where T : IComparable
{
public int Compare(object x, object y) => ((T)x).CompareTo(y);
}
}

View File

@ -9,7 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -0,0 +1,133 @@
using MathNet.Numerics;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace LibationUiBase.Forms;
public enum DialogResult
{
/// <summary> Nothing is returned from the dialog box. This means that the modal dialog continues running. </summary>
None = 0,
/// <summary> The dialog box return value is OK (usually sent from a button labeled OK). </summary>
OK = 1, //IDOK
/// <summary> The dialog box return value is Cancel (usually sent from a button labeled Cancel). </summary>
Cancel = 2, //IDCANCEL
/// <summary> The dialog box return value is Abort (usually sent from a button labeled Abort). </summary>
Abort = 3, //IDABORT
/// <summary> The dialog box return value is Retry (usually sent from a button labeled Retry). </summary>
Retry = 4, //IDRETRY
/// <summary> The dialog box return value is Ignore (usually sent from a button labeled Ignore). </summary>
Ignore = 5, //IDIGNORE
/// <summary> The dialog box return value is Yes (usually sent from a button labeled Yes). </summary>
Yes = 6, //IDYES
/// <summary> The dialog box return value is No (usually sent from a button labeled No). </summary>
No = 7, //IDNO
/// <summary> The dialog box return value is Try Again (usually sent from a button labeled Try Again). </summary>
TryAgain = 10, //IDTRYAGAIN
/// <summary> The dialog box return value is Continue (usually sent from a button labeled Continue). </summary>
Continue = 11 //IDCONTINUE
}
public enum MessageBoxIcon
{
/// <summary> Specifies that the message box contain no symbols. </summary>
None = 0x00000000,
/// <summary> Specifies that the message box contains a hand symbol. </summary>
Hand = 0x00000010, //MB_ICONHAND
/// <summary> Specifies that the message box contains a question mark symbol. </summary>
Question = 0x00000020, //MB_ICONQUESTION
/// <summary> Specifies that the message box contains an exclamation symbol. </summary>
Exclamation = 0x00000030, //MB_ICONEXCLAMATION
/// <summary> Specifies that the message box contains an asterisk symbol. </summary>
Asterisk = 0x00000040, //MB_ICONASTERISK
/// <summary> Specifies that the message box contains a hand icon. This field is constant. </summary>
Stop = Hand,
/// <summary> Specifies that the message box contains a hand icon. </summary>
Error = Hand,
/// <summary> Specifies that the message box contains an exclamation icon. </summary>
Warning = Exclamation,
/// <summary> Specifies that the message box contains an asterisk icon. </summary>
Information = Asterisk
}
public enum MessageBoxButtons
{
/// <summary> Specifies that the message box contains an OK button. </summary>
OK = 0x00000000, //MB_OK
/// <summary> Specifies that the message box contains OK and Cancel buttons. </summary>
OKCancel = 0x00000001, //MB_OKCANCEL
/// <summary> Specifies that the message box contains Abort, Retry, and Ignore buttons. </summary>
AbortRetryIgnore = 0x00000002, //MB_ABORTRETRYIGNORE
/// <summary> Specifies that the message box contains Yes, No, and Cancel buttons. </summary>
YesNoCancel = 0x00000003, //MB_YESNOCANCEL
/// <summary> Specifies that the message box contains Yes and No buttons. </summary>
YesNo = 0x00000004, //MB_YESNO
/// <summary> Specifies that the message box contains Retry and Cancel buttons. </summary>
RetryCancel = 0x00000005, //MB_RETRYCANCEL
/// <summary> Specifies that the message box contains Cancel, Try Again, and Continue buttons. </summary>
CancelTryContinue = 0x00000006 //MB_CANCELTRYCONTINUE
}
public enum MessageBoxDefaultButton
{
/// <summary> Specifies that the first button on the message box should be the default button. </summary>
Button1 = 0x00000000, //MB_DEFBUTTON1
/// <summary> Specifies that the second button on the message box should be the default button. </summary>
Button2 = 0x00000100, //MB_DEFBUTTON2
/// <summary> Specifies that the third button on the message box should be the default button. </summary>
Button3 = 0x00000200, //MB_DEFBUTTON3
/// <summary> Specifies that the Help button on the message box should be the default button. </summary>
Button4 = 0x00000300, //MB_DEFBUTTON4
}
/// <summary>
/// Displays a message box in front of the specified object and with the specified text, caption, buttons, icon, and default button.
/// </summary>
/// <param name="owner">An implementation of a GUI window that will own the modal dialog box</param>
/// <param name="message">The text to display in the message box</param>
/// <param name="caption">The text to display in the title bar of the message box</param>
/// <param name="buttons">One of the <see cref="MessageBoxButtons"/> values that specifies which buttons to disply in the message box</param>
/// <param name="icon">One of the <see cref="MessageBoxIcon"/> values that specifies which icon to disply in the message box</param>
/// <param name="defaultButton">One of the <see cref="MessageBoxDefaultButton"/> values that specifies the default button of the message box</param>
/// <param name="saveAndRestorePosition">A value indicating whether the message box's position should be saved and restored the next time it is shown</param>
/// <returns>One of the <see cref="DialogResult"/> values</returns>
public delegate Task<DialogResult> 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<DialogResult> 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<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
=> ShowAsyncImpl(null, text, caption, buttons, icon, defaultButton);
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, bool saveAndRestorePosition = true)
=> ShowAsyncImpl(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition);
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons)
=> ShowAsyncImpl(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static Task<DialogResult> Show(string text, string caption)
=> ShowAsyncImpl(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static Task<DialogResult> Show(string text)
=> ShowAsyncImpl(null, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static Task<DialogResult> Show(object? owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton)
=> ShowAsyncImpl(owner, text, caption, buttons, icon, defaultButton);
public static Task<DialogResult> Show(object? owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon)
=> ShowAsyncImpl(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1);
public static Task<DialogResult> Show(object? owner, string text, string caption, MessageBoxButtons buttons)
=> ShowAsyncImpl(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static Task<DialogResult> Show(object? owner, string text, string caption)
=> ShowAsyncImpl(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static Task<DialogResult> Show(object? owner, string text)
=> ShowAsyncImpl(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
}

View File

@ -0,0 +1,414 @@
using ApplicationServices;
using AudibleApi;
using AudibleApi.Common;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileLiberator;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationUiBase.ProcessQueue;
public enum ProcessBookResult
{
None,
Success,
Cancelled,
ValidationFail,
FailedRetry,
FailedSkip,
FailedAbort,
LicenseDenied,
LicenseDeniedPossibleOutage
}
public enum ProcessBookStatus
{
Queued,
Cancelled,
Working,
Completed,
Failed
}
/// <summary>
/// This is the viewmodel for queued processables
/// </summary>
public abstract class ProcessBookViewModelBase : ReactiveObject
{
private readonly LogMe Logger;
public LibraryBook LibraryBook { get; protected set; }
private ProcessBookResult _result = ProcessBookResult.None;
private ProcessBookStatus _status = ProcessBookStatus.Queued;
private string? _narrator;
private string? _author;
private string? _title;
private int _progress;
private string? _eta;
private object? _cover;
private TimeSpan _timeRemaining;
#region Properties exposed to the view
public ProcessBookResult Result { get => _result; set { RaiseAndSetIfChanged(ref _result, value); RaisePropertyChanged(nameof(StatusText)); } }
public ProcessBookStatus Status { get => _status; set { RaiseAndSetIfChanged(ref _status, value); RaisePropertyChanged(nameof(IsFinished)); RaisePropertyChanged(nameof(IsDownloading)); RaisePropertyChanged(nameof(Queued)); } }
public string? Narrator { get => _narrator; set => RaiseAndSetIfChanged(ref _narrator, value); }
public string? Author { get => _author; set => RaiseAndSetIfChanged(ref _author, value); }
public string? Title { get => _title; set => RaiseAndSetIfChanged(ref _title, value); }
public int Progress { get => _progress; protected set => RaiseAndSetIfChanged(ref _progress, value); }
public TimeSpan TimeRemaining { get => _timeRemaining; set { RaiseAndSetIfChanged(ref _timeRemaining, value); ETA = $"ETA: {value:mm\\:ss}"; } }
public string? ETA { get => _eta; private set => RaiseAndSetIfChanged(ref _eta, value); }
public object? Cover { get => _cover; protected set => RaiseAndSetIfChanged(ref _cover, value); }
public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
public bool IsDownloading => Status is ProcessBookStatus.Working;
public bool Queued => Status is ProcessBookStatus.Queued;
public string StatusText => Result switch
{
ProcessBookResult.Success => "Finished",
ProcessBookResult.Cancelled => "Cancelled",
ProcessBookResult.ValidationFail => "Validation fail",
ProcessBookResult.FailedRetry => "Error, will retry later",
ProcessBookResult.FailedSkip => "Error, Skipping",
ProcessBookResult.FailedAbort => "Error, Abort",
ProcessBookResult.LicenseDenied => "License Denied",
ProcessBookResult.LicenseDeniedPossibleOutage => "Possible Service Interruption",
_ => Status.ToString(),
};
#endregion
protected Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke();
protected void NextProcessable() => _currentProcessable = null;
private Processable? _currentProcessable;
/// <summary> A series of Processable actions to perform on this book </summary>
protected Queue<Func<Processable>> Processes { get; } = new();
protected ProcessBookViewModelBase(LibraryBook libraryBook, LogMe logme)
{
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<ProcessBookResult> ProcessOneAsync()
{
string procName = CurrentProcessable.Name;
ProcessBookResult result = ProcessBookResult.None;
try
{
LinkProcessable(CurrentProcessable);
var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true);
if (statusHandler.IsSuccess)
result = ProcessBookResult.Success;
else if (statusHandler.Errors.Contains("Cancelled"))
{
Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}");
result = ProcessBookResult.Cancelled;
}
else if (statusHandler.Errors.Contains("Validation failed"))
{
Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}");
result = ProcessBookResult.ValidationFail;
}
else
{
foreach (var errorMessage in statusHandler.Errors)
Logger.Error($"{procName}: {errorMessage}");
}
}
catch (ContentLicenseDeniedException ldex)
{
if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
{
Logger.Info($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDeniedPossibleOutage;
}
else
{
Logger.Info($"{procName}: Content license denied. Check your Audible account to see if you have access to this title. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDenied;
}
}
catch (Exception ex)
{
Logger.Error(ex, procName);
}
finally
{
if (result == ProcessBookResult.None)
result = await GetFailureActionAsync(LibraryBook);
var status = result switch
{
ProcessBookResult.Success => ProcessBookStatus.Completed,
ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled,
_ => ProcessBookStatus.Failed,
};
Status = status;
}
Result = result;
return result;
}
public async Task CancelAsync()
{
try
{
if (CurrentProcessable is AudioDecodable audioDecodable)
await audioDecodable.CancelAsync();
}
catch (Exception ex)
{
Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling");
}
}
public ProcessBookViewModelBase AddDownloadPdf() => AddProcessable<DownloadPdf>();
public ProcessBookViewModelBase AddDownloadDecryptBook() => AddProcessable<DownloadDecryptBook>();
public ProcessBookViewModelBase AddConvertToMp3() => AddProcessable<ConvertToMp3>();
private ProcessBookViewModelBase AddProcessable<T>() where T : Processable, new()
{
Processes.Enqueue(() => new T());
return this;
}
public override string ToString() => LibraryBook.ToString();
#region Subscribers and Unsubscribers
private void LinkProcessable(Processable processable)
{
processable.Begin += Processable_Begin;
processable.Completed += Processable_Completed;
processable.StreamingProgressChanged += Streamable_StreamingProgressChanged;
processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining;
if (processable is AudioDecodable audioDecodable)
{
audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt;
audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered;
audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered;
audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered;
audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered;
}
}
private void UnlinkProcessable(Processable processable)
{
processable.Begin -= Processable_Begin;
processable.Completed -= Processable_Completed;
processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged;
processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining;
if (processable is AudioDecodable audioDecodable)
{
audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt;
audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered;
audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered;
audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered;
audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered;
}
}
#endregion
#region AudioDecodable event handlers
private void AudioDecodable_TitleDiscovered(object? sender, string title) => Title = title;
private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors;
private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators;
private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt)
=> Cover = LoadImageFromBytes(coverArt, PictureSize._80x80);
private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e)
{
var quality
= Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null
? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native)
: new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500);
byte[] coverData = PictureStorage.GetPictureSynchronously(quality);
AudioDecodable_CoverImageDiscovered(this, coverData);
return coverData;
}
#endregion
#region Streamable event handlers
private void Streamable_StreamingTimeRemaining(object? sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining;
private void Streamable_StreamingProgressChanged(object? sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress)
{
if (!downloadProgress.ProgressPercentage.HasValue)
return;
if (downloadProgress.ProgressPercentage == 0)
TimeRemaining = TimeSpan.Zero;
else
Progress = (int)downloadProgress.ProgressPercentage;
}
#endregion
#region Processable event handlers
private void Processable_Begin(object? sender, LibraryBook libraryBook)
{
Status = ProcessBookStatus.Working;
if (sender is Processable processable)
Logger.Info($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}");
Title = libraryBook.Book.TitleWithSubtitle;
Author = libraryBook.Book.AuthorNames();
Narrator = libraryBook.Book.NarratorNames();
}
private async void Processable_Completed(object? sender, LibraryBook libraryBook)
{
if (sender is Processable processable)
{
Logger.Info($"{processable.Name} Step, Completed: {libraryBook.Book}");
UnlinkProcessable(processable);
}
if (Processes.Count == 0)
return;
NextProcessable();
LinkProcessable(CurrentProcessable);
StatusHandler result;
try
{
result = await CurrentProcessable.ProcessSingleAsync(libraryBook, validate: true);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Processable_Completed)} error");
result = new StatusHandler();
result.AddError($"{nameof(Processable_Completed)} error. See log for details. Error summary: {ex.Message}");
}
if (result.HasErrors)
{
foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed"))
Logger.Error(errorMessage);
}
}
#endregion
#region Failure Handler
protected async Task<ProcessBookResult> GetFailureActionAsync(LibraryBook libraryBook)
{
const DialogResult SkipResult = DialogResult.Ignore;
Logger.Error($"ERROR. All books have not been processed. Book failed: {libraryBook.Book}");
DialogResult? dialogResult = Configuration.Instance.BadBook switch
{
Configuration.BadBookAction.Abort => DialogResult.Abort,
Configuration.BadBookAction.Retry => DialogResult.Retry,
Configuration.BadBookAction.Ignore => DialogResult.Ignore,
Configuration.BadBookAction.Ask or _ => await ShowRetryDialogAsync(libraryBook)
};
if (dialogResult == SkipResult)
{
libraryBook.UpdateBookStatus(LiberatedStatus.Error);
Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}");
}
return dialogResult is SkipResult ? ProcessBookResult.FailedSkip
: dialogResult is DialogResult.Abort ? ProcessBookResult.FailedAbort
: ProcessBookResult.FailedRetry;
}
protected async Task<DialogResult> ShowRetryDialogAsync(LibraryBook libraryBook)
{
string details;
try
{
static string trunc(string str)
=> string.IsNullOrWhiteSpace(str) ? "[empty]"
: (str.Length > 50) ? $"{str.Truncate(47)}..."
: str;
details = $"""
Title: {libraryBook.Book.TitleWithSubtitle}
ID: {libraryBook.Book.AudibleProductId}
Author: {trunc(libraryBook.Book.AuthorNames())}
Narr: {trunc(libraryBook.Book.NarratorNames())}
""";
}
catch
{
details = "[Error retrieving details]";
}
var skipDialogText = $"""
An error occurred while trying to process this book.
{details}
- ABORT: Stop processing books.
- RETRY: Skip this book for now, but retry if it is requeued. Continue processing the queued books.
- IGNORE: Permanently ignore this book. Continue processing the queued books. (Will not try this book again later.)
See Settings in the Download/Decrypt tab to avoid this box in the future.
""";
const MessageBoxButtons SkipDialogButtons = MessageBoxButtons.AbortRetryIgnore;
const MessageBoxDefaultButton SkipDialogDefaultButton = MessageBoxDefaultButton.Button1;
try
{
return await MessageBoxBase.Show(skipDialogText, "Skip this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error showing retry dialog. Defaulting to 'Retry'; action.");
return DialogResult.Retry;
}
}
#endregion
}

View File

@ -0,0 +1,235 @@
using DataLayer;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Linq;
using ApplicationServices;
using System.Threading.Tasks;
#nullable enable
namespace LibationUiBase.ProcessQueue;
public abstract class ProcessQueueViewModelBase : ReactiveObject, ILogForm
{
public abstract void WriteLine(string text);
protected abstract ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook);
public TrackedQueue<ProcessBookViewModelBase> Queue { get; }
public Task? QueueRunner { get; private set; }
public bool Running => !QueueRunner?.IsCompleted ?? false;
protected LogMe Logger { get; }
public ProcessQueueViewModelBase(ICollection<ProcessBookViewModelBase>? underlyingList)
{
Logger = LogMe.RegisterForm(this);
Queue = new(underlyingList);
Queue.QueuedCountChanged += Queue_QueuedCountChanged;
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
}
private int _completedCount;
private int _errorCount;
private int _queuedCount;
private string? _runningTime;
private bool _progressBarVisible;
public int CompletedCount { get => _completedCount; private set { RaiseAndSetIfChanged(ref _completedCount, value); RaisePropertyChanged(nameof(AnyCompleted)); } }
public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); RaisePropertyChanged(nameof(AnyQueued)); } }
public int ErrorCount { get => _errorCount; private set { RaiseAndSetIfChanged(ref _errorCount, value); RaisePropertyChanged(nameof(AnyErrors)); } }
public string? RunningTime { get => _runningTime; set => RaiseAndSetIfChanged(ref _runningTime, value); }
public bool ProgressBarVisible { get => _progressBarVisible; set => RaiseAndSetIfChanged(ref _progressBarVisible, value); }
public bool AnyCompleted => CompletedCount > 0;
public bool AnyQueued => QueuedCount > 0;
public bool AnyErrors => ErrorCount > 0;
public double Progress => 100d * Queue.Completed.Count / Queue.Count;
private void Queue_CompletedCountChanged(object? sender, int e)
{
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
ErrorCount = errCount;
CompletedCount = completeCount;
RaisePropertyChanged(nameof(Progress));
}
private void Queue_QueuedCountChanged(object? sender, int cueCount)
{
QueuedCount = cueCount;
RaisePropertyChanged(nameof(Progress));
}
#region Add Books to Queue
public bool QueueDownloadPdf(IList<LibraryBook> libraryBooks)
{
var needsPdf = libraryBooks.Where(lb => lb.NeedsPdfDownload()).ToArray();
if (needsPdf.Length > 0)
{
Serilog.Log.Logger.Information("Begin download {count} pdfs", needsPdf.Length);
AddDownloadPdf(needsPdf);
return true;
}
return false;
}
public bool QueueConvertToMp3(IList<LibraryBook> 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<LibraryBook> libraryBooks)
{
if (libraryBooks.Count == 1)
{
var item = libraryBooks[0];
if (item.AbsentFromLastScan)
return false;
else if (item.NeedsBookDownload())
{
RemoveCompleted(item);
Serilog.Log.Logger.Information("Begin single library book backup of {libraryBook}", item);
AddDownloadDecrypt([item]);
return true;
}
else if (item.NeedsPdfDownload())
{
RemoveCompleted(item);
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
AddDownloadPdf([item]);
return true;
}
}
else
{
var toLiberate = libraryBooks.UnLiberated().ToArray();
if (toLiberate.Length > 0)
{
Serilog.Log.Logger.Information("Begin backup of {count} library books", toLiberate.Length);
AddDownloadDecrypt(toLiberate);
return true;
}
}
return false;
}
private bool IsBookInQueue(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is not ProcessBookViewModelBase entry ? false
: entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed ? !Queue.RemoveCompleted(entry)
: true;
private bool RemoveCompleted(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModelBase entry
&& entry.Status is ProcessBookStatus.Completed
&& Queue.RemoveCompleted(entry);
private void AddDownloadPdf(IEnumerable<LibraryBook> entries)
{
var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray();
Serilog.Log.Logger.Information("Queueing {count} books for PDF-only download", procs.Length);
AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddDownloadPdf();
}
private void AddDownloadDecrypt(IEnumerable<LibraryBook> entries)
{
var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray();
Serilog.Log.Logger.Information("Queueing {count} books ofr download/decrypt", procs.Length);
AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddDownloadDecryptBook().AddDownloadPdf();
}
private void AddConvertMp3(IEnumerable<LibraryBook> entries)
{
var procs = entries.Where(e => !IsBookInQueue(e)).Select(Create).ToArray();
Serilog.Log.Logger.Information("Queueing {count} books for mp3 conversion", procs.Length);
AddToQueue(procs);
ProcessBookViewModelBase Create(LibraryBook entry)
=> CreateNewProcessBook(entry).AddConvertToMp3();
}
private void AddToQueue(IEnumerable<ProcessBookViewModelBase> pbook)
{
Queue.Enqueue(pbook);
if (!Running)
QueueRunner = Task.Run(QueueLoop);
}
#endregion
private async Task QueueLoop()
{
try
{
Serilog.Log.Logger.Information("Begin processing queue");
RunningTime = string.Empty;
ProgressBarVisible = true;
var startingTime = DateTime.Now;
bool shownServiceOutageMessage = false;
using var counterTimer = new System.Threading.Timer(_ => RunningTime = timeToStr(DateTime.Now - startingTime), null, 0, 500);
while (Queue.MoveNext())
{
if (Queue.Current is not ProcessBookViewModelBase nextBook)
{
Serilog.Log.Logger.Information("Current queue item is empty.");
continue;
}
Serilog.Log.Logger.Information("Begin processing queued item: '{item_LibraryBook}'", nextBook.LibraryBook);
var result = await nextBook.ProcessOneAsync();
Serilog.Log.Logger.Information("Completed processing queued item: '{item_LibraryBook}' with result: {result}", nextBook.LibraryBook, result);
if (result == ProcessBookResult.ValidationFail)
Queue.ClearCurrent();
else if (result == ProcessBookResult.FailedAbort)
Queue.ClearQueue();
else if (result == ProcessBookResult.FailedSkip)
nextBook.LibraryBook.UpdateBookStatus(LiberatedStatus.Error);
else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage)
{
await MessageBoxBase.Show($"""
You were denied a content license for {nextBook.LibraryBook.Book.TitleWithSubtitle}
This error appears to be caused by a temporary interruption of service that sometimes affects Libation's users. This type of error usually resolves itself in 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app.
""",
"Possible Interruption of Service",
MessageBoxButtons.OK,
MessageBoxIcon.Asterisk);
shownServiceOutageMessage = true;
}
}
Serilog.Log.Logger.Information("Completed processing queue");
Queue_CompletedCountChanged(this, 0);
ProgressBarVisible = false;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items");
}
string timeToStr(TimeSpan time)
=> time.TotalHours < 1 ? $"{time:mm\\:ss}"
: $"{time.TotalHours:F0}:{time:mm\\:ss}";
}
}

View File

@ -0,0 +1,33 @@
using Dinah.Core.Threading;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
#nullable enable
namespace LibationUiBase;
public class ReactiveObject : SynchronizeInvoker, INotifyPropertyChanged, INotifyPropertyChanging
{
public event PropertyChangedEventHandler? PropertyChanged;
public event PropertyChangingEventHandler? PropertyChanging;
public void RaisePropertyChanging(PropertyChangingEventArgs args) => Invoke(() => PropertyChanging?.Invoke(this, args));
public void RaisePropertyChanging(string propertyName) => RaisePropertyChanging(new PropertyChangingEventArgs(propertyName));
public void RaisePropertyChanged(PropertyChangedEventArgs args) => Invoke(() => PropertyChanged?.Invoke(this, args));
public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
public TRet RaiseAndSetIfChanged<TRet>(ref TRet backingField, TRet newValue, [CallerMemberName] string? propertyName = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(propertyName, nameof(propertyName));
if (!EqualityComparer<TRet>.Default.Equals(backingField, newValue))
{
RaisePropertyChanging(propertyName);
backingField = newValue;
RaisePropertyChanged(propertyName!);
}
return newValue;
}
}

View File

@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
#nullable enable
namespace LibationUiBase namespace LibationUiBase
{ {
public enum QueuePosition public enum QueuePosition
@ -34,10 +35,10 @@ namespace LibationUiBase
*/ */
public class TrackedQueue<T> where T : class public class TrackedQueue<T> where T : class
{ {
public event EventHandler<int> CompletedCountChanged; public event EventHandler<int>? CompletedCountChanged;
public event EventHandler<int> QueuededCountChanged; public event EventHandler<int>? QueuedCountChanged;
public T Current { get; private set; } public T? Current { get; private set; }
public IReadOnlyList<T> Queued => _queued; public IReadOnlyList<T> Queued => _queued;
public IReadOnlyList<T> Completed => _completed; public IReadOnlyList<T> Completed => _completed;
@ -46,9 +47,10 @@ namespace LibationUiBase
private readonly List<T> _completed = new(); private readonly List<T> _completed = new();
private readonly object lockObject = new(); private readonly object lockObject = new();
private readonly ICollection<T> _underlyingList; private readonly ICollection<T>? _underlyingList;
public ICollection<T>? UnderlyingList => _underlyingList;
public TrackedQueue(ICollection<T> underlyingList = null) public TrackedQueue(ICollection<T>? underlyingList = null)
{ {
_underlyingList = underlyingList; _underlyingList = underlyingList;
} }
@ -113,7 +115,7 @@ namespace LibationUiBase
if (itemsRemoved) if (itemsRemoved)
{ {
QueuededCountChanged?.Invoke(this, queuedCount); QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary(); RebuildSecondary();
} }
return itemsRemoved; return itemsRemoved;
@ -149,7 +151,7 @@ namespace LibationUiBase
{ {
lock (lockObject) lock (lockObject)
_queued.Clear(); _queued.Clear();
QueuededCountChanged?.Invoke(this, 0); QueuedCountChanged?.Invoke(this, 0);
RebuildSecondary(); RebuildSecondary();
} }
@ -169,7 +171,7 @@ namespace LibationUiBase
} }
} }
public T FirstOrDefault(Func<T, bool> predicate) public T? FirstOrDefault(Func<T, bool> predicate)
{ {
lock (lockObject) lock (lockObject)
{ {
@ -246,7 +248,7 @@ namespace LibationUiBase
{ {
if (completedChanged) if (completedChanged)
CompletedCountChanged?.Invoke(this, completedCount); CompletedCountChanged?.Invoke(this, completedCount);
QueuededCountChanged?.Invoke(this, queuedCount); QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary(); RebuildSecondary();
} }
} }
@ -261,7 +263,7 @@ namespace LibationUiBase
} }
foreach (var i in item) foreach (var i in item)
_underlyingList?.Add(i); _underlyingList?.Add(i);
QueuededCountChanged?.Invoke(this, queueCount); QueuedCountChanged?.Invoke(this, queueCount);
} }
private void RebuildSecondary() private void RebuildSecondary()

View File

@ -1,4 +1,5 @@
using DataLayer; using DataLayer;
using LibationUiBase;
using System; using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -11,19 +12,13 @@ namespace LibationWinForms
private void Configure_Liberate() { } private void Configure_Liberate() { }
//GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread //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 try
{ {
var unliberated = await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated))
SetQueueCollapseState(false); SetQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all library books");
processBookQueue1.AddDownloadDecrypt(
ApplicationServices.DbContexts
.GetLibrary_Flat_NoTracking()
.UnLiberated()
);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -33,9 +28,8 @@ namespace LibationWinForms
private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e) private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e)
{ {
if (processBookQueue1.ViewModel.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking())))
SetQueueCollapseState(false); SetQueueCollapseState(false);
await Task.Run(() => processBookQueue1.AddDownloadPdf(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()
.Where(lb => lb.Book.UserDefinedItem.PdfStatus is DataLayer.LiberatedStatus.NotLiberated)));
} }
private async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, EventArgs e) private async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, EventArgs e)
@ -48,13 +42,8 @@ namespace LibationWinForms
"Convert all M4b => Mp3?", "Convert all M4b => Mp3?",
MessageBoxButtons.YesNo, MessageBoxButtons.YesNo,
MessageBoxIcon.Warning); MessageBoxIcon.Warning);
if (result == DialogResult.Yes) if (result == DialogResult.Yes && processBookQueue1.ViewModel.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking())))
{
SetQueueCollapseState(false); 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.
} }
} }
} }

View File

@ -1,6 +1,8 @@
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using LibationFileManager; using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using LibationWinForms.ProcessQueue; using LibationWinForms.ProcessQueue;
using System; using System;
@ -14,7 +16,7 @@ namespace LibationWinForms
int WidthChange = 0; int WidthChange = 0;
private void Configure_ProcessQueue() private void Configure_ProcessQueue()
{ {
processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut; processBookQueue1.PopoutButton.Click += ProcessBookQueue1_PopOut;
WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth; WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth;
int width = this.Width; int width = this.Width;
@ -27,31 +29,12 @@ namespace LibationWinForms
{ {
try try
{ {
if (libraryBooks.Length == 1) if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks))
{
var item = libraryBooks[0];
//Remove this item from the queue if it's already present and completed.
//Only do this when adding a single book at a time to prevent accidental
//extra downloads when queueing in batches.
processBookQueue1.RemoveCompleted(item);
if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
{
Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item);
SetQueueCollapseState(false); SetQueueCollapseState(false);
processBookQueue1.AddDownloadDecrypt(item); else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists())
}
else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
{
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
SetQueueCollapseState(false);
processBookQueue1.AddDownloadPdf(item);
}
else if (item.Book.Audio_Exists())
{ {
// liberated: open explorer to file // liberated: open explorer to file
var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId); var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId);
if (!Go.To.File(filePath?.ShortPathName)) if (!Go.To.File(filePath?.ShortPathName))
{ {
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}"; var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
@ -59,20 +42,6 @@ namespace LibationWinForms
} }
} }
} }
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);
}
}
}
catch (Exception ex) catch (Exception ex)
{ {
Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks); Serilog.Log.Logger.Error(ex, "An error occurred while handling the stop light button click for {libraryBook}", libraryBooks);
@ -83,11 +52,10 @@ namespace LibationWinForms
{ {
try try
{ {
SetQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook); Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
processBookQueue1.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated()); if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
SetQueueCollapseState(false);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -99,13 +67,8 @@ namespace LibationWinForms
{ {
try try
{ {
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray(); if (processBookQueue1.ViewModel.QueueConvertToMp3(libraryBooks))
if (preLiberated.Length > 0)
{
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
SetQueueCollapseState(false); SetQueueCollapseState(false);
processBookQueue1.AddConvertMp3(preLiberated);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -124,10 +87,14 @@ namespace LibationWinForms
} }
else if (!collapsed && splitContainer1.Panel2Collapsed) else if (!collapsed && splitContainer1.Panel2Collapsed)
{ {
if (!processBookQueue1.PopoutButton.Visible)
//Queue is in popout mode. Do nothing.
return;
Width += WidthChange; Width += WidthChange;
splitContainer1.Panel2.Controls.Add(processBookQueue1); splitContainer1.Panel2.Controls.Add(processBookQueue1);
splitContainer1.Panel2Collapsed = false; splitContainer1.Panel2Collapsed = false;
processBookQueue1.popoutBtn.Visible = true; processBookQueue1.PopoutButton.Visible = true;
} }
Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed)); Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed));
@ -147,7 +114,7 @@ namespace LibationWinForms
dockForm.FormClosing += DockForm_FormClosing; dockForm.FormClosing += DockForm_FormClosing;
splitContainer1.Panel2.Controls.Remove(processBookQueue1); splitContainer1.Panel2.Controls.Remove(processBookQueue1);
splitContainer1.Panel2Collapsed = true; splitContainer1.Panel2Collapsed = true;
processBookQueue1.popoutBtn.Visible = false; processBookQueue1.PopoutButton.Visible = false;
dockForm.PassControl(processBookQueue1); dockForm.PassControl(processBookQueue1);
dockForm.Show(); dockForm.Show();
this.Width -= dockForm.WidthChange; this.Width -= dockForm.WidthChange;
@ -164,7 +131,7 @@ namespace LibationWinForms
this.Width += dockForm.WidthChange; this.Width += dockForm.WidthChange;
splitContainer1.Panel2.Controls.Add(dockForm.RegainControl()); splitContainer1.Panel2.Controls.Add(dockForm.RegainControl());
splitContainer1.Panel2Collapsed = false; splitContainer1.Panel2Collapsed = false;
processBookQueue1.popoutBtn.Visible = true; processBookQueue1.PopoutButton.Visible = true;
dockForm.SaveSizeAndLocation(Configuration.Instance); dockForm.SaveSizeAndLocation(Configuration.Instance);
this.Focus(); this.Focus();
toggleQueueHideBtn.Visible = true; toggleQueueHideBtn.Visible = true;

View File

@ -5,6 +5,7 @@ using System.Windows.Forms;
using ApplicationServices; using ApplicationServices;
using DataLayer; using DataLayer;
using Dinah.Core.Threading; using Dinah.Core.Threading;
using LibationUiBase;
using LibationWinForms.Dialogs; using LibationWinForms.Dialogs;
namespace LibationWinForms namespace LibationWinForms
@ -25,9 +26,17 @@ namespace LibationWinForms
} }
private async void setLiberatedVisibleMenuItemAsync(object _, object __) private async void setLiberatedVisibleMenuItemAsync(object _, object __)
=> await Task.Run(setLiberatedVisibleMenuItem); => await Task.Run(setLiberatedVisibleMenuItem);
private static DateTime lastVisibleCountUpdated;
void setLiberatedVisibleMenuItem() 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()); var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible());
if (updaterTime < lastVisibleCountUpdated)
return;
this.UIThreadSync(() => this.UIThreadSync(() =>
{ {
if (libraryStats.HasPendingBooks) if (libraryStats.HasPendingBooks)
@ -50,15 +59,8 @@ namespace LibationWinForms
{ {
try try
{ {
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray()))
SetQueueCollapseState(false); SetQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up visible library books");
processBookQueue1.AddDownloadDecrypt(
productsDisplay
.GetVisible()
.UnLiberated()
);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@ -23,7 +23,8 @@ namespace LibationWinForms
{ {
// for development and debugging, show me what broke! // for development and debugging, show me what broke!
if (System.Diagnostics.Debugger.IsAttached) 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 try
{ {

View File

@ -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
}
/// <summary>
/// This is the viewmodel for queued processables
/// </summary>
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<Func<Processable>> 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<ProcessBookResult> 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<DownloadPdf>();
public void AddDownloadDecryptBook() => AddProcessable<DownloadDecryptBook>();
public void AddConvertToMp3() => AddProcessable<ConvertToMp3>();
private void AddProcessable<T>() 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
}

View File

@ -1,4 +1,5 @@
using System; using LibationUiBase.ProcessQueue;
using System;
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
@ -6,45 +7,74 @@ namespace LibationWinForms.ProcessQueue
{ {
internal partial class ProcessBookControl : UserControl internal partial class ProcessBookControl : UserControl
{ {
private static int ControlNumberCounter = 0;
/// <summary>
/// The contol's position within <see cref="VirtualFlowControl"/>
/// </summary>
public int ControlNumber { get; }
private ProcessBookStatus Status { get; set; } = ProcessBookStatus.Queued;
private readonly int CancelBtnDistanceFromEdge; private readonly int CancelBtnDistanceFromEdge;
private readonly int ProgressBarDistanceFromEdge; private readonly int ProgressBarDistanceFromEdge;
public static Color FailedColor = Color.LightCoral; private static Color FailedColor { get; } = Color.LightCoral;
public static Color CancelledColor = Color.Khaki; private static Color CancelledColor { get; } = Color.Khaki;
public static Color QueuedColor = SystemColors.Control; private static Color QueuedColor { get; } = SystemColors.Control;
public static Color SuccessColor = Color.PaleGreen; private static Color SuccessColor { get; } = Color.PaleGreen;
private ProcessBookViewModelBase m_Context;
public ProcessBookViewModelBase Context
{
get => m_Context;
set
{
if (m_Context != value)
{
OnContextChanging();
m_Context = value;
OnContextChanged();
}
}
}
public ProcessBookControl() public ProcessBookControl()
{ {
InitializeComponent(); InitializeComponent();
statusLbl.Text = "Queued";
remainingTimeLbl.Visible = false; remainingTimeLbl.Visible = false;
progressBar1.Visible = false; progressBar1.Visible = false;
etaLbl.Visible = false; etaLbl.Visible = false;
CancelBtnDistanceFromEdge = Width - cancelBtn.Location.X; CancelBtnDistanceFromEdge = Width - cancelBtn.Location.X;
ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width; ProgressBarDistanceFromEdge = Width - progressBar1.Location.X - progressBar1.Width;
ControlNumber = ControlNumberCounter++;
} }
public void SetCover(Image cover) private void OnContextChanging()
{ {
pictureBox1.Image = cover; if (Context is not null)
Context.PropertyChanged -= Context_PropertyChanged;
} }
public void SetBookInfo(string title) private void OnContextChanged()
{ {
bookInfoLbl.Text = title; Context.PropertyChanged += Context_PropertyChanged;
Context_PropertyChanged(this, new System.ComponentModel.PropertyChangedEventArgs(null));
} }
public void SetProgrss(int progress) private void Context_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
SuspendLayout();
if (e.PropertyName is null or nameof(Context.Cover))
SetCover(Context.Cover as Image);
if (e.PropertyName is null or nameof(Context.Title) or nameof(Context.Author) or nameof(Context.Narrator))
SetBookInfo($"{Context.Title}\r\nBy {Context.Author}\r\nNarrated by {Context.Narrator}");
if (e.PropertyName is null or nameof(Context.Status) or nameof(Context.StatusText))
SetStatus(Context.Status, Context.StatusText);
if (e.PropertyName is null or nameof(Context.Progress))
SetProgress(Context.Progress);
if (e.PropertyName is null or nameof(Context.TimeRemaining))
SetRemainingTime(Context.TimeRemaining);
ResumeLayout();
}
private void SetCover(Image cover) => pictureBox1.Image = cover;
private void SetBookInfo(string title) => bookInfoLbl.Text = title;
private void SetRemainingTime(TimeSpan remaining)
=> remainingTimeLbl.Text = $"{remaining:mm\\:ss}";
private void SetProgress(int progress)
{ {
//Disable slow fill //Disable slow fill
//https://stackoverflow.com/a/5332770/3335599 //https://stackoverflow.com/a/5332770/3335599
@ -53,34 +83,9 @@ namespace LibationWinForms.ProcessQueue
progressBar1.Value = progress; progressBar1.Value = progress;
} }
public void SetRemainingTime(TimeSpan remaining) private void SetStatus(ProcessBookStatus status, string statusText)
{ {
remainingTimeLbl.Text = $"{remaining:mm\\:ss}"; Color backColor = status switch
}
public void SetResult(ProcessBookResult result)
{
(string statusText, ProcessBookStatus status) = result switch
{
ProcessBookResult.Success => ("Finished", ProcessBookStatus.Completed),
ProcessBookResult.Cancelled => ("Cancelled", ProcessBookStatus.Cancelled),
ProcessBookResult.FailedRetry => ("Error, will retry later", ProcessBookStatus.Failed),
ProcessBookResult.FailedSkip => ("Error, Skipping", ProcessBookStatus.Failed),
ProcessBookResult.FailedAbort => ("Error, Abort", ProcessBookStatus.Failed),
ProcessBookResult.ValidationFail => ("Validation fail", ProcessBookStatus.Failed),
ProcessBookResult.LicenseDenied => ("License Denied", ProcessBookStatus.Failed),
ProcessBookResult.LicenseDeniedPossibleOutage => ("Possible Service Interruption", ProcessBookStatus.Failed),
_ => ("UNKNOWN", ProcessBookStatus.Failed),
};
SetStatus(status, statusText);
}
public void SetStatus(ProcessBookStatus status, string statusText = null)
{
Status = status;
Color backColor = Status switch
{ {
ProcessBookStatus.Completed => SuccessColor, ProcessBookStatus.Completed => SuccessColor,
ProcessBookStatus.Cancelled => CancelledColor, ProcessBookStatus.Cancelled => CancelledColor,
@ -89,27 +94,25 @@ namespace LibationWinForms.ProcessQueue
_ => FailedColor _ => FailedColor
}; };
SuspendLayout(); cancelBtn.Visible = status is ProcessBookStatus.Queued or ProcessBookStatus.Working;
moveLastBtn.Visible = status == ProcessBookStatus.Queued;
cancelBtn.Visible = Status is ProcessBookStatus.Queued or ProcessBookStatus.Working; moveDownBtn.Visible = status == ProcessBookStatus.Queued;
moveLastBtn.Visible = Status == ProcessBookStatus.Queued; moveUpBtn.Visible = status == ProcessBookStatus.Queued;
moveDownBtn.Visible = Status == ProcessBookStatus.Queued; moveFirstBtn.Visible = status == ProcessBookStatus.Queued;
moveUpBtn.Visible = Status == ProcessBookStatus.Queued; remainingTimeLbl.Visible = status == ProcessBookStatus.Working;
moveFirstBtn.Visible = Status == ProcessBookStatus.Queued; progressBar1.Visible = status == ProcessBookStatus.Working;
remainingTimeLbl.Visible = Status == ProcessBookStatus.Working; etaLbl.Visible = status == ProcessBookStatus.Working;
progressBar1.Visible = Status == ProcessBookStatus.Working; statusLbl.Visible = status != ProcessBookStatus.Working;
etaLbl.Visible = Status == ProcessBookStatus.Working; statusLbl.Text = statusText;
statusLbl.Visible = Status != ProcessBookStatus.Working;
statusLbl.Text = statusText ?? Status.ToString();
BackColor = backColor; BackColor = backColor;
int deltaX = Width - cancelBtn.Location.X - CancelBtnDistanceFromEdge; 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 //If the last book to occupy this control before resizing was not
//queued, the buttons were not Visible so the Anchor property was //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); cancelBtn.Location = new Point(cancelBtn.Location.X + deltaX, cancelBtn.Location.Y);
moveFirstBtn.Location = new Point(moveFirstBtn.Location.X + deltaX, moveFirstBtn.Location.Y); moveFirstBtn.Location = new Point(moveFirstBtn.Location.X + deltaX, moveFirstBtn.Location.Y);
@ -129,13 +132,8 @@ namespace LibationWinForms.ProcessQueue
{ {
bookInfoLbl.Width = moveLastBtn.Location.X - bookInfoLbl.Location.X - bookInfoLbl.Padding.Left + moveLastBtn.Padding.Right; bookInfoLbl.Width = moveLastBtn.Location.X - bookInfoLbl.Location.X - bookInfoLbl.Padding.Left + moveLastBtn.Padding.Right;
} }
ResumeLayout();
} }
public override string ToString() public override string ToString() => bookInfoLbl.Text ?? "[NO TITLE]";
{
return bookInfoLbl.Text ?? "[NO TITLE]";
}
} }
} }

View File

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

View File

@ -55,7 +55,6 @@
this.panel2 = new System.Windows.Forms.Panel(); this.panel2 = new System.Windows.Forms.Panel();
this.logCopyBtn = new System.Windows.Forms.Button(); this.logCopyBtn = new System.Windows.Forms.Button();
this.clearLogBtn = 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.statusStrip1.SuspendLayout();
this.tabControl1.SuspendLayout(); this.tabControl1.SuspendLayout();
this.tabPage1.SuspendLayout(); this.tabPage1.SuspendLayout();
@ -163,7 +162,6 @@
this.virtualFlowControl2.Name = "virtualFlowControl2"; this.virtualFlowControl2.Name = "virtualFlowControl2";
this.virtualFlowControl2.Size = new System.Drawing.Size(390, 424); this.virtualFlowControl2.Size = new System.Drawing.Size(390, 424);
this.virtualFlowControl2.TabIndex = 3; this.virtualFlowControl2.TabIndex = 3;
this.virtualFlowControl2.VirtualControlCount = 0;
// //
// panel1 // panel1
// //
@ -329,11 +327,6 @@
this.clearLogBtn.UseVisualStyleBackColor = true; this.clearLogBtn.UseVisualStyleBackColor = true;
this.clearLogBtn.Click += new System.EventHandler(this.clearLogBtn_Click); this.clearLogBtn.Click += new System.EventHandler(this.clearLogBtn_Click);
// //
// counterTimer
//
this.counterTimer.Interval = 950;
this.counterTimer.Tick += new System.EventHandler(this.CounterTimer_Tick);
//
// ProcessQueueControl // ProcessQueueControl
// //
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F); this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
@ -377,7 +370,6 @@
private System.Windows.Forms.Panel panel3; private System.Windows.Forms.Panel panel3;
private System.Windows.Forms.Panel panel4; private System.Windows.Forms.Panel panel4;
private System.Windows.Forms.ToolStripStatusLabel runningTimeLbl; private System.Windows.Forms.ToolStripStatusLabel runningTimeLbl;
private System.Windows.Forms.Timer counterTimer;
private System.Windows.Forms.DataGridView logDGV; private System.Windows.Forms.DataGridView logDGV;
private System.Windows.Forms.DataGridViewTextBoxColumn timestampColumn; private System.Windows.Forms.DataGridViewTextBoxColumn timestampColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn logEntryColumn; private System.Windows.Forms.DataGridViewTextBoxColumn logEntryColumn;

View File

@ -1,49 +1,27 @@
using System; using LibationFileManager;
using System.Collections.Generic; using LibationUiBase;
using System;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using ApplicationServices;
using LibationFileManager;
using LibationUiBase;
namespace LibationWinForms.ProcessQueue #nullable enable
namespace LibationWinForms.ProcessQueue;
internal partial class ProcessQueueControl : UserControl
{ {
internal partial class ProcessQueueControl : UserControl, ILogForm public ProcessQueueViewModel ViewModel { get; } = new();
public ToolStripButton PopoutButton { get; } = new()
{ {
private TrackedQueue<ProcessBook> Queue = new(); DisplayStyle = ToolStripItemDisplayStyle.Text,
private readonly LogMe Logger; Name = nameof(PopoutButton),
private int QueuedCount Text = "Pop Out",
{ TextAlign = ContentAlignment.MiddleCenter,
set Alignment = ToolStripItemAlignment.Right,
{ Anchor = AnchorStyles.Bottom | AnchorStyles.Right,
queueNumberLbl.Text = value.ToString(); };
queueNumberLbl.Visible = value > 0;
}
}
private int ErrorCount
{
set
{
errorNumberLbl.Text = value.ToString();
errorNumberLbl.Visible = value > 0;
}
}
private int CompletedCount
{
set
{
completedNumberLbl.Text = value.ToString();
completedNumberLbl.Visible = value > 0;
}
}
public Task QueueRunner { get; private set; }
public bool Running => !QueueRunner?.IsCompleted ?? false;
public ToolStripButton popoutBtn = new();
public ProcessQueueControl() public ProcessQueueControl()
{ {
@ -51,369 +29,130 @@ namespace LibationWinForms.ProcessQueue
var speedLimitMBps = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024; var speedLimitMBps = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
numericUpDown1.Value = speedLimitMBps > numericUpDown1.Maximum || speedLimitMBps < numericUpDown1.Minimum ? 0 : speedLimitMBps; numericUpDown1.Value = speedLimitMBps > numericUpDown1.Maximum || speedLimitMBps < numericUpDown1.Minimum ? 0 : speedLimitMBps;
statusStrip1.Items.Add(PopoutButton);
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);
Logger = LogMe.RegisterForm(this);
virtualFlowControl2.RequestData += VirtualFlowControl1_RequestData;
virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked; virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked;
Queue.QueuededCountChanged += Queue_QueuededCountChanged; ViewModel.LogWritten += (_, text) => WriteLine(text);
Queue.CompletedCountChanged += Queue_CompletedCountChanged; ViewModel.PropertyChanged += ProcessQueue_PropertyChanged;
virtualFlowControl2.Items = ViewModel.Items;
Load += ProcessQueueControl_Load; Load += ProcessQueueControl_Load;
} }
private void ProcessQueueControl_Load(object sender, EventArgs e) private void ProcessQueueControl_Load(object? sender, EventArgs e)
{ {
if (DesignMode) return; if (DesignMode) return;
ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null));
runningTimeLbl.Text = string.Empty;
QueuedCount = 0;
ErrorCount = 0;
CompletedCount = 0;
}
private bool isBookInQueue(DataLayer.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(DataLayer.LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBook entry
&& entry.Status is ProcessBookStatus.Completed
&& Queue.RemoveCompleted(entry);
public void AddDownloadPdf(DataLayer.LibraryBook libraryBook)
=> AddDownloadPdf(new List<DataLayer.LibraryBook>() { libraryBook });
public void AddDownloadDecrypt(DataLayer.LibraryBook libraryBook)
=> AddDownloadDecrypt(new List<DataLayer.LibraryBook>() { libraryBook });
public void AddConvertMp3(DataLayer.LibraryBook libraryBook)
=> AddConvertMp3(new List<DataLayer.LibraryBook>() { libraryBook });
public void AddDownloadPdf(IEnumerable<DataLayer.LibraryBook> entries)
{
List<ProcessBook> procs = new();
foreach (var entry in entries)
{
if (isBookInQueue(entry))
continue;
ProcessBook pbook = new(entry, Logger);
pbook.PropertyChanged += Pbook_DataAvailable;
pbook.AddDownloadPdf();
procs.Add(pbook);
}
Serilog.Log.Logger.Information("Queueing {count} books", procs.Count);
AddToQueue(procs);
}
public void AddDownloadDecrypt(IEnumerable<DataLayer.LibraryBook> entries)
{
List<ProcessBook> procs = new();
foreach (var entry in entries)
{
if (isBookInQueue(entry))
continue;
ProcessBook pbook = new(entry, Logger);
pbook.PropertyChanged += Pbook_DataAvailable;
pbook.AddDownloadDecryptBook();
pbook.AddDownloadPdf();
procs.Add(pbook);
}
Serilog.Log.Logger.Information("Queueing {count} books", procs.Count);
AddToQueue(procs);
}
public void AddConvertMp3(IEnumerable<DataLayer.LibraryBook> entries)
{
List<ProcessBook> procs = new();
foreach (var entry in entries)
{
if (isBookInQueue(entry))
continue;
ProcessBook pbook = new(entry, Logger);
pbook.PropertyChanged += Pbook_DataAvailable;
pbook.AddConvertToMp3();
procs.Add(pbook);
}
Serilog.Log.Logger.Information("Queueing {count} books", procs.Count);
AddToQueue(procs);
}
private void AddToQueue(IEnumerable<ProcessBook> pbook)
{
BeginInvoke(() =>
{
Queue.Enqueue(pbook);
if (!Running)
QueueRunner = QueueLoop();
});
}
DateTime StartingTime;
private async Task QueueLoop()
{
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) public void WriteLine(string text)
{ {
if (IsDisposed) return; if (!IsDisposed)
logDGV.Rows.Add(DateTime.Now, text.Trim());
var timeStamp = DateTime.Now;
Invoke(() => logDGV.Rows.Add(timeStamp, text.Trim()));
} }
#region Control event handlers private async void cancelAllBtn_Click(object? sender, EventArgs e)
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); ViewModel.Queue.ClearQueue();
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); if (ViewModel.Queue.Current is not null)
await ViewModel.Queue.Current.CancelAsync();
ErrorCount = errCount; virtualFlowControl2.RefreshDisplay();
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) private void btnClearFinished_Click(object? sender, EventArgs e)
{ {
Queue.ClearQueue(); ViewModel.Queue.ClearCompleted();
if (Queue.Current is not null) virtualFlowControl2.RefreshDisplay();
await Queue.Current.CancelAsync();
virtualFlowControl2.VirtualControlCount = Queue.Count;
UpdateAllControls();
}
private void btnClearFinished_Click(object sender, EventArgs e) if (!ViewModel.Running)
{
Queue.ClearCompleted();
virtualFlowControl2.VirtualControlCount = Queue.Count;
UpdateAllControls();
if (!Running)
runningTimeLbl.Text = string.Empty; runningTimeLbl.Text = string.Empty;
} }
private void CounterTimer_Tick(object sender, EventArgs e) private void clearLogBtn_Click(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(); logDGV.Rows.Clear();
} }
private void LogCopyBtn_Click(object sender, EventArgs e) private void LogCopyBtn_Click(object? sender, EventArgs e)
{ {
string logText = string.Join("\r\n", logDGV.Rows.Cast<DataGridViewRow>().Select(r => $"{r.Cells[0].Value}\t{r.Cells[1].Value}")); string logText = string.Join("\r\n", logDGV.Rows.Cast<DataGridViewRow>().Select(r => $"{r.Cells[0].Value}\t{r.Cells[1].Value}"));
Clipboard.SetDataObject(logText, false, 5, 150); Clipboard.SetDataObject(logText, false, 5, 150);
} }
private void LogDGV_Resize(object sender, EventArgs e) private void LogDGV_Resize(object? sender, EventArgs e)
{ {
logDGV.Columns[1].Width = logDGV.Width - logDGV.Columns[0].Width; logDGV.Columns[1].Width = logDGV.Width - logDGV.Columns[0].Width;
} }
#endregion
#region View-Model update event handling #region View-Model update event handling
/// <summary> private void ProcessQueue_PropertyChanged(object? sender, PropertyChangedEventArgs e)
/// Index of the first <see cref="ProcessBook"/> visible in the <see cref="VirtualFlowControl"/>
/// </summary>
private int FirstVisible = 0;
/// <summary>
/// Number of <see cref="ProcessBook"/> visible in the <see cref="VirtualFlowControl"/>
/// </summary>
private int NumVisible = 0;
/// <summary>
/// Controls displaying the <see cref="ProcessBook"/> state, starting with <see cref="FirstVisible"/>
/// </summary>
private IReadOnlyList<ProcessBookControl> Panels;
/// <summary>
/// Updates the display of a single <see cref="ProcessBookControl"/> at <paramref name="queueIndex"/> within <see cref="Queue"/>
/// </summary>
/// <param name="queueIndex">index of the <see cref="ProcessBook"/> within the <see cref="Queue"/></param>
/// <param name="propertyName">The nme of the property that needs updating. If null, all properties are updated.</param>
private void UpdateControl(int queueIndex, string propertyName = null)
{ {
try if (e.PropertyName is null or nameof(ViewModel.QueuedCount))
{ {
int i = queueIndex - FirstVisible; queueNumberLbl.Text = ViewModel.QueuedCount.ToString();
queueNumberLbl.Visible = ViewModel.QueuedCount > 0;
if (i > NumVisible || i < 0) return; virtualFlowControl2.RefreshDisplay();
var proc = Queue[queueIndex];
Invoke(() =>
{
Panels[i].SuspendLayout();
if (propertyName is null or nameof(proc.Cover))
Panels[i].SetCover(proc.Cover);
if (propertyName is null or nameof(proc.BookText))
Panels[i].SetBookInfo(proc.BookText);
if (proc.Result != ProcessBookResult.None)
{
Panels[i].SetResult(proc.Result);
return;
} }
if (e.PropertyName is null or nameof(ViewModel.ErrorCount))
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."); errorNumberLbl.Text = ViewModel.ErrorCount.ToString();
errorNumberLbl.Visible = ViewModel.ErrorCount > 0;
}
if (e.PropertyName is null or nameof(ViewModel.CompletedCount))
{
completedNumberLbl.Text = ViewModel.CompletedCount.ToString();
completedNumberLbl.Visible = ViewModel.CompletedCount > 0;
}
if (e.PropertyName is null or nameof(ViewModel.Progress))
{
toolStripProgressBar1.Maximum = ViewModel.Queue.Count;
toolStripProgressBar1.Value = ViewModel.Queue.Completed.Count;
}
if (e.PropertyName is null or nameof(ViewModel.ProgressBarVisible))
{
toolStripProgressBar1.Visible = ViewModel.ProgressBarVisible;
}
if (e.PropertyName is null or nameof(ViewModel.RunningTime))
{
runningTimeLbl.Text = ViewModel.RunningTime;
} }
} }
private void UpdateAllControls()
{
int numToShow = Math.Min(NumVisible, Queue.Count - FirstVisible);
for (int i = 0; i < numToShow; i++)
UpdateControl(FirstVisible + i);
}
/// <summary> /// <summary>
/// View notified the model that a botton was clicked /// View notified the model that a botton was clicked
/// </summary> /// </summary>
/// <param name="queueIndex">index of the <see cref="ProcessBook"/> within <see cref="Queue"/></param> /// <param name="sender">the <see cref="ProcessBookControl"/> whose button was clicked</param>
/// <param name="panelClicked">The clicked control to update</param> /// <param name="buttonName">The name of the button clicked</param>
private async void VirtualFlowControl2_ButtonClicked(int queueIndex, string buttonName, ProcessBookControl panelClicked) private async void VirtualFlowControl2_ButtonClicked(object? sender, string buttonName)
{ {
if (sender is not ProcessBookControl control || control.Context is not ProcessBookViewModel item)
return;
try try
{ {
ProcessBook item = Queue[queueIndex]; if (buttonName is nameof(ProcessBookControl.cancelBtn))
if (buttonName == nameof(panelClicked.cancelBtn))
{ {
if (item is not null)
await item.CancelAsync(); await item.CancelAsync();
Queue.RemoveQueued(item); ViewModel.Queue.RemoveQueued(item);
virtualFlowControl2.VirtualControlCount = Queue.Count; virtualFlowControl2.RefreshDisplay();
} }
else if (buttonName == nameof(panelClicked.moveFirstBtn)) else
{ {
Queue.MoveQueuePosition(item, QueuePosition.Fisrt); QueuePosition? position = buttonName switch
UpdateAllControls(); {
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.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) catch(Exception ex)
@ -422,29 +161,9 @@ This error appears to be caused by a temporary interruption of service that some
} }
} }
/// <summary>
/// View needs updating
/// </summary>
private void VirtualFlowControl1_RequestData(int firstIndex, int numVisible, IReadOnlyList<ProcessBookControl> panelsToFill)
{
FirstVisible = firstIndex;
NumVisible = numVisible;
Panels = panelsToFill;
UpdateAllControls();
}
/// <summary>
/// Model updates the view
/// </summary>
private void Pbook_DataAvailable(object sender, PropertyChangedEventArgs e)
{
int index = Queue.IndexOf((ProcessBook)sender);
UpdateControl(index, e.PropertyName);
}
#endregion #endregion
private void numericUpDown1_ValueChanged(object sender, EventArgs e) private void numericUpDown1_ValueChanged(object? sender, EventArgs e)
{ {
var newValue = (long)(numericUpDown1.Value * 1024 * 1024); var newValue = (long)(numericUpDown1.Value * 1024 * 1024);
@ -466,9 +185,10 @@ This error appears to be caused by a temporary interruption of service that some
: numericUpDown1.Value >= 1 ? 1 : numericUpDown1.Value >= 1 ? 1
: 2; : 2;
} }
} }
public class NumericUpDownSuffix : NumericUpDown
{ public class NumericUpDownSuffix : NumericUpDown
{
[Description("Suffix displayed after numeric value."), Category("Data")] [Description("Suffix displayed after numeric value."), Category("Data")]
[Browsable(true)] [Browsable(true)]
[EditorBrowsable(EditorBrowsableState.Always)] [EditorBrowsable(EditorBrowsableState.Always)]
@ -484,6 +204,8 @@ This error appears to be caused by a temporary interruption of service that some
} }
} }
private string _suffix = string.Empty; private string _suffix = string.Empty;
[AllowNull]
public override string Text public override string Text
{ {
get => string.IsNullOrEmpty(Suffix) ? base.Text : base.Text.Replace(Suffix, string.Empty); get => string.IsNullOrEmpty(Suffix) ? base.Text : base.Text.Replace(Suffix, string.Empty);
@ -495,5 +217,4 @@ This error appears to be caused by a temporary interruption of service that some
base.Text = value + Suffix; base.Text = value + Suffix;
} }
} }
}
} }

View File

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

View File

@ -1,44 +1,42 @@
using System; using LibationUiBase.ProcessQueue;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Windows.Forms; using System.Windows.Forms;
namespace LibationWinForms.ProcessQueue namespace LibationWinForms.ProcessQueue
{ {
internal delegate void RequestDataDelegate(int queueIndex, int numVisible, IReadOnlyList<ProcessBookControl> panelsToFill);
internal delegate void ControlButtonClickedDelegate(int queueIndex, string buttonName, ProcessBookControl panelClicked);
internal partial class VirtualFlowControl : UserControl internal partial class VirtualFlowControl : UserControl
{ {
/// <summary>
/// Triggered when the <see cref="VirtualFlowControl"/> needs to update the displayed <see cref="ProcessBookControl"/>s
/// </summary>
public event RequestDataDelegate RequestData;
/// <summary> /// <summary>
/// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked /// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked
/// </summary> /// </summary>
public event ControlButtonClickedDelegate ButtonClicked; public event EventHandler<string> ButtonClicked;
private List<ProcessBookViewModelBase> m_Items;
public List<ProcessBookViewModelBase> Items
{
get => m_Items;
set
{
m_Items = value;
if (m_Items is not null)
RefreshDisplay();
}
}
public void RefreshDisplay()
{
AdjustScrollBar();
DoVirtualScroll();
}
#region Dynamic Properties #region Dynamic Properties
/// <summary> /// <summary>
/// The number of virtual <see cref="ProcessBookControl"/>s in the <see cref="VirtualFlowControl"/> /// The number of virtual <see cref="ProcessBookControl"/>s in the <see cref="VirtualFlowControl"/>
/// </summary> /// </summary>
public int VirtualControlCount public int VirtualControlCount => Items?.Count ?? 0;
{
get => _virtualControlCount;
set
{
if (_virtualControlCount == 0)
vScrollBar1.Value = 0;
_virtualControlCount = value;
AdjustScrollBar();
DoVirtualScroll();
}
}
private int _virtualControlCount;
int ScrollValue => Math.Max(vScrollBar1.Value, 0); int ScrollValue => Math.Max(vScrollBar1.Value, 0);
/// <summary> /// <summary>
@ -100,12 +98,7 @@ namespace LibationWinForms.ProcessQueue
{ {
InitializeComponent(); InitializeComponent();
panel1.Resize += (_, _) => panel1.Resize += (_, _) => RefreshDisplay();
{
AdjustScrollBar();
DoVirtualScroll();
};
var control = InitControl(0); var control = InitControl(0);
VirtualControlHeight = this.DpiUnscale(control.Height + control.Margin.Top + control.Margin.Bottom); VirtualControlHeight = this.DpiUnscale(control.Height + control.Margin.Top + control.Margin.Bottom);
@ -151,9 +144,7 @@ namespace LibationWinForms.ProcessQueue
while (form is not ProcessBookControl) while (form is not ProcessBookControl)
form = form.Parent; form = form.Parent;
int clickedIndex = BookControls.IndexOf((ProcessBookControl)form); ButtonClicked?.Invoke(form, button.Name);
ButtonClicked?.Invoke(FirstVisibleVirtualIndex + clickedIndex, button.Name, BookControls[clickedIndex]);
} }
/// <summary> /// <summary>
@ -174,19 +165,20 @@ namespace LibationWinForms.ProcessQueue
else else
{ {
vScrollBar1.Enabled = true; vScrollBar1.Enabled = true;
vScrollBar1.LargeChange = LargeScrollChange;
//https://stackoverflow.com/a/2882878/3335599 //https://stackoverflow.com/a/2882878/3335599
int newMaximum = VirtualHeight + vScrollBar1.LargeChange - 1; int newMaximum = VirtualHeight + LargeScrollChange - 1;
if (newMaximum < vScrollBar1.Maximum) if (newMaximum < vScrollBar1.Maximum)
vScrollBar1.Value = Math.Max(vScrollBar1.Value - (vScrollBar1.Maximum - newMaximum), 0); vScrollBar1.Value = Math.Max(vScrollBar1.Value - (vScrollBar1.Maximum - newMaximum), 0);
vScrollBar1.Maximum = newMaximum; vScrollBar1.Maximum = newMaximum;
vScrollBar1.LargeChange = LargeScrollChange;
} }
} }
/// <summary> /// <summary>
/// Calculated the virtual controls that are in view at the currrent scroll position and windows size, /// Calculated the virtual controls that are in view at the currrent scroll position and windows size,
/// positions <see cref="panel1"/> to simulate scroll activity, then fires <see cref="RequestData"/> to notify the model to update all visible controls /// positions <see cref="panel1"/> to simulate scroll activity, then fires updates the controls with
/// the context corresponding to the virtual scroll position
/// </summary> /// </summary>
private void DoVirtualScroll() private void DoVirtualScroll()
{ {
@ -203,11 +195,16 @@ namespace LibationWinForms.ProcessQueue
numVisible = Math.Min(numVisible, VirtualControlCount); numVisible = Math.Min(numVisible, VirtualControlCount);
numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible); numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible);
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++) for (int i = 0; i < BookControls.Count; i++)
{
BookControls[i].Visible = i < numVisible; BookControls[i].Visible = i < numVisible;
} }
}
/// <summary> /// <summary>
/// Set scroll value to an integral multiple of <see cref="SmallScrollChange"/> /// Set scroll value to an integral multiple of <see cref="SmallScrollChange"/>

View File

@ -22,6 +22,7 @@ namespace LibationWinForms
static void Main() static void Main()
{ {
Task<List<LibraryBook>> libraryLoadTask; Task<List<LibraryBook>> libraryLoadTask;
try try
{ {
//// Uncomment to see Console. Must be called before anything writes to Console. //// Uncomment to see Console. Must be called before anything writes to Console.
@ -86,7 +87,31 @@ namespace LibationWinForms
var form1 = new Form1(); var form1 = new Form1();
form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask); form1.Load += async (_, _) => await form1.InitLibraryAsync(await libraryLoadTask);
LibationUiBase.Forms.MessageBoxBase.ShowAsyncImpl = ShowMessageBox;
Application.Run(form1); Application.Run(form1);
#region Message Box Handler for LibationUiBase
Task<LibationUiBase.Forms.DialogResult> 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) private static void RunInstaller(Configuration config)

View File

@ -26,7 +26,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3240.44" /> <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3351.48" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -6,10 +6,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" /> <PackageReference Include="FluentAssertions" Version="8.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" /> <PackageReference Include="MSTest.TestAdapter" Version="3.9.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" /> <PackageReference Include="MSTest.TestFramework" Version="3.9.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -6,10 +6,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" /> <PackageReference Include="FluentAssertions" Version="8.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" /> <PackageReference Include="MSTest.TestAdapter" Version="3.9.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" /> <PackageReference Include="MSTest.TestFramework" Version="3.9.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -7,10 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" /> <PackageReference Include="FluentAssertions" Version="8.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" /> <PackageReference Include="MSTest.TestAdapter" Version="3.9.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" /> <PackageReference Include="MSTest.TestFramework" Version="3.9.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -7,10 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" /> <PackageReference Include="FluentAssertions" Version="8.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" /> <PackageReference Include="MSTest.TestAdapter" Version="3.9.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" /> <PackageReference Include="MSTest.TestFramework" Version="3.9.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -117,7 +117,6 @@ namespace TemplatesTests
[DataRow("<bitrate> 42 <bitrate>", "", "", "1 8 1 8")] [DataRow("<bitrate> 42 <bitrate>", "", "", "1 8 1 8")]
[DataRow(" <bitrate> - <bitrate> ", "", "", "1 8 - 1 8")] [DataRow(" <bitrate> - <bitrate> ", "", "", "1 8 - 1 8")]
[DataRow("4<bitrate> - <bitrate> 4", "", "", "1 8 - 1 8")] [DataRow("4<bitrate> - <bitrate> 4", "", "", "1 8 - 1 8")]
[DataRow("4<bitrate> - <bitrate> 4", "", "", "1 8 - 1 8")]
[DataRow("<channels><channels><samplerate><channels><channels>", "", "", "100")] [DataRow("<channels><channels><samplerate><channels><channels>", "", "", "100")]
[DataRow(" <channels> <channels> <samplerate> <channels> <channels>", "", "", "100")] [DataRow(" <channels> <channels> <samplerate> <channels> <channels>", "", "", "100")]
[DataRow(" <channels> - <channels> <samplerate> <channels> - <channels>", "", "", "- 100 -")] [DataRow(" <channels> - <channels> <samplerate> <channels> - <channels>", "", "", "- 100 -")]

View File

@ -7,10 +7,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" /> <PackageReference Include="FluentAssertions" Version="8.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" /> <PackageReference Include="MSTest.TestAdapter" Version="3.9.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" /> <PackageReference Include="MSTest.TestFramework" Version="3.9.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4"> <PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@ -61,9 +61,6 @@ namespace SearchEngineTests
[DataRow("AudibleProductId:B000000123", "audibleproductid:b000000123")] [DataRow("AudibleProductId:B000000123", "audibleproductid:b000000123")]
[DataRow("ProductId:B000000123", "productid:b000000123")] [DataRow("ProductId:B000000123", "productid:b000000123")]
// bool keyword. Append :True
[DataRow("israted", "israted:True")]
// bool keyword with [:bool]. Do not add :True // bool keyword with [:bool]. Do not add :True
[DataRow("israted:True", "israted:True")] [DataRow("israted:True", "israted:True")]
[DataRow("isRated:false", "israted:false")] [DataRow("isRated:false", "israted:false")]