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

View File

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

View File

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

View File

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

View File

@ -290,33 +290,24 @@ namespace AppScaffolding
public static UpgradeProperties GetLatestRelease()
{
// timed out
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
(var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null || zip is null)
return null;
var latestVersionString = latest.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var latestRelease))
return null;
// we're up to date
if (latestRelease <= BuildVersion)
if (version is null || latest is null || zip is null)
return null;
// we have an update
var zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new
{
latestRelease = latestRelease.ToString(),
latestRelease = version.ToString(),
latest.HtmlUrl,
zipUrl
});
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body);
}
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
{
try
{
@ -330,15 +321,23 @@ namespace AppScaffolding
{
Log.Logger.Error(aggEx, "Checking for new version too often");
}
return (null, null);
return (null, null, null);
}
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
{
const string ownerAccount = "rmcrackan";
const string repoName = "Libation";
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
//Ensure that latest release is greater than the current version
var latestVersionString = latestRelease.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
return (null, null, null);
//Download the release index
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
@ -356,10 +355,7 @@ namespace AppScaffolding
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
}
}

View File

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

View File

@ -6,7 +6,7 @@
<ItemGroup>
<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>

View File

@ -12,12 +12,12 @@
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.1.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>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.4">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

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

View File

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

View File

@ -71,12 +71,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia" Version="11.3.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
<!--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 Include="Avalonia.ReactiveUI" Version="11.3.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@ -16,6 +16,8 @@ using Dinah.Core;
using LibationAvalonia.Themes;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using LibationUiBase.Forms;
using Avalonia.Controls;
#nullable enable
namespace LibationAvalonia
@ -42,6 +44,9 @@ namespace LibationAvalonia
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();

View File

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

View File

@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Dinah.Core;
using LibationFileManager;
using ReactiveUI;
@ -90,7 +91,7 @@ namespace LibationAvalonia.Controls
var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options);
directoryState.CustomDir = selectedFolders.SingleOrDefault()?.Path?.LocalPath ?? directoryState.CustomDir;
directoryState.CustomDir = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? directoryState.CustomDir;
}
private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ using DataLayer;
using Dinah.Core.Logging;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Dialogs;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Linq;
@ -13,54 +14,9 @@ using System.Threading.Tasks;
namespace LibationAvalonia
{
public enum DialogResult
{
None = 0,
OK = 1,
Cancel = 2,
Abort = 3,
Retry = 4,
Ignore = 5,
Yes = 6,
No = 7,
TryAgain = 10,
Continue = 11
}
public enum MessageBoxIcon
{
None = 0,
Error = 16,
Hand = 16,
Stop = 16,
Question = 32,
Exclamation = 48,
Warning = 48,
Asterisk = 64,
Information = 64
}
public enum MessageBoxButtons
{
OK,
OKCancel,
AbortRetryIgnore,
YesNoCancel,
YesNo,
RetryCancel,
CancelTryContinue
}
public enum MessageBoxDefaultButton
{
Button1,
Button2 = 256,
Button3 = 512,
}
public class MessageBox
{
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton)
=> ShowCoreAsync(null, text, caption, buttons, icon, defaultButton);
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);
public static Task<DialogResult> Show(string text)
=> 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)
=> ShowCoreAsync(owner, text, caption, buttons, icon, 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, saveAndRestorePosition);
public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon)
=> ShowCoreAsync(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1);
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)
=> ShowCoreAsync(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
public static async Task VerboseLoggingWarning_ShowIfTrue()
{
// when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured
if (Serilog.Log.Logger.IsVerboseEnabled())
await Show(@"
Warning: verbose logging is enabled.
await Show("""
Warning: verbose logging is enabled.
This should be used for debugging only. It creates many
more logs and debug files, neither of which are as
strictly anonymous.
This should be used for debugging only. It creates many
more logs and debug files, neither of which are as
strictly anonymous.
When you are finished debugging, it's highly recommended
to set your debug MinimumLevel to Information and restart
Libation.
".Trim(), "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
When you are finished debugging, it's highly recommended
to set your debug MinimumLevel to Information and restart
Libation.
""", "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
/// <summary>
@ -138,7 +92,8 @@ Libation.
{
// for development and debugging, show me what broke!
if (System.Diagnostics.Debugger.IsAttached)
throw exception;
//Wrap the exception to preserve its stack trace.
throw new Exception("An unhandled exception was encountered", exception);
try
{
@ -152,12 +107,12 @@ Libation.
}
private static async Task<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;
var dialog = await Dispatcher.UIThread.InvokeAsync(() => CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition));
var dialog = CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
return await DisplayWindow(dialog, owner);
}
});
private static MessageBoxWindow CreateMessageBox(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
{
@ -175,7 +130,6 @@ Libation.
tbx.MinWidth = vm.TextBlockMinWidth;
tbx.Text = message;
var thisScreen = owner.Screens?.ScreenFromVisual(owner);
var maxSize
@ -229,6 +183,5 @@ Libation.
return await toDisplay.ShowDialog<DialogResult>(owner);
}
}
}
}

View File

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

View File

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

View File

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

View File

@ -4,6 +4,9 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using LibationUiBase.Forms;
using LibationUiBase;
using System.Collections.Generic;
#nullable enable
namespace LibationAvalonia.ViewModels
@ -12,19 +15,14 @@ namespace LibationAvalonia.ViewModels
{
public void Configure_Liberate() { }
public void BackupAllBooks()
public async Task BackupAllBooks()
{
try
{
var unliberated = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
if (ProcessQueue.QueueDownloadDecrypt(unliberated))
setQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all library books");
ProcessQueue.AddDownloadDecrypt(
DbContexts
.GetLibrary_Flat_NoTracking()
.UnLiberated()
);
}
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);
ProcessQueue.AddDownloadPdf(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated));
}
public async Task ConvertAllToMp3Async()
@ -48,12 +46,8 @@ namespace LibationAvalonia.ViewModels
"Convert all M4b => Mp3?",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning);
if (result == DialogResult.Yes)
{
if (result == DialogResult.Yes && ProcessQueue.QueueConvertToMp3(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
setQueueCollapseState(false);
ProcessQueue.AddConvertMp3(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is ContentType.Product));
}
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
}
private void setQueueCollapseState(bool collapsed)

View File

@ -1,10 +1,11 @@
using LibationFileManager;
using System;
using System.Linq;
using DataLayer;
using DataLayer;
using Dinah.Core;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia.ViewModels
@ -37,32 +38,12 @@ namespace LibationAvalonia.ViewModels
{
try
{
if (libraryBooks.Length == 1)
{
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);
if (ProcessQueue.QueueDownloadDecrypt(libraryBooks))
setQueueCollapseState(false);
ProcessQueue.AddDownloadDecrypt(item);
}
else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
{
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
setQueueCollapseState(false);
ProcessQueue.AddDownloadPdf(item);
}
else if (item.Book.Audio_Exists())
else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists())
{
// 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))
{
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)
{
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
{
setQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
ProcessQueue.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated());
if (ProcessQueue.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
setQueueCollapseState(false);
}
catch (Exception ex)
{
@ -110,13 +76,8 @@ namespace LibationAvalonia.ViewModels
{
try
{
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray();
if (preLiberated.Length > 0)
{
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
if (ProcessQueue.QueueConvertToMp3(libraryBooks))
setQueueCollapseState(false);
ProcessQueue.AddConvertMp3(preLiberated);
}
}
catch (Exception ex)
{

View File

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

View File

@ -1,420 +1,17 @@
using ApplicationServices;
using AudibleApi;
using AudibleApi.Common;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileLiberator;
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using LibationUiBase.ProcessQueue;
#nullable enable
namespace LibationAvalonia.ViewModels
namespace LibationAvalonia.ViewModels;
public class ProcessBookViewModel : ProcessBookViewModelBase
{
public enum ProcessBookResult
{
None,
Success,
Cancelled,
ValidationFail,
FailedRetry,
FailedSkip,
FailedAbort,
LicenseDenied,
LicenseDeniedPossibleOutage
}
public enum ProcessBookStatus
{
Queued,
Cancelled,
Working,
Completed,
Failed
}
public ProcessBookViewModel(LibraryBook libraryBook, LogMe logme) : base(libraryBook, logme) { }
/// <summary>
/// This is the viewmodel for queued processables
/// </summary>
public class ProcessBookViewModel : ViewModelBase
{
public event EventHandler? Completed;
protected override object? LoadImageFromBytes(byte[] bytes, PictureSize pictureSize)
=> AvaloniaUtils.TryLoadImageOrDefault(bytes, pictureSize);
public LibraryBook LibraryBook { get; private set; }
private ProcessBookResult _result = ProcessBookResult.None;
private ProcessBookStatus _status = ProcessBookStatus.Queued;
private string? _narrator;
private string? _author;
private string? _title;
private int _progress;
private string? _eta;
private Bitmap? _cover;
#region Properties exposed to the view
public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } }
public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } }
public string? Narrator { get => _narrator; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
public string? Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); }
public string? Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); }
public int Progress { get => _progress; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _progress, value)); }
public string? ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); }
public Bitmap? Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
public bool IsDownloading => Status is ProcessBookStatus.Working;
public bool Queued => Status is ProcessBookStatus.Queued;
public string StatusText => Result switch
{
ProcessBookResult.Success => "Finished",
ProcessBookResult.Cancelled => "Cancelled",
ProcessBookResult.ValidationFail => "Validation fail",
ProcessBookResult.FailedRetry => "Error, will retry later",
ProcessBookResult.FailedSkip => "Error, Skipping",
ProcessBookResult.FailedAbort => "Error, Abort",
ProcessBookResult.LicenseDenied => "License Denied",
ProcessBookResult.LicenseDeniedPossibleOutage => "Possible Service Interruption",
_ => Status.ToString(),
};
#endregion
private TimeSpan TimeRemaining { set { ETA = $"ETA: {value:mm\\:ss}"; } }
private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke();
private Processable? NextProcessable() => _currentProcessable = null;
private Processable? _currentProcessable;
private readonly Queue<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.Threading;
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
using LibationUiBase.ProcessQueue;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
namespace LibationAvalonia.ViewModels;
public record LogEntry(DateTime LogDate, string? LogMessage)
{
public string LogDateString => LogDate.ToShortTimeString();
}
public class ProcessQueueViewModel : ViewModelBase, ILogForm
public class ProcessQueueViewModel : ProcessQueueViewModelBase
{
public ProcessQueueViewModel() : base(CreateEmptyList())
{
public ObservableCollection<LogEntry> LogEntries { get; } = new();
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
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;
Items = Queue.UnderlyingList as AvaloniaList<ProcessBookViewModelBase>
?? throw new ArgumentNullException(nameof(Queue.UnderlyingList));
SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
}
private int _completedCount;
private int _errorCount;
private int _queuedCount;
private string? _runningTime;
private bool _progressBarVisible;
private decimal _speedLimit;
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
public string? RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
public bool AnyCompleted => CompletedCount > 0;
public bool AnyQueued => QueuedCount > 0;
public bool AnyErrors => ErrorCount > 0;
public double Progress => 100d * Queue.Completed.Count / Queue.Count;
public decimal SpeedLimitIncrement { get; private set; }
public ObservableCollection<LogEntry> LogEntries { get; } = new();
public AvaloniaList<ProcessBookViewModelBase> Items { get; }
public decimal SpeedLimit
{
@ -81,209 +54,21 @@ namespace LibationAvalonia.ViewModels
: _speedLimit > 1 ? 0.1m
: 0.01m;
Dispatcher.UIThread.Invoke(() =>
{
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
this.RaisePropertyChanged();
});
RaisePropertyChanged(nameof(SpeedLimitIncrement));
RaisePropertyChanged(nameof(SpeedLimit));
}
}
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);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
ErrorCount = errCount;
CompletedCount = completeCount;
Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress)));
}
private void Queue_QueuededCountChanged(object? sender, int cueCount)
{
QueuedCount = cueCount;
Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress)));
}
public void WriteLine(string text)
{
Dispatcher.UIThread.Invoke(() =>
LogEntries.Add(new()
{
LogDate = DateTime.Now,
LogMessage = text.Trim()
}));
}
#region Add Books to Queue
private bool isBookInQueue(LibraryBook libraryBook)
{
var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId);
if (entry == null)
return false;
else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed)
return !Queue.RemoveCompleted(entry);
else
return true;
}
public bool RemoveCompleted(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModel entry
&& entry.Status is ProcessBookStatus.Completed
&& Queue.RemoveCompleted(entry);
public void AddDownloadPdf(LibraryBook libraryBook)
=> AddDownloadPdf(new List<LibraryBook>() { 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; }
if (Design.IsDesignMode)
_ = Configuration.Instance.LibationFiles;
return new AvaloniaList<ProcessBookViewModelBase>();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
</PropertyGroup>
<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="Serilog.Exceptions" Version="8.4.0" />
</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.
//The reason this info is in here instead of GridEntry is because all of this info is needed
//for the "Liberate" column's display and sorting functions.
public abstract class EntryStatus : SynchronizeInvoker, IComparable, INotifyPropertyChanged
public abstract class EntryStatus : ReactiveObject, IComparable
{
public event PropertyChangedEventHandler PropertyChanged;
public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book);
public LiberatedStatus BookStatus
{
@ -81,8 +80,6 @@ namespace LibationUiBase.GridView
internal protected abstract object LoadImage(byte[] picture);
protected abstract object GetResourceImage(string rescName);
public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args));
public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
/// <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)
@ -104,7 +101,13 @@ namespace LibationUiBase.GridView
else if (!IsUnavailable && second.IsUnavailable) return -1;
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
else return BookStatus.CompareTo(second.BookStatus);
var statusCompare = BookStatus.CompareTo(second.BookStatus);
if (statusCompare != 0) return statusCompare;
else if (PdfStatus is null && second.PdfStatus is null) return 0;
else if (PdfStatus is null && second.PdfStatus is not null) return 1;
else if (PdfStatus is not null && second.PdfStatus is null) return -1;
else return PdfStatus.Value.CompareTo(second.PdfStatus.Value);
}
private object GetLiberateIcon()

View File

@ -22,7 +22,7 @@ namespace LibationUiBase.GridView
}
/// <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 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
#region Sorting
@ -228,16 +215,16 @@ namespace LibationUiBase.GridView
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(Rating), new ObjectComparer<Rating>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(EntryStatus), new ObjectComparer<EntryStatus>() },
{ typeof(SeriesOrder), new ObjectComparer<SeriesOrder>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
{ typeof(RemoveStatus), Comparer<RemoveStatus>.Default },
{ typeof(string), Comparer<string>.Default },
{ typeof(int), Comparer <int>.Default },
{ typeof(float), Comparer<float >.Default },
{ typeof(bool), Comparer<bool>.Default },
{ typeof(Rating), Comparer<Rating>.Default },
{ typeof(DateTime), Comparer<DateTime>.Default },
{ typeof(EntryStatus), Comparer<EntryStatus>.Default },
{ typeof(SeriesOrder), Comparer<SeriesOrder>.Default },
{ typeof(LastDownloadStatus), Comparer<LastDownloadStatus>.Default },
};
#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>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
</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.Linq;
#nullable enable
namespace LibationUiBase
{
public enum QueuePosition
@ -34,10 +35,10 @@ namespace LibationUiBase
*/
public class TrackedQueue<T> where T : class
{
public event EventHandler<int> CompletedCountChanged;
public event EventHandler<int> QueuededCountChanged;
public event EventHandler<int>? CompletedCountChanged;
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> Completed => _completed;
@ -46,9 +47,10 @@ namespace LibationUiBase
private readonly List<T> _completed = 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;
}
@ -113,7 +115,7 @@ namespace LibationUiBase
if (itemsRemoved)
{
QueuededCountChanged?.Invoke(this, queuedCount);
QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary();
}
return itemsRemoved;
@ -149,7 +151,7 @@ namespace LibationUiBase
{
lock (lockObject)
_queued.Clear();
QueuededCountChanged?.Invoke(this, 0);
QueuedCountChanged?.Invoke(this, 0);
RebuildSecondary();
}
@ -169,7 +171,7 @@ namespace LibationUiBase
}
}
public T FirstOrDefault(Func<T, bool> predicate)
public T? FirstOrDefault(Func<T, bool> predicate)
{
lock (lockObject)
{
@ -246,7 +248,7 @@ namespace LibationUiBase
{
if (completedChanged)
CompletedCountChanged?.Invoke(this, completedCount);
QueuededCountChanged?.Invoke(this, queuedCount);
QueuedCountChanged?.Invoke(this, queuedCount);
RebuildSecondary();
}
}
@ -261,7 +263,7 @@ namespace LibationUiBase
}
foreach (var i in item)
_underlyingList?.Add(i);
QueuededCountChanged?.Invoke(this, queueCount);
QueuedCountChanged?.Invoke(this, queueCount);
}
private void RebuildSecondary()

View File

@ -1,4 +1,5 @@
using DataLayer;
using LibationUiBase;
using System;
using System.Linq;
using System.Threading.Tasks;
@ -11,19 +12,13 @@ namespace LibationWinForms
private void Configure_Liberate() { }
//GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread
private void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null)
private async void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null)
{
try
{
var unliberated = await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated))
SetQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all library books");
processBookQueue1.AddDownloadDecrypt(
ApplicationServices.DbContexts
.GetLibrary_Flat_NoTracking()
.UnLiberated()
);
}
catch (Exception ex)
{
@ -33,9 +28,8 @@ namespace LibationWinForms
private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e)
{
if (processBookQueue1.ViewModel.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking())))
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)
@ -48,13 +42,8 @@ namespace LibationWinForms
"Convert all M4b => Mp3?",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning);
if (result == DialogResult.Yes)
{
if (result == DialogResult.Yes && processBookQueue1.ViewModel.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking())))
SetQueueCollapseState(false);
await Task.Run(() => processBookQueue1.AddConvertMp3(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()
.Where(lb => lb.Book.UserDefinedItem.BookStatus is DataLayer.LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product)));
}
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
}
}
}

View File

@ -1,6 +1,8 @@
using DataLayer;
using Dinah.Core;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using LibationUiBase.GridView;
using LibationWinForms.ProcessQueue;
using System;
@ -14,7 +16,7 @@ namespace LibationWinForms
int WidthChange = 0;
private void Configure_ProcessQueue()
{
processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut;
processBookQueue1.PopoutButton.Click += ProcessBookQueue1_PopOut;
WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth;
int width = this.Width;
@ -27,31 +29,12 @@ namespace LibationWinForms
{
try
{
if (libraryBooks.Length == 1)
{
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);
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks))
SetQueueCollapseState(false);
processBookQueue1.AddDownloadDecrypt(item);
}
else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
{
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
SetQueueCollapseState(false);
processBookQueue1.AddDownloadPdf(item);
}
else if (item.Book.Audio_Exists())
else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists())
{
// 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))
{
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)
{
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
{
SetQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
processBookQueue1.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated());
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
SetQueueCollapseState(false);
}
catch (Exception ex)
{
@ -99,13 +67,8 @@ namespace LibationWinForms
{
try
{
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray();
if (preLiberated.Length > 0)
{
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
if (processBookQueue1.ViewModel.QueueConvertToMp3(libraryBooks))
SetQueueCollapseState(false);
processBookQueue1.AddConvertMp3(preLiberated);
}
}
catch (Exception ex)
{
@ -124,10 +87,14 @@ namespace LibationWinForms
}
else if (!collapsed && splitContainer1.Panel2Collapsed)
{
if (!processBookQueue1.PopoutButton.Visible)
//Queue is in popout mode. Do nothing.
return;
Width += WidthChange;
splitContainer1.Panel2.Controls.Add(processBookQueue1);
splitContainer1.Panel2Collapsed = false;
processBookQueue1.popoutBtn.Visible = true;
processBookQueue1.PopoutButton.Visible = true;
}
Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed));
@ -147,7 +114,7 @@ namespace LibationWinForms
dockForm.FormClosing += DockForm_FormClosing;
splitContainer1.Panel2.Controls.Remove(processBookQueue1);
splitContainer1.Panel2Collapsed = true;
processBookQueue1.popoutBtn.Visible = false;
processBookQueue1.PopoutButton.Visible = false;
dockForm.PassControl(processBookQueue1);
dockForm.Show();
this.Width -= dockForm.WidthChange;
@ -164,7 +131,7 @@ namespace LibationWinForms
this.Width += dockForm.WidthChange;
splitContainer1.Panel2.Controls.Add(dockForm.RegainControl());
splitContainer1.Panel2Collapsed = false;
processBookQueue1.popoutBtn.Visible = true;
processBookQueue1.PopoutButton.Visible = true;
dockForm.SaveSizeAndLocation(Configuration.Instance);
this.Focus();
toggleQueueHideBtn.Visible = true;

View File

@ -5,6 +5,7 @@ using System.Windows.Forms;
using ApplicationServices;
using DataLayer;
using Dinah.Core.Threading;
using LibationUiBase;
using LibationWinForms.Dialogs;
namespace LibationWinForms
@ -25,9 +26,17 @@ namespace LibationWinForms
}
private async void setLiberatedVisibleMenuItemAsync(object _, object __)
=> await Task.Run(setLiberatedVisibleMenuItem);
private static DateTime lastVisibleCountUpdated;
void setLiberatedVisibleMenuItem()
{
//Assume that all calls to update arrive in order,
//Only display results of the latest book count.
var updaterTime = lastVisibleCountUpdated = DateTime.UtcNow;
var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible());
if (updaterTime < lastVisibleCountUpdated)
return;
this.UIThreadSync(() =>
{
if (libraryStats.HasPendingBooks)
@ -50,15 +59,8 @@ namespace LibationWinForms
{
try
{
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray()))
SetQueueCollapseState(false);
Serilog.Log.Logger.Information("Begin backing up visible library books");
processBookQueue1.AddDownloadDecrypt(
productsDisplay
.GetVisible()
.UnLiberated()
);
}
catch (Exception ex)
{

View File

@ -23,7 +23,8 @@ namespace LibationWinForms
{
// for development and debugging, show me what broke!
if (System.Diagnostics.Debugger.IsAttached)
throw exception;
//Wrap the exception to preserve its stack trace.
throw new Exception("An unhandled exception was encountered", exception);
try
{

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

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

View File

@ -1,49 +1,27 @@
using System;
using System.Collections.Generic;
using LibationFileManager;
using LibationUiBase;
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using LibationFileManager;
using LibationUiBase;
namespace LibationWinForms.ProcessQueue
#nullable enable
namespace LibationWinForms.ProcessQueue;
internal partial class ProcessQueueControl : UserControl
{
internal partial class ProcessQueueControl : UserControl, ILogForm
public ProcessQueueViewModel ViewModel { get; } = new();
public ToolStripButton PopoutButton { get; } = new()
{
private TrackedQueue<ProcessBook> Queue = new();
private readonly LogMe Logger;
private int QueuedCount
{
set
{
queueNumberLbl.Text = value.ToString();
queueNumberLbl.Visible = value > 0;
}
}
private int ErrorCount
{
set
{
errorNumberLbl.Text = value.ToString();
errorNumberLbl.Visible = value > 0;
}
}
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();
DisplayStyle = ToolStripItemDisplayStyle.Text,
Name = nameof(PopoutButton),
Text = "Pop Out",
TextAlign = ContentAlignment.MiddleCenter,
Alignment = ToolStripItemAlignment.Right,
Anchor = AnchorStyles.Bottom | AnchorStyles.Right,
};
public ProcessQueueControl()
{
@ -51,369 +29,130 @@ namespace LibationWinForms.ProcessQueue
var speedLimitMBps = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
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;
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
ViewModel.LogWritten += (_, text) => WriteLine(text);
ViewModel.PropertyChanged += ProcessQueue_PropertyChanged;
virtualFlowControl2.Items = ViewModel.Items;
Load += ProcessQueueControl_Load;
}
private void ProcessQueueControl_Load(object sender, EventArgs e)
private void ProcessQueueControl_Load(object? sender, EventArgs e)
{
if (DesignMode) return;
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");
}
ProcessQueue_PropertyChanged(this, new PropertyChangedEventArgs(null));
}
public void WriteLine(string text)
{
if (IsDisposed) return;
var timeStamp = DateTime.Now;
Invoke(() => logDGV.Rows.Add(timeStamp, text.Trim()));
if (!IsDisposed)
logDGV.Rows.Add(DateTime.Now, text.Trim());
}
#region Control event handlers
private void Queue_CompletedCountChanged(object sender, int e)
private async void cancelAllBtn_Click(object? sender, EventArgs e)
{
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
ErrorCount = errCount;
CompletedCount = completeCount;
UpdateProgressBar();
}
private void Queue_QueuededCountChanged(object sender, int cueCount)
{
QueuedCount = cueCount;
virtualFlowControl2.VirtualControlCount = Queue.Count;
UpdateProgressBar();
}
private void UpdateProgressBar()
{
toolStripProgressBar1.Maximum = Queue.Count;
toolStripProgressBar1.Value = Queue.Completed.Count;
ViewModel.Queue.ClearQueue();
if (ViewModel.Queue.Current is not null)
await ViewModel.Queue.Current.CancelAsync();
virtualFlowControl2.RefreshDisplay();
}
private async void cancelAllBtn_Click(object sender, EventArgs e)
private void btnClearFinished_Click(object? sender, EventArgs e)
{
Queue.ClearQueue();
if (Queue.Current is not null)
await Queue.Current.CancelAsync();
virtualFlowControl2.VirtualControlCount = Queue.Count;
UpdateAllControls();
}
ViewModel.Queue.ClearCompleted();
virtualFlowControl2.RefreshDisplay();
private void btnClearFinished_Click(object sender, EventArgs e)
{
Queue.ClearCompleted();
virtualFlowControl2.VirtualControlCount = Queue.Count;
UpdateAllControls();
if (!Running)
if (!ViewModel.Running)
runningTimeLbl.Text = string.Empty;
}
private void CounterTimer_Tick(object sender, EventArgs e)
{
string timeToStr(TimeSpan time)
{
string minsSecs = $"{time:mm\\:ss}";
if (time.TotalHours >= 1)
return $"{time.TotalHours:F0}:{minsSecs}";
return minsSecs;
}
if (Running)
runningTimeLbl.Text = timeToStr(DateTime.Now - StartingTime);
}
private void clearLogBtn_Click(object sender, EventArgs e)
private void clearLogBtn_Click(object? sender, EventArgs e)
{
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}"));
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;
}
#endregion
#region View-Model update event handling
/// <summary>
/// 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)
private void ProcessQueue_PropertyChanged(object? sender, PropertyChangedEventArgs e)
{
try
if (e.PropertyName is null or nameof(ViewModel.QueuedCount))
{
int i = queueIndex - FirstVisible;
if (i > NumVisible || i < 0) return;
var proc = Queue[queueIndex];
Invoke(() =>
{
Panels[i].SuspendLayout();
if (propertyName is null or nameof(proc.Cover))
Panels[i].SetCover(proc.Cover);
if (propertyName is null or nameof(proc.BookText))
Panels[i].SetBookInfo(proc.BookText);
if (proc.Result != ProcessBookResult.None)
{
Panels[i].SetResult(proc.Result);
return;
queueNumberLbl.Text = ViewModel.QueuedCount.ToString();
queueNumberLbl.Visible = ViewModel.QueuedCount > 0;
virtualFlowControl2.RefreshDisplay();
}
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)
if (e.PropertyName is null or nameof(ViewModel.ErrorCount))
{
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>
/// View notified the model that a botton was clicked
/// </summary>
/// <param name="queueIndex">index of the <see cref="ProcessBook"/> within <see cref="Queue"/></param>
/// <param name="panelClicked">The clicked control to update</param>
private async void VirtualFlowControl2_ButtonClicked(int queueIndex, string buttonName, ProcessBookControl panelClicked)
/// <param name="sender">the <see cref="ProcessBookControl"/> whose button was clicked</param>
/// <param name="buttonName">The name of the button clicked</param>
private async void VirtualFlowControl2_ButtonClicked(object? sender, string buttonName)
{
if (sender is not ProcessBookControl control || control.Context is not ProcessBookViewModel item)
return;
try
{
ProcessBook item = Queue[queueIndex];
if (buttonName == nameof(panelClicked.cancelBtn))
if (buttonName is nameof(ProcessBookControl.cancelBtn))
{
if (item is not null)
await item.CancelAsync();
Queue.RemoveQueued(item);
virtualFlowControl2.VirtualControlCount = Queue.Count;
ViewModel.Queue.RemoveQueued(item);
virtualFlowControl2.RefreshDisplay();
}
else if (buttonName == nameof(panelClicked.moveFirstBtn))
else
{
Queue.MoveQueuePosition(item, QueuePosition.Fisrt);
UpdateAllControls();
QueuePosition? position = buttonName switch
{
nameof(ProcessBookControl.moveFirstBtn) => QueuePosition.Fisrt,
nameof(ProcessBookControl.moveUpBtn) => QueuePosition.OneUp,
nameof(ProcessBookControl.moveDownBtn) => QueuePosition.OneDown,
nameof(ProcessBookControl.moveLastBtn) => QueuePosition.Last,
_ => null
};
if (position is not null)
{
ViewModel.Queue.MoveQueuePosition(item, position.Value);
virtualFlowControl2.RefreshDisplay();
}
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)
@ -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
private void numericUpDown1_ValueChanged(object sender, EventArgs e)
private void numericUpDown1_ValueChanged(object? sender, EventArgs e)
{
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
: 2;
}
}
public class NumericUpDownSuffix : NumericUpDown
{
}
public class NumericUpDownSuffix : NumericUpDown
{
[Description("Suffix displayed after numeric value."), Category("Data")]
[Browsable(true)]
[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;
[AllowNull]
public override string Text
{
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;
}
}
}
}

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.Drawing;
using System.Windows.Forms;
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
{
/// <summary>
/// Triggered when the <see cref="VirtualFlowControl"/> needs to update the displayed <see cref="ProcessBookControl"/>s
/// </summary>
public event RequestDataDelegate RequestData;
/// <summary>
/// Triggered when one of the <see cref="ProcessBookControl"/>'s buttons has been clicked
/// </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
/// <summary>
/// The number of virtual <see cref="ProcessBookControl"/>s in the <see cref="VirtualFlowControl"/>
/// </summary>
public int VirtualControlCount
{
get => _virtualControlCount;
set
{
if (_virtualControlCount == 0)
vScrollBar1.Value = 0;
_virtualControlCount = value;
AdjustScrollBar();
DoVirtualScroll();
}
}
private int _virtualControlCount;
public int VirtualControlCount => Items?.Count ?? 0;
int ScrollValue => Math.Max(vScrollBar1.Value, 0);
/// <summary>
@ -100,12 +98,7 @@ namespace LibationWinForms.ProcessQueue
{
InitializeComponent();
panel1.Resize += (_, _) =>
{
AdjustScrollBar();
DoVirtualScroll();
};
panel1.Resize += (_, _) => RefreshDisplay();
var control = InitControl(0);
VirtualControlHeight = this.DpiUnscale(control.Height + control.Margin.Top + control.Margin.Bottom);
@ -151,9 +144,7 @@ namespace LibationWinForms.ProcessQueue
while (form is not ProcessBookControl)
form = form.Parent;
int clickedIndex = BookControls.IndexOf((ProcessBookControl)form);
ButtonClicked?.Invoke(FirstVisibleVirtualIndex + clickedIndex, button.Name, BookControls[clickedIndex]);
ButtonClicked?.Invoke(form, button.Name);
}
/// <summary>
@ -174,19 +165,20 @@ namespace LibationWinForms.ProcessQueue
else
{
vScrollBar1.Enabled = true;
vScrollBar1.LargeChange = LargeScrollChange;
//https://stackoverflow.com/a/2882878/3335599
int newMaximum = VirtualHeight + vScrollBar1.LargeChange - 1;
int newMaximum = VirtualHeight + LargeScrollChange - 1;
if (newMaximum < vScrollBar1.Maximum)
vScrollBar1.Value = Math.Max(vScrollBar1.Value - (vScrollBar1.Maximum - newMaximum), 0);
vScrollBar1.Maximum = newMaximum;
vScrollBar1.LargeChange = LargeScrollChange;
}
}
/// <summary>
/// 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>
private void DoVirtualScroll()
{
@ -203,11 +195,16 @@ namespace LibationWinForms.ProcessQueue
numVisible = Math.Min(numVisible, VirtualControlCount);
numVisible = Math.Min(numVisible, VirtualControlCount - firstVisible);
RequestData?.Invoke(firstVisible, numVisible, BookControls);
for (int i = 0; i < numVisible; i++)
{
BookControls[i].Context = Items[firstVisible + i];
}
for (int i = 0; i < BookControls.Count; i++)
{
BookControls[i].Visible = i < numVisible;
}
}
/// <summary>
/// Set scroll value to an integral multiple of <see cref="SmallScrollChange"/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,10 +7,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" />
<PackageReference Include="FluentAssertions" Version="8.5.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.9.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.9.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<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> - <bitrate> ", "", "", "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 -")]

View File

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

View File

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