mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-03 03:18:27 -05:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0d7a7bf64 | ||
|
|
8bc098e7bd | ||
|
|
9280b29512 | ||
|
|
d8e9b9c505 | ||
|
|
554b308364 | ||
|
|
8d7872a376 | ||
|
|
747451d243 | ||
|
|
7e79e98771 | ||
|
|
4b7939541a | ||
|
|
a3734c76b1 | ||
|
|
ced4ea6c17 | ||
|
|
35ca6f2621 | ||
|
|
4dab16837e | ||
|
|
1cf889eed7 | ||
|
|
b65b1e819b | ||
|
|
3d50643ab0 | ||
|
|
abd18d74b0 | ||
|
|
0e49df06b8 | ||
|
|
38cc3e9725 | ||
|
|
c9af2bba4b | ||
|
|
2191c1536d | ||
|
|
5b9bf2fbb0 | ||
|
|
9b1ce8c1d7 | ||
|
|
9f8075041b | ||
|
|
944645379e | ||
|
|
cc72517284 | ||
|
|
0044820415 | ||
|
|
9f24027de1 | ||
|
|
24f95cb03d | ||
|
|
3aeea54615 | ||
|
|
f511041781 | ||
|
|
da9dc91469 | ||
|
|
e04e70d333 | ||
|
|
e0b566ee60 | ||
|
|
bf15d7302e | ||
|
|
8f01c644c0 | ||
|
|
ebd2cc96c5 | ||
|
|
0d1cc42ca7 | ||
|
|
e126dd09ce | ||
|
|
ec497f4f81 | ||
|
|
248fdfd2bc | ||
|
|
35862d619a | ||
|
|
ac2c67985d | ||
|
|
f8ae303417 | ||
|
|
0d24caeac2 | ||
|
|
7f1b357c52 | ||
|
|
ef67ae9d6a | ||
|
|
f35c82d59d | ||
|
|
10c01f4147 | ||
|
|
9366b3baca | ||
|
|
20e792c589 | ||
|
|
dfb63d3275 | ||
|
|
19db226f5a | ||
|
|
203ab00865 | ||
|
|
b11a4887d7 | ||
|
|
e73fc5e1eb | ||
|
|
8561a15061 | ||
|
|
28ba62aead |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -27,4 +27,4 @@ If applicable, add screenshots to help explain your problem.
|
||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||
|
||||
**Log Files**
|
||||
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'
|
||||
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'. If your user folder contains the file "LibationCrash.log", attach that also.
|
||||
|
||||
25
.github/workflows/build-windows.yml
vendored
25
.github/workflows/build-windows.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ main() {
|
||||
init_config_file Settings.json
|
||||
|
||||
info "loading settings"
|
||||
update_settings Settings.json Books /data
|
||||
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
|
||||
update_settings Settings.json InProgress /tmp
|
||||
|
||||
info "loading database"
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
## Q: Where can I get help for my specific problem?
|
||||
|
||||
**A:** [You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
|
||||
## Q: What's the difference between 'Classic' and 'Chardonnay'?
|
||||
|
||||
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- [Download PDF attachments](#download-pdf-attachments)
|
||||
- [Details of downloaded files](#details-of-downloaded-files)
|
||||
- [Export your library](#export-your-library)
|
||||
- [I still need help](#i-still-need-help)
|
||||
|
||||
|
||||
|
||||
@@ -148,3 +149,7 @@ When you set up Libation, you'll specify a Books directory. Libation looks insid
|
||||

|
||||
|
||||
Export your library to Excel, CSV, or JSON
|
||||
|
||||
### I still need help
|
||||
|
||||
[You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
|
||||
@@ -8,23 +8,23 @@
|
||||
|
||||
[](https://repology.org/project/libation/versions)
|
||||
|
||||
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the Libation repository's releases page.
|
||||
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the [Libation repository's releases page](https://github.com/rmcrackan/Libation/releases).
|
||||
|
||||
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
|
||||
Run these commands in your terminal to download and install Libation. **Make sure you replace** `X.X.X` with the latest Libation version and `ARCH` with your CPU's architechture (either `amd64` or `arm64`).
|
||||
|
||||
### Debian
|
||||
```Console
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.deb
|
||||
sudo apt install ./libation.deb
|
||||
```
|
||||
### Redhat and CentOS
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
|
||||
sudo yum install ./libation.rpm
|
||||
```
|
||||
### Fedora
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
|
||||
sudo dnf5 install ./libation.rpm
|
||||
```
|
||||
---
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
||||
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
||||
- [Export your library](Documentation/GettingStarted.md#export-your-library)
|
||||
- If you still need help, [you can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
|
||||
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
||||
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AAXClean;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
@@ -8,7 +9,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||
|
||||
protected Mp4File AaxFile { get; private set; }
|
||||
public Mp4File AaxFile { get; private set; }
|
||||
protected Mp4Operation AaxConversion { get; set; }
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
@@ -33,20 +34,33 @@ namespace AaxDecrypter
|
||||
{
|
||||
if (DownloadOptions.InputType is FileType.Dash)
|
||||
{
|
||||
//We may have multiple keys , so use the key whose key ID matches
|
||||
//the dash files default Key ID.
|
||||
var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||
|
||||
var dash = new DashFile(InputFileStream);
|
||||
dash.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
||||
|
||||
if (kidIndex == -1)
|
||||
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
||||
|
||||
DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex];
|
||||
var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1;
|
||||
var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2;
|
||||
|
||||
dash.SetDecryptionKey(keyId, key);
|
||||
return dash;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aax)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1);
|
||||
return aax;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2);
|
||||
return aax;
|
||||
}
|
||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||
|
||||
@@ -73,11 +73,16 @@ namespace AaxDecrypter
|
||||
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||
|
||||
//Stop the downloader so it doesn't keep running in the background.
|
||||
if (!success)
|
||||
nfsPersister.Dispose();
|
||||
|
||||
await progressTask;
|
||||
|
||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||
|
||||
nfsPersister.Dispose();
|
||||
return success;
|
||||
|
||||
async Task reportProgress()
|
||||
@@ -177,7 +182,7 @@ namespace AaxDecrypter
|
||||
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
|
||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||
if (DownloadOptions.DecryptionKeys != null &&
|
||||
DownloadOptions.RetainEncryptedFile &&
|
||||
DownloadOptions.InputType is AAXClean.FileType fileType)
|
||||
{
|
||||
@@ -188,22 +193,29 @@ namespace AaxDecrypter
|
||||
|
||||
if (fileType is AAXClean.FileType.Aax)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Aaxc)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||
await File.WriteAllTextAsync(keyPath,
|
||||
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
||||
$"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Dash)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"KeyId={DownloadOptions.AudibleKey}{Environment.NewLine}Key={DownloadOptions.AudibleIV}");
|
||||
await File.WriteAllTextAsync(keyPath,
|
||||
$"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
||||
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"Unknown file type: {fileType}");
|
||||
|
||||
if (tempFilePath != aaxPath)
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,35 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public interface IDownloadOptions
|
||||
public class KeyData
|
||||
{
|
||||
public byte[] KeyPart1 { get; }
|
||||
public byte[]? KeyPart2 { get; }
|
||||
|
||||
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
|
||||
{
|
||||
KeyPart1 = keyPart1;
|
||||
KeyPart2 = keyPart2;
|
||||
}
|
||||
|
||||
public KeyData(string keyPart1, string? keyPart2 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
|
||||
KeyPart1 = Convert.FromHexString(keyPart1);
|
||||
if (keyPart2 != null)
|
||||
KeyPart2 = Convert.FromHexString(keyPart2);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IDownloadOptions
|
||||
{
|
||||
event EventHandler<long> DownloadSpeedChanged;
|
||||
string DownloadUrl { get; }
|
||||
string UserAgent { get; }
|
||||
string AudibleKey { get; }
|
||||
string AudibleIV { get; }
|
||||
KeyData[]? DecryptionKeys { get; }
|
||||
TimeSpan RuntimeLength { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool TrimOutputToChapterLength { get; }
|
||||
@@ -21,14 +41,14 @@ namespace AaxDecrypter
|
||||
long DownloadSpeedBps { get; }
|
||||
ChapterInfo ChapterInfo { get; }
|
||||
bool FixupFile { get; }
|
||||
string AudibleProductId { get; }
|
||||
string Title { get; }
|
||||
string Subtitle { get; }
|
||||
string Publisher { get; }
|
||||
string Language { get; }
|
||||
string SeriesName { get; }
|
||||
string? AudibleProductId { get; }
|
||||
string? Title { get; }
|
||||
string? Subtitle { get; }
|
||||
string? Publisher { get; }
|
||||
string? Language { get; }
|
||||
string? SeriesName { get; }
|
||||
float? SeriesNumber { get; }
|
||||
NAudio.Lame.LameConfig LameConfig { get; }
|
||||
NAudio.Lame.LameConfig? LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
bool MoveMoovToBeginning { get; }
|
||||
|
||||
@@ -110,14 +110,16 @@ namespace AaxDecrypter
|
||||
#region Downloader
|
||||
|
||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||
private void OnUpdate()
|
||||
private void OnUpdate(bool waitForWrite = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (DateTime.UtcNow > NextUpdateTime)
|
||||
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
|
||||
//If an update is called less than 100 ms since the last update, persister will
|
||||
//sleep the thread until 100 ms has elapsed.
|
||||
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
|
||||
}
|
||||
}
|
||||
@@ -305,7 +307,7 @@ namespace AaxDecrypter
|
||||
finally
|
||||
{
|
||||
_downloadedPiece.Set();
|
||||
OnUpdate();
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +404,7 @@ namespace AaxDecrypter
|
||||
_cancellationSource?.Dispose();
|
||||
_readFile.Dispose();
|
||||
_writeFile.Dispose();
|
||||
OnUpdate();
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>12.4.1.1</Version>
|
||||
<Version>12.4.7.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace ApplicationServices
|
||||
ScanEnd += (_, __) => Scanning = false;
|
||||
}
|
||||
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace ApplicationServices
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
@@ -101,7 +101,7 @@ namespace ApplicationServices
|
||||
}
|
||||
|
||||
#region FULL LIBRARY scan and import
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[]? accounts)
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
@@ -131,7 +131,7 @@ namespace ApplicationServices
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
@@ -262,7 +262,7 @@ namespace ApplicationServices
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
|
||||
@@ -278,7 +278,7 @@ namespace ApplicationServices
|
||||
try
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await apiExtendedfunc(account);
|
||||
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||
|
||||
@@ -11,11 +11,13 @@ using Polly;
|
||||
using Polly.Retry;
|
||||
using System.Threading;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||
public class ApiExtended
|
||||
{
|
||||
public static Func<Account, ILoginChoiceEager>? LoginChoiceFactory { get; set; }
|
||||
public Api Api { get; private set; }
|
||||
|
||||
private const int MaxConcurrency = 10;
|
||||
@@ -24,52 +26,46 @@ namespace AudibleUtilities
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account?.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account?.Locale?.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
loginChoiceEager,
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
try
|
||||
{
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||
});
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||
});
|
||||
|
||||
return await CreateAsync(account.AccountId, account.Locale.Name);
|
||||
}
|
||||
|
||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Username = username.ToMask(),
|
||||
LocaleName = localeName,
|
||||
});
|
||||
if (LoginChoiceFactory is null)
|
||||
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
Localization.Get(localeName),
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = account.Locale?.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
LoginChoiceFactory(account),
|
||||
account.Locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
account.GetIdentityTokensJsonPath());
|
||||
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
}
|
||||
|
||||
private static AsyncRetryPolicy policy { get; }
|
||||
= Policy.Handle<Exception>()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -55,7 +55,7 @@ public class WidevineKey
|
||||
Type = (KeyType)type;
|
||||
Key = key;
|
||||
}
|
||||
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray()).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
||||
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
||||
}
|
||||
|
||||
public partial class Cdm
|
||||
@@ -192,7 +192,7 @@ public partial class Cdm
|
||||
id = id.Append(new byte[16 - id.Length]);
|
||||
}
|
||||
|
||||
keys[i] = new WidevineKey(new Guid(id), keyContainer.Type, keyBytes);
|
||||
keys[i] = new WidevineKey(new Guid(id,bigEndian: true), keyContainer.Type, keyBytes);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -103,13 +103,11 @@ namespace DataLayer
|
||||
) == true
|
||||
).ToList();
|
||||
|
||||
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)
|
||||
);
|
||||
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.NeedsPdfDownload() || lb.NeedsBookDownload());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,29 @@ namespace FileLiberator
|
||||
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
|
||||
return;
|
||||
|
||||
#region Prevent erroneous truncation due to incorrect chapter info
|
||||
|
||||
//Sometimes the chapter info is not accurate. Since AAXClean trims audio
|
||||
//files to the chapters start and end, if the last chapter's end time is
|
||||
//before the end of the audio file, the file will be truncated to match
|
||||
//the chapter. This is never desirable, so pad the last chapter to match
|
||||
//the original audio length.
|
||||
|
||||
var fileDuration = converter.AaxFile.Duration;
|
||||
if (options.Config.StripAudibleBrandAudio)
|
||||
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||
|
||||
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
||||
//Remove the last chapter and re-add it with the durationDelta that will
|
||||
//make the chapter's end coincide with the end of the audio file.
|
||||
var chapters = options.ChapterInfo.Chapters as List<AAXClean.Chapter>;
|
||||
var lastChapter = chapters[^1];
|
||||
|
||||
chapters.Remove(lastChapter);
|
||||
options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta);
|
||||
|
||||
#endregion
|
||||
|
||||
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
||||
tags.Album ??= tags.Title;
|
||||
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
||||
|
||||
@@ -28,7 +28,7 @@ public partial class DownloadOptions
|
||||
{
|
||||
var license = await ChooseContent(api, libraryBook, config);
|
||||
|
||||
//Come audiobooks will have incorrect chapters in the metadata returned from the license request,
|
||||
//Some audiobooks will have incorrect chapters in the metadata returned from the license request,
|
||||
//but the metadata returned by the content metadata endpoint will be correct. Call the content
|
||||
//metadata endpoint and use its chapters. Only replace the license request chapters if the total
|
||||
//lengths match (defensive against different audio formats having slightly different lengths).
|
||||
@@ -41,26 +41,38 @@ public partial class DownloadOptions
|
||||
return options;
|
||||
}
|
||||
|
||||
private static async Task<ContentLicense> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
|
||||
private class LicenseInfo
|
||||
{
|
||||
public DrmType DrmType { get; }
|
||||
public ContentMetadata ContentMetadata { get; set; }
|
||||
public KeyData[]? DecryptionKeys { get; }
|
||||
public LicenseInfo(ContentLicense license, IEnumerable<KeyData>? keys = null)
|
||||
{
|
||||
DrmType = license.DrmType;
|
||||
ContentMetadata = license.ContentMetadata;
|
||||
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher);
|
||||
}
|
||||
|
||||
private static KeyData[]? ToKeys(VoucherDtoV10? voucher)
|
||||
=> voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
|
||||
}
|
||||
|
||||
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
|
||||
{
|
||||
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
|
||||
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
|
||||
return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
|
||||
ContentLicense? contentLic = null, fallback = null;
|
||||
{
|
||||
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
return new LicenseInfo(license);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
//try to request a widevine content license using the user's spatial audio settings
|
||||
var codecChoice = config.SpatialAudioCodec switch
|
||||
{
|
||||
Configuration.SpatialCodec.EC_3 => Ec3Codec,
|
||||
Configuration.SpatialCodec.AC_4 => Ac4Codec,
|
||||
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
|
||||
};
|
||||
var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec;
|
||||
|
||||
contentLic
|
||||
var contentLic
|
||||
= await api.GetDownloadLicenseAsync(
|
||||
libraryBook.Book.AudibleProductId,
|
||||
dlQuality,
|
||||
@@ -68,119 +80,50 @@ public partial class DownloadOptions
|
||||
DrmType.Widevine,
|
||||
config.RequestSpatial,
|
||||
codecChoice);
|
||||
|
||||
if (contentLic.DrmType is not DrmType.Widevine)
|
||||
return new LicenseInfo(contentLic);
|
||||
|
||||
using var client = new HttpClient();
|
||||
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
|
||||
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
|
||||
|
||||
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
|
||||
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
|
||||
|
||||
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
|
||||
|
||||
using var session = cdm.OpenSession();
|
||||
var challenge = session.GetLicenseChallenge(dash);
|
||||
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
|
||||
var keys = session.ParseLicense(licenseMessage);
|
||||
return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
||||
//We failed to get a widevine license, so fall back to AAX(C)
|
||||
return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
//We failed to get a widevine content license. Depending on the
|
||||
//failure reason, users can potentially still download this audiobook
|
||||
//by disabling the "Use Widevine DRM" feature.
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!contentLic.ContentMetadata.ContentReference.IsSpatial && contentLic.DrmType != DrmType.Adrm)
|
||||
{
|
||||
/*
|
||||
We got a widevine license and we have a Cdm, but we still need to decide if we WANT the file
|
||||
being delivered with widevine. This file is not "spatial", so it may be no better than the
|
||||
audio in the Adrm files. All else being equal, we prefer Adrm files because they have more
|
||||
build-in metadata and always AAC-LC, which is a codec playable by pretty much every device
|
||||
in existence.
|
||||
|
||||
Unfortunately, there appears to be no way to determine which codec/quality combination we'll
|
||||
get until we make the request and see what content gets delivered. For some books,
|
||||
Widevine/High delivers 44.1 kHz / 128 kbps audio and Adrm/High delivers 22.05 kHz / 64 kbps.
|
||||
In those cases, the Widevine content size is much larger. Other books will deliver the same
|
||||
sample rate / bitrate for both Widevine and Adrm, the only difference being codec. Widevine
|
||||
is usually xHE-AAC, but is sometimes AAC-LC. Adrm is always AAC-LC.
|
||||
|
||||
To decide which file we want, use this simple rule: if files are different codecs and
|
||||
Widevine is significantly larger, use Widevine. Otherwise use ADRM.
|
||||
*/
|
||||
|
||||
fallback = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
|
||||
var wvCr = contentLic.ContentMetadata.ContentReference;
|
||||
var adrmCr = fallback.ContentMetadata.ContentReference;
|
||||
|
||||
if (wvCr.Codec == adrmCr.Codec ||
|
||||
adrmCr.ContentSizeInBytes > wvCr.ContentSizeInBytes ||
|
||||
RelativePercentDifference(adrmCr.ContentSizeInBytes, wvCr.ContentSizeInBytes) < 0.05)
|
||||
{
|
||||
contentLic = fallback;
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLic.DrmType == DrmType.Widevine)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
|
||||
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
|
||||
|
||||
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
|
||||
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
|
||||
|
||||
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
|
||||
|
||||
using var session = cdm.OpenSession();
|
||||
var challenge = session.GetLicenseChallenge(dash);
|
||||
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
|
||||
var keys = session.ParseLicense(licenseMessage);
|
||||
contentLic.Voucher = new VoucherDtoV10()
|
||||
{
|
||||
Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()),
|
||||
Iv = Convert.ToHexStringLower(keys[0].Key)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (fallback != null)
|
||||
return fallback;
|
||||
|
||||
//We won't have a fallback if the requested license is for a spatial audio file.
|
||||
//Throw so that the user is aware that spatial audio exists and that they were not able to download it.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
return contentLic;
|
||||
}
|
||||
|
||||
|
||||
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
||||
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||
{
|
||||
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
|
||||
var outputFormat
|
||||
= contentLic.DrmType is not DrmType.Adrm and not DrmType.Widevine ||
|
||||
(config.AllowLibationFixup && config.DecryptToLossy && contentLic.ContentMetadata.ContentReference.Codec != "ac-4")
|
||||
? OutputFormat.Mp3
|
||||
: OutputFormat.M4b;
|
||||
|
||||
long chapterStartMs
|
||||
= config.StripAudibleBrandAudio
|
||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||
? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||
: 0;
|
||||
|
||||
AAXClean.FileType? inputType
|
||||
= contentLic.DrmType is DrmType.Widevine ? AAXClean.FileType.Dash
|
||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 8 && contentLic.Voucher?.Iv == null ? AAXClean.FileType.Aax
|
||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 32 && contentLic.Voucher?.Iv.Length == 32 ? AAXClean.FileType.Aaxc
|
||||
: null;
|
||||
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic.ContentMetadata.ContentUrl?.OfflineUrl)
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
|
||||
{
|
||||
AudibleKey = contentLic.Voucher?.Key,
|
||||
AudibleIV = contentLic.Voucher?.Iv,
|
||||
InputType = inputType,
|
||||
OutputFormat = outputFormat,
|
||||
DrmType = contentLic.DrmType,
|
||||
ContentMetadata = contentLic.ContentMetadata,
|
||||
LameConfig = outputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null,
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||
};
|
||||
|
||||
dlOptions.LibraryBookDto.Codec = contentLic.ContentMetadata.ContentReference.Codec;
|
||||
if (TryGetAudioInfo(contentLic.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
|
||||
if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
|
||||
{
|
||||
dlOptions.LibraryBookDto.BitRate = bitrate;
|
||||
dlOptions.LibraryBookDto.SampleRate = sampleRate;
|
||||
@@ -189,7 +132,7 @@ public partial class DownloadOptions
|
||||
|
||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||
var chapters
|
||||
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||
.OrderBy(c => c.StartOffsetMs)
|
||||
.ToList();
|
||||
|
||||
@@ -205,7 +148,7 @@ public partial class DownloadOptions
|
||||
chapLenMs -= chapterStartMs;
|
||||
|
||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
|
||||
@@ -9,47 +9,47 @@ using System.IO;
|
||||
using ApplicationServices;
|
||||
using LibationFileManager.Templates;
|
||||
|
||||
#nullable enable
|
||||
namespace FileLiberator
|
||||
{
|
||||
public partial class DownloadOptions : IDownloadOptions, IDisposable
|
||||
{
|
||||
public event EventHandler<long> DownloadSpeedChanged;
|
||||
public event EventHandler<long>? DownloadSpeedChanged;
|
||||
public LibraryBook LibraryBook { get; }
|
||||
public LibraryBookDto LibraryBookDto { get; }
|
||||
public string DownloadUrl { get; }
|
||||
public string AudibleKey { get; init; }
|
||||
public string AudibleIV { get; init; }
|
||||
public TimeSpan RuntimeLength { get; init; }
|
||||
public OutputFormat OutputFormat { get; init; }
|
||||
public ChapterInfo ChapterInfo { get; init; }
|
||||
public KeyData[]? DecryptionKeys { get; }
|
||||
public required TimeSpan RuntimeLength { get; init; }
|
||||
public OutputFormat OutputFormat { get; }
|
||||
public required ChapterInfo ChapterInfo { get; init; }
|
||||
public string Title => LibraryBook.Book.Title;
|
||||
public string Subtitle => LibraryBook.Book.Subtitle;
|
||||
public string Publisher => LibraryBook.Book.Publisher;
|
||||
public string Language => LibraryBook.Book.Language;
|
||||
public string AudibleProductId => LibraryBookDto.AudibleProductId;
|
||||
public string SeriesName => LibraryBookDto.FirstSeries?.Name;
|
||||
public string? AudibleProductId => LibraryBookDto.AudibleProductId;
|
||||
public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
|
||||
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
||||
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||
public NAudio.Lame.LameConfig? LameConfig { get; }
|
||||
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
||||
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
|
||||
public bool CreateCueSheet => config.CreateCueSheet;
|
||||
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks;
|
||||
public long DownloadSpeedBps => config.DownloadSpeedLimit;
|
||||
public bool RetainEncryptedFile => config.RetainAaxFile;
|
||||
public bool FixupFile => config.AllowLibationFixup;
|
||||
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
|
||||
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
|
||||
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
||||
public AAXClean.FileType? InputType { get; init; }
|
||||
public AudibleApi.Common.DrmType DrmType { get; init; }
|
||||
public AudibleApi.Common.ContentMetadata ContentMetadata { get; init; }
|
||||
public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio;
|
||||
public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
|
||||
public bool CreateCueSheet => Config.CreateCueSheet;
|
||||
public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks;
|
||||
public long DownloadSpeedBps => Config.DownloadSpeedLimit;
|
||||
public bool RetainEncryptedFile => Config.RetainAaxFile;
|
||||
public bool FixupFile => Config.AllowLibationFixup;
|
||||
public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
|
||||
public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
|
||||
public bool MoveMoovToBeginning => Config.MoveMoovToBeginning;
|
||||
public AAXClean.FileType? InputType { get; }
|
||||
public AudibleApi.Common.DrmType DrmType { get; }
|
||||
public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
|
||||
|
||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||
{
|
||||
var baseDir = Path.GetDirectoryName(props.OutputFileName);
|
||||
var extension = Path.GetExtension(props.OutputFileName);
|
||||
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension);
|
||||
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension);
|
||||
}
|
||||
|
||||
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||
@@ -59,7 +59,7 @@ namespace FileLiberator
|
||||
{
|
||||
if (DownloadClipsBookmarks)
|
||||
{
|
||||
var format = config.ClipsBookmarksFileFormat;
|
||||
var format = Config.ClipsBookmarksFileFormat;
|
||||
|
||||
var formatExtension = format.ToString().ToLowerInvariant();
|
||||
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
||||
@@ -84,7 +84,7 @@ namespace FileLiberator
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private readonly Configuration config;
|
||||
public Configuration Config { get; }
|
||||
private readonly IDisposable cancellation;
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -92,14 +92,38 @@ namespace FileLiberator
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private DownloadOptions(Configuration config, LibraryBook libraryBook, [System.Diagnostics.CodeAnalysis.NotNull] string downloadUrl)
|
||||
private DownloadOptions(Configuration config, LibraryBook libraryBook, LicenseInfo licInfo)
|
||||
{
|
||||
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||
Config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
|
||||
ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo));
|
||||
|
||||
if (licInfo.ContentMetadata.ContentUrl.OfflineUrl is not string licUrl)
|
||||
throw new InvalidDataException("Content license doesn't contain an offline Url");
|
||||
|
||||
DownloadUrl = licUrl;
|
||||
DecryptionKeys = licInfo.DecryptionKeys;
|
||||
DrmType = licInfo.DrmType;
|
||||
ContentMetadata = licInfo.ContentMetadata;
|
||||
InputType
|
||||
= licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash
|
||||
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 4 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax
|
||||
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 16 && licInfo.DecryptionKeys[0].KeyPart2?.Length == 16 ? AAXClean.FileType.Aaxc
|
||||
: null;
|
||||
|
||||
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
|
||||
OutputFormat
|
||||
= licInfo.DrmType is not AudibleApi.Common.DrmType.Adrm and not AudibleApi.Common.DrmType.Widevine ||
|
||||
(config.AllowLibationFixup && config.DecryptToLossy && licInfo.ContentMetadata.ContentReference.Codec != Ac4Codec)
|
||||
? OutputFormat.Mp3
|
||||
: OutputFormat.M4b;
|
||||
|
||||
LameConfig = OutputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null;
|
||||
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
LibraryBookDto = LibraryBook.ToDto();
|
||||
LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec;
|
||||
|
||||
cancellation =
|
||||
config
|
||||
|
||||
@@ -20,9 +20,15 @@ namespace FileLiberator
|
||||
account: libraryBook.Account.ToMask()
|
||||
);
|
||||
|
||||
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
|
||||
|
||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||
{
|
||||
var apiExtended = await ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
Account account;
|
||||
using (var accounts = AudibleApiStorage.GetAccountsSettingsPersister())
|
||||
account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
|
||||
|
||||
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||
return apiExtended.Api;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,10 +32,29 @@ namespace LibationAvalonia.Controls.Settings
|
||||
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
|
||||
{
|
||||
if (VisualRoot is Window parent)
|
||||
await MessageBox.Show(parent,
|
||||
"Your must remove account(s) from Libation and then re-add them to enable widwvine content.",
|
||||
{
|
||||
var choice = await MessageBox.Show(parent,
|
||||
"In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" +
|
||||
"Do you want Libation to clear your current account settings and prompt you to login before the next download?",
|
||||
"Widevine Content Unavailable",
|
||||
MessageBoxButtons.OK);
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question,
|
||||
MessageBoxDefaultButton.Button2);
|
||||
|
||||
if (choice == DialogResult.Yes)
|
||||
{
|
||||
foreach (var account in accounts.AccountsSettings.Accounts.ToArray())
|
||||
{
|
||||
if (account.IdentityTokens.DeviceType != AudibleApi.Resources.DeviceType)
|
||||
{
|
||||
accounts.AccountsSettings.Delete(account);
|
||||
var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name);
|
||||
acc.AccountName = account.AccountName;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_viewModel.UseWidevine = false;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs.Login
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -9,10 +10,6 @@ namespace LibationAvalonia.Dialogs.Login
|
||||
{
|
||||
public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
{
|
||||
/// <summary>Convenience method. Recommended when wiring up Winforms to <see cref="ApplicationServices.LibraryCommands.ImportAccountAsync"/></summary>
|
||||
public static async Task<ApiExtended> ApiExtendedFunc(Account account)
|
||||
=> await ApiExtended.CreateAsync(account, new AvaloniaLoginChoiceEager(account));
|
||||
|
||||
public ILoginCallback LoginCallback { get; }
|
||||
|
||||
private readonly Account _account;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.Dialogs.Login
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -14,14 +15,22 @@
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
|
||||
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 Height="32" Width="32" Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationAvalonia.ViewModels.Dialogs;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using AppScaffolding;
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ using DataLayer;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Dialogs;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -13,92 +14,45 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia
|
||||
{
|
||||
public enum DialogResult
|
||||
{
|
||||
None = 0,
|
||||
OK = 1,
|
||||
Cancel = 2,
|
||||
Abort = 3,
|
||||
Retry = 4,
|
||||
Ignore = 5,
|
||||
Yes = 6,
|
||||
No = 7,
|
||||
TryAgain = 10,
|
||||
Continue = 11
|
||||
}
|
||||
|
||||
public enum MessageBoxIcon
|
||||
{
|
||||
None = 0,
|
||||
Error = 16,
|
||||
Hand = 16,
|
||||
Stop = 16,
|
||||
Question = 32,
|
||||
Exclamation = 48,
|
||||
Warning = 48,
|
||||
Asterisk = 64,
|
||||
Information = 64
|
||||
}
|
||||
|
||||
public enum MessageBoxButtons
|
||||
{
|
||||
OK,
|
||||
OKCancel,
|
||||
AbortRetryIgnore,
|
||||
YesNoCancel,
|
||||
YesNo,
|
||||
RetryCancel,
|
||||
CancelTryContinue
|
||||
}
|
||||
|
||||
public enum MessageBoxDefaultButton
|
||||
{
|
||||
Button1,
|
||||
Button2 = 256,
|
||||
Button3 = 512,
|
||||
}
|
||||
|
||||
public class MessageBox
|
||||
{
|
||||
|
||||
public static Task<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)
|
||||
=> ShowCoreAsync(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition);
|
||||
=> ShowCoreAsync(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition);
|
||||
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons)
|
||||
=> ShowCoreAsync(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(string text, string caption)
|
||||
=> ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<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);
|
||||
|
||||
=> 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, 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);
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons)
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(Window owner, string text, string caption)
|
||||
=> ShowCoreAsync(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.ViewModels.Dialogs
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -14,8 +15,10 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
partial class MainVM
|
||||
{
|
||||
private QuickFilters.NamedFilter? lastGoodFilter = new(string.Empty, null);
|
||||
private QuickFilters.NamedFilter? _selectedNamedFilter = new(string.Empty, null);
|
||||
private string lastGoodSearch = string.Empty;
|
||||
private QuickFilters.NamedFilter? lastGoodFilter => new(lastGoodSearch, null);
|
||||
|
||||
private QuickFilters.NamedFilter? _selectedNamedFilter = new(string.Empty, null);
|
||||
private bool _firstFilterIsDefault = true;
|
||||
|
||||
/// <summary> Library filterting query </summary>
|
||||
@@ -64,15 +67,16 @@ namespace LibationAvalonia.ViewModels
|
||||
try
|
||||
{
|
||||
await ProductsDisplay.Filter(tryFilter);
|
||||
lastGoodFilter = namedFilter;
|
||||
lastGoodSearch = namedFilter?.Filter ?? "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
|
||||
await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
|
||||
// re-apply last good filter
|
||||
await PerformFilter(lastGoodFilter);
|
||||
// re-apply last good filter
|
||||
namedFilter = (namedFilter ?? new(string.Empty, null)) with { Filter = lastGoodSearch };
|
||||
await PerformFilter(namedFilter);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Input;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -201,7 +202,7 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts);
|
||||
var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(accounts);
|
||||
|
||||
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
|
||||
if (Configuration.Instance.ShowImportedStats && newAdded > 0)
|
||||
|
||||
@@ -4,6 +4,9 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using LibationUiBase.Forms;
|
||||
using LibationUiBase;
|
||||
using System.Collections.Generic;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -12,19 +15,14 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
public void Configure_Liberate() { }
|
||||
|
||||
public void BackupAllBooks()
|
||||
public async Task BackupAllBooks()
|
||||
{
|
||||
try
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
var unliberated = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up all library books");
|
||||
|
||||
ProcessQueue.AddDownloadDecrypt(
|
||||
DbContexts
|
||||
.GetLibrary_Flat_NoTracking()
|
||||
.UnLiberated()
|
||||
);
|
||||
if (ProcessQueue.QueueDownloadDecrypt(unliberated))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -32,10 +30,10 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public void BackupAllPdfs()
|
||||
public async Task BackupAllPdfs()
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadPdf(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated));
|
||||
if (ProcessQueue.QueueDownloadPdf(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
|
||||
public async Task ConvertAllToMp3Async()
|
||||
@@ -48,12 +46,8 @@ namespace LibationAvalonia.ViewModels
|
||||
"Convert all M4b => Mp3?",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning);
|
||||
if (result == DialogResult.Yes)
|
||||
{
|
||||
if (result == DialogResult.Yes && ProcessQueue.QueueConvertToMp3(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddConvertMp3(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is ContentType.Product));
|
||||
}
|
||||
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
|
||||
}
|
||||
|
||||
private void setQueueCollapseState(bool collapsed)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.GridView;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -37,50 +38,16 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
if (libraryBooks.Length == 1)
|
||||
if (ProcessQueue.QueueDownloadDecrypt(libraryBooks))
|
||||
setQueueCollapseState(false);
|
||||
else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists())
|
||||
{
|
||||
var item = libraryBooks[0];
|
||||
|
||||
//Remove this item from the queue if it's already present and completed.
|
||||
//Only do this when adding a single book at a time to prevent accidental
|
||||
//extra downloads when queueing in batches.
|
||||
ProcessQueue.RemoveCompleted(item);
|
||||
|
||||
if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||
// liberated: open explorer to file
|
||||
var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId);
|
||||
if (!Go.To.File(filePath?.ShortPathName))
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item);
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadDecrypt(item);
|
||||
}
|
||||
else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadPdf(item);
|
||||
}
|
||||
else if (item.Book.Audio_Exists())
|
||||
{
|
||||
// liberated: open explorer to file
|
||||
var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId);
|
||||
|
||||
if (!Go.To.File(filePath?.ShortPathName))
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
|
||||
await MessageBox.Show($"File not found" + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var toLiberate
|
||||
= libraryBooks
|
||||
.Where(x => x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
|
||||
.ToArray();
|
||||
|
||||
if (toLiberate.Length > 0)
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadDecrypt(toLiberate);
|
||||
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
|
||||
await MessageBox.Show($"File not found" + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,11 +61,10 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
|
||||
|
||||
ProcessQueue.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated());
|
||||
if (ProcessQueue.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -110,13 +76,8 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray();
|
||||
if (preLiberated.Length > 0)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
|
||||
if (ProcessQueue.QueueConvertToMp3(libraryBooks))
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddConvertMp3(preLiberated);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace LibationAvalonia.ViewModels
|
||||
// in autoScan, new books SHALL NOT show dialog
|
||||
try
|
||||
{
|
||||
await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts);
|
||||
await LibraryCommands.ImportAccountAsync(accounts);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,9 @@ using DataLayer;
|
||||
using Avalonia.Threading;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using ReactiveUI;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Linq;
|
||||
using LibationUiBase;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -71,15 +74,8 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up visible library books");
|
||||
|
||||
ProcessQueue.AddDownloadDecrypt(
|
||||
ProductsDisplay
|
||||
.GetVisibleBookEntries()
|
||||
.UnLiberated()
|
||||
);
|
||||
if (ProcessQueue.QueueDownloadDecrypt(ProductsDisplay.GetVisibleBookEntries().UnLiberated().ToArray()))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,289 +1,74 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using ReactiveUI;
|
||||
using LibationUiBase.ProcessQueue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
namespace LibationAvalonia.ViewModels;
|
||||
|
||||
public record LogEntry(DateTime LogDate, string? LogMessage)
|
||||
{
|
||||
public string LogDateString => LogDate.ToShortTimeString();
|
||||
}
|
||||
|
||||
public class ProcessQueueViewModel : ViewModelBase, ILogForm
|
||||
public class ProcessQueueViewModel : ProcessQueueViewModelBase
|
||||
{
|
||||
public ProcessQueueViewModel() : base(CreateEmptyList())
|
||||
{
|
||||
public ObservableCollection<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;
|
||||
Items = Queue.UnderlyingList as AvaloniaList<ProcessBookViewModelBase>
|
||||
?? throw new ArgumentNullException(nameof(Queue.UnderlyingList));
|
||||
|
||||
private readonly LogMe Logger;
|
||||
SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
|
||||
}
|
||||
|
||||
public ProcessQueueViewModel()
|
||||
private decimal _speedLimit;
|
||||
public decimal SpeedLimitIncrement { get; private set; }
|
||||
public ObservableCollection<LogEntry> LogEntries { get; } = new();
|
||||
public AvaloniaList<ProcessBookViewModelBase> Items { get; }
|
||||
|
||||
public decimal SpeedLimit
|
||||
{
|
||||
get
|
||||
{
|
||||
Logger = LogMe.RegisterForm(this);
|
||||
Queue = new(Items);
|
||||
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
|
||||
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
_ = Configuration.Instance.LibationFiles;
|
||||
|
||||
SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
|
||||
return _speedLimit;
|
||||
}
|
||||
|
||||
private int _completedCount;
|
||||
private int _errorCount;
|
||||
private int _queuedCount;
|
||||
private string? _runningTime;
|
||||
private bool _progressBarVisible;
|
||||
private decimal _speedLimit;
|
||||
|
||||
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
|
||||
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
|
||||
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
|
||||
public string? RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
|
||||
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
|
||||
public bool AnyCompleted => CompletedCount > 0;
|
||||
public bool AnyQueued => QueuedCount > 0;
|
||||
public bool AnyErrors => ErrorCount > 0;
|
||||
public double Progress => 100d * Queue.Completed.Count / Queue.Count;
|
||||
|
||||
public decimal SpeedLimit
|
||||
set
|
||||
{
|
||||
get
|
||||
{
|
||||
return _speedLimit;
|
||||
}
|
||||
set
|
||||
{
|
||||
var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024));
|
||||
var config = Configuration.Instance;
|
||||
config.DownloadSpeedLimit = newValue;
|
||||
var newValue = Math.Min(999 * 1024 * 1024, (long)(value * 1024 * 1024));
|
||||
var config = Configuration.Instance;
|
||||
config.DownloadSpeedLimit = newValue;
|
||||
|
||||
_speedLimit
|
||||
= config.DownloadSpeedLimit <= newValue ? value
|
||||
: value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024
|
||||
: 0;
|
||||
_speedLimit
|
||||
= config.DownloadSpeedLimit <= newValue ? value
|
||||
: value == 0.01m ? config.DownloadSpeedLimit / 1024m / 1024
|
||||
: 0;
|
||||
|
||||
config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024);
|
||||
config.DownloadSpeedLimit = (long)(_speedLimit * 1024 * 1024);
|
||||
|
||||
SpeedLimitIncrement = _speedLimit > 100 ? 10
|
||||
: _speedLimit > 10 ? 1
|
||||
: _speedLimit > 1 ? 0.1m
|
||||
: 0.01m;
|
||||
SpeedLimitIncrement = _speedLimit > 100 ? 10
|
||||
: _speedLimit > 10 ? 1
|
||||
: _speedLimit > 1 ? 0.1m
|
||||
: 0.01m;
|
||||
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
|
||||
this.RaisePropertyChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public decimal SpeedLimitIncrement { get; private set; }
|
||||
|
||||
private void Queue_CompletedCountChanged(object? sender, int e)
|
||||
{
|
||||
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
|
||||
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
|
||||
|
||||
ErrorCount = errCount;
|
||||
CompletedCount = completeCount;
|
||||
Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress)));
|
||||
}
|
||||
private void Queue_QueuededCountChanged(object? sender, int cueCount)
|
||||
{
|
||||
QueuedCount = cueCount;
|
||||
Dispatcher.UIThread.Invoke(() => this.RaisePropertyChanged(nameof(Progress)));
|
||||
}
|
||||
|
||||
public void WriteLine(string text)
|
||||
{
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
LogEntries.Add(new()
|
||||
{
|
||||
LogDate = DateTime.Now,
|
||||
LogMessage = text.Trim()
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
#region Add Books to Queue
|
||||
|
||||
private bool isBookInQueue(LibraryBook libraryBook)
|
||||
{
|
||||
var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId);
|
||||
if (entry == null)
|
||||
return false;
|
||||
else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed)
|
||||
return !Queue.RemoveCompleted(entry);
|
||||
else
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveCompleted(LibraryBook libraryBook)
|
||||
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is ProcessBookViewModel entry
|
||||
&& entry.Status is ProcessBookStatus.Completed
|
||||
&& Queue.RemoveCompleted(entry);
|
||||
|
||||
public void AddDownloadPdf(LibraryBook libraryBook)
|
||||
=> AddDownloadPdf(new List<LibraryBook>() { 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);
|
||||
RaisePropertyChanged(nameof(SpeedLimitIncrement));
|
||||
RaisePropertyChanged(nameof(SpeedLimit));
|
||||
}
|
||||
}
|
||||
|
||||
public class LogEntry
|
||||
public override void WriteLine(string text)
|
||||
=> Dispatcher.UIThread.Invoke(() => LogEntries.Add(new(DateTime.Now, text.Trim())));
|
||||
|
||||
protected override ProcessBookViewModelBase CreateNewProcessBook(LibraryBook libraryBook)
|
||||
=> new ProcessBookViewModel(libraryBook, Logger);
|
||||
|
||||
private static AvaloniaList<ProcessBookViewModelBase> CreateEmptyList()
|
||||
{
|
||||
public DateTime LogDate { get; init; }
|
||||
public string LogDateString => LogDate.ToShortTimeString();
|
||||
public string? LogMessage { get; init; }
|
||||
if (Design.IsDesignMode)
|
||||
_ = Configuration.Instance.LibationFiles;
|
||||
return new AvaloniaList<ProcessBookViewModelBase>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -431,7 +431,7 @@ namespace LibationAvalonia.ViewModels
|
||||
.Select(lbe => lbe.LibraryBook)
|
||||
.Where(lb => !lb.Book.HasLiberated());
|
||||
|
||||
var removedBooks = await LibraryCommands.FindInactiveBooks(AvaloniaLoginChoiceEager.ApiExtendedFunc, lib, accounts);
|
||||
var removedBooks = await LibraryCommands.FindInactiveBooks(lib, accounts);
|
||||
|
||||
var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using FileManager;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using LibationUiBase.GridView;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
@@ -21,6 +22,7 @@ namespace LibationAvalonia.Views
|
||||
public MainWindow()
|
||||
{
|
||||
DataContext = new MainVM(this);
|
||||
ApiExtended.LoginChoiceFactory = account => new Dialogs.Login.AvaloniaLoginChoiceEager(account);
|
||||
|
||||
AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
|
||||
InitializeComponent();
|
||||
@@ -164,20 +166,23 @@ namespace LibationAvalonia.Views
|
||||
private void Configure_Upgrade()
|
||||
{
|
||||
setProgressVisible(false);
|
||||
#if !DEBUG
|
||||
async System.Threading.Tasks.Task upgradeAvailable(LibationUiBase.UpgradeEventArgs e)
|
||||
#pragma warning disable CS8321 // Local function is declared but never used
|
||||
async Task upgradeAvailable(LibationUiBase.UpgradeEventArgs e)
|
||||
{
|
||||
var notificationResult = await new Dialogs.UpgradeNotificationDialog(e.UpgradeProperties, e.CapUpgrade).ShowDialogAsync(this);
|
||||
var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties, e.CapUpgrade).ShowDialogAsync(this);
|
||||
|
||||
e.Ignore = notificationResult == DialogResult.Ignore;
|
||||
e.InstallUpgrade = notificationResult == DialogResult.OK;
|
||||
}
|
||||
#pragma warning restore CS8321 // Local function is declared but never used
|
||||
|
||||
var upgrader = new LibationUiBase.Upgrader();
|
||||
upgrader.DownloadProgress += async (_, e) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => ViewModel.DownloadProgress = e.ProgressPercentage);
|
||||
upgrader.DownloadBegin += async (_, _) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => setProgressVisible(true));
|
||||
upgrader.DownloadCompleted += async (_, _) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => setProgressVisible(false));
|
||||
upgrader.DownloadProgress += async (_, e) => await Dispatcher.UIThread.InvokeAsync(() => ViewModel.DownloadProgress = e.ProgressPercentage);
|
||||
upgrader.DownloadBegin += async (_, _) => await Dispatcher.UIThread.InvokeAsync(() => setProgressVisible(true));
|
||||
upgrader.DownloadCompleted += async (_, _) => await Dispatcher.UIThread.InvokeAsync(() => setProgressVisible(false));
|
||||
upgrader.UpgradeFailed += async (_, message) => await Dispatcher.UIThread.InvokeAsync(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); });
|
||||
|
||||
#if !DEBUG
|
||||
Opened += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Avalonia.Controls;
|
||||
using DataLayer;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.ProcessQueue;
|
||||
|
||||
namespace LibationAvalonia.Views
|
||||
{
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace LibationCli
|
||||
: $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account.";
|
||||
Console.WriteLine(intro);
|
||||
|
||||
var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync((a) => ApiExtended.CreateAsync(a), _accounts);
|
||||
var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync(_accounts);
|
||||
|
||||
Console.WriteLine("Scan complete.");
|
||||
Console.WriteLine($"Total processed: {TotalBooksProcessed}");
|
||||
|
||||
@@ -132,7 +132,7 @@ namespace LibationFileManager
|
||||
public bool AllowLibationFixup { get => GetNonString(defaultValue: true); set => SetNonString(value); }
|
||||
|
||||
[Description("Create a cue sheet (.cue)")]
|
||||
public bool CreateCueSheet { get => GetNonString(defaultValue: true); set => SetNonString(value); }
|
||||
public bool CreateCueSheet { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
[Description("Retain the Aax file after successfully decrypting")]
|
||||
public bool RetainAaxFile { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
@@ -257,7 +257,7 @@ namespace LibationFileManager
|
||||
}
|
||||
|
||||
[Description("Use widevine DRM")]
|
||||
public bool UseWidevine { get => GetNonString(defaultValue: true); set => SetNonString(value); }
|
||||
public bool UseWidevine { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
[Description("Request Spatial Audio")]
|
||||
public bool RequestSpatial { get => GetNonString(defaultValue: true); set => SetNonString(value); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,8 @@ public class LibationContributor
|
||||
GitHubUser("muchtall"),
|
||||
GitHubUser("ScubyG"),
|
||||
GitHubUser("patienttruth"),
|
||||
GitHubUser("stickystyle")
|
||||
GitHubUser("stickystyle"),
|
||||
GitHubUser("cherez"),
|
||||
]);
|
||||
|
||||
private LibationContributor(string name, LibationContributorType type,Uri link)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
133
Source/LibationUiBase/MessageBoxBase.cs
Normal file
133
Source/LibationUiBase/MessageBoxBase.cs
Normal 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);
|
||||
}
|
||||
414
Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs
Normal file
414
Source/LibationUiBase/ProcessQueue/ProcessBookViewModelBase.cs
Normal 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
|
||||
}
|
||||
235
Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs
Normal file
235
Source/LibationUiBase/ProcessQueue/ProcessQueueViewModelBase.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
33
Source/LibationUiBase/ReactiveObject.cs
Normal file
33
Source/LibationUiBase/ReactiveObject.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -6,11 +6,12 @@ using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public class UpgradeEventArgs
|
||||
{
|
||||
public UpgradeProperties UpgradeProperties { get; internal init; }
|
||||
public required UpgradeProperties UpgradeProperties { get; init; }
|
||||
public bool CapUpgrade { get; internal init; }
|
||||
private bool _ignore = false;
|
||||
private bool _installUpgrade = true;
|
||||
@@ -34,18 +35,147 @@ namespace LibationUiBase
|
||||
}
|
||||
}
|
||||
|
||||
public class Upgrader
|
||||
public class Upgrader : UpgraderBase
|
||||
{
|
||||
public event EventHandler DownloadBegin;
|
||||
public event EventHandler<DownloadProgress> DownloadProgress;
|
||||
public event EventHandler<bool> DownloadCompleted;
|
||||
protected override async Task<UpgradeProperties?> CheckForUpgradeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await Task.Run(LibationScaffolding.GetLatestRelease);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
string message = "An error occurred while checking for app upgrades.";
|
||||
Serilog.Log.Logger.Error(ex, message);
|
||||
OnUpgradeFailed(message, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task<string?> DownloadUpgradeAsync(UpgradeProperties upgradeProperties)
|
||||
{
|
||||
if (upgradeProperties.ZipUrl is null)
|
||||
{
|
||||
string message = "Download link for new version not found.";
|
||||
Serilog.Log.Logger.Warning(message);
|
||||
OnUpgradeFailed(message, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
//Silently download the upgrade in the background, save it to a temp file.
|
||||
|
||||
var zipFile = Path.Combine(Path.GetTempPath(), Path.GetFileName(upgradeProperties.ZipUrl));
|
||||
|
||||
Serilog.Log.Logger.Information($"Downloading {zipFile}");
|
||||
|
||||
try
|
||||
{
|
||||
using var dlClient = new HttpClient();
|
||||
using var response = await dlClient.GetAsync(upgradeProperties.ZipUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
using var dlStream = await response.Content.ReadAsStreamAsync();
|
||||
using var tempFile = File.OpenWrite(zipFile);
|
||||
|
||||
int read;
|
||||
long totalRead = 0;
|
||||
Memory<byte> buffer = new byte[128 * 1024];
|
||||
long contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||
|
||||
while ((read = await dlStream.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
await tempFile.WriteAsync(buffer[..read]);
|
||||
totalRead += read;
|
||||
|
||||
OnDownloadProgress(
|
||||
new DownloadProgress
|
||||
{
|
||||
BytesReceived = totalRead,
|
||||
TotalBytesToReceive = contentLength,
|
||||
ProgressPercentage = contentLength > 0 ? 100d * totalRead / contentLength : 0
|
||||
});
|
||||
}
|
||||
|
||||
return zipFile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var message = $"Failed to download the upgrade: {upgradeProperties.ZipUrl}";
|
||||
Serilog.Log.Logger.Error(ex, message);
|
||||
OnUpgradeFailed(message, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MockUpgrader : UpgraderBase
|
||||
{
|
||||
public int DownloadTimeMs { get; set; } = 3000;
|
||||
public int DownloadSizeInBytes { get; set; } = 150 * 1024 * 1024;
|
||||
public bool CheckForUpgradeSucceeds { get; set; } = true;
|
||||
public bool DownloadUpgradeSucceeds { get; set; } = true;
|
||||
public string? MockUpgradeBundle { get; set; }
|
||||
|
||||
protected override Task<UpgradeProperties?> CheckForUpgradeAsync()
|
||||
{
|
||||
if (!CheckForUpgradeSucceeds)
|
||||
{
|
||||
OnUpgradeFailed("Mock Check For Upgrade Failed", null);
|
||||
return Task.FromResult<UpgradeProperties?>(null);
|
||||
}
|
||||
return Task.FromResult<UpgradeProperties?>(new UpgradeProperties(
|
||||
"http://fake.url/to/bundle.zip",
|
||||
"",
|
||||
Path.GetFileName(MockUpgradeBundle),
|
||||
LibationScaffolding.BuildVersion,
|
||||
"<RELEASE NOTES>"));
|
||||
}
|
||||
|
||||
protected override async Task<string?> DownloadUpgradeAsync(UpgradeProperties upgradeProperties)
|
||||
{
|
||||
if (!File.Exists(MockUpgradeBundle))
|
||||
{
|
||||
OnUpgradeFailed("Mock Download bundle file not found", null);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (int i = 1; i <= 100; i++)
|
||||
{
|
||||
await Task.Delay(DownloadTimeMs / 100);
|
||||
OnDownloadProgress(new()
|
||||
{
|
||||
BytesReceived = DownloadSizeInBytes / 100,
|
||||
ProgressPercentage = i,
|
||||
TotalBytesToReceive = DownloadSizeInBytes * i / 100
|
||||
});
|
||||
}
|
||||
if (!DownloadUpgradeSucceeds)
|
||||
{
|
||||
OnUpgradeFailed("Mock Download Upgrade Failed", null);
|
||||
return null;
|
||||
}
|
||||
|
||||
return MockUpgradeBundle;
|
||||
}
|
||||
}
|
||||
|
||||
public abstract class UpgraderBase
|
||||
{
|
||||
public event EventHandler? DownloadBegin;
|
||||
public event EventHandler<DownloadProgress>? DownloadProgress;
|
||||
public event EventHandler<bool>? DownloadCompleted;
|
||||
public event EventHandler<string>? UpgradeFailed;
|
||||
|
||||
protected void OnDownloadProgress(DownloadProgress args) => DownloadProgress?.Invoke(this, args);
|
||||
protected void OnUpgradeFailed(string message, Exception? ex)
|
||||
=> UpgradeFailed?.Invoke(this, (message + Environment.NewLine + Environment.NewLine + ex?.Message).Trim());
|
||||
protected abstract Task<UpgradeProperties?> CheckForUpgradeAsync();
|
||||
protected abstract Task<string?> DownloadUpgradeAsync(UpgradeProperties upgradeProperties);
|
||||
|
||||
public async Task CheckForUpgradeAsync(Func<UpgradeEventArgs, Task> upgradeAvailableHandler)
|
||||
{
|
||||
try
|
||||
{
|
||||
var upgradeProperties = await Task.Run(LibationScaffolding.GetLatestRelease);
|
||||
if (upgradeProperties is null) return;
|
||||
if (await CheckForUpgradeAsync() is not UpgradeProperties upgradeProperties)
|
||||
return;
|
||||
|
||||
const string ignoreUpgrade = "IgnoreUpgrade";
|
||||
var config = Configuration.Instance;
|
||||
@@ -73,7 +203,7 @@ namespace LibationUiBase
|
||||
|
||||
//Download the upgrade file in the background,
|
||||
DownloadBegin?.Invoke(this, EventArgs.Empty);
|
||||
string upgradeBundle = await DownloadUpgradeAsync(upgradeProperties);
|
||||
string? upgradeBundle = await DownloadUpgradeAsync(upgradeProperties);
|
||||
|
||||
if (string.IsNullOrEmpty(upgradeBundle) || !File.Exists(upgradeBundle))
|
||||
{
|
||||
@@ -91,57 +221,9 @@ namespace LibationUiBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "An error occured while checking for app upgrades.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> DownloadUpgradeAsync(UpgradeProperties upgradeProperties)
|
||||
{
|
||||
if (upgradeProperties.ZipUrl is null)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Download link for new version not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
//Silently download the upgrade in the background, save it to a temp file.
|
||||
|
||||
var zipFile = Path.Combine(Path.GetTempPath(), Path.GetFileName(upgradeProperties.ZipUrl));
|
||||
|
||||
Serilog.Log.Logger.Information($"Downloading {zipFile}");
|
||||
|
||||
try
|
||||
{
|
||||
using var dlClient = new HttpClient();
|
||||
using var response = await dlClient.GetAsync(upgradeProperties.ZipUrl, HttpCompletionOption.ResponseHeadersRead);
|
||||
using var dlStream = await response.Content.ReadAsStreamAsync();
|
||||
using var tempFile = File.OpenWrite(zipFile);
|
||||
|
||||
int read;
|
||||
long totalRead = 0;
|
||||
Memory<byte> buffer = new byte[128 * 1024];
|
||||
long contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||
|
||||
while ((read = await dlStream.ReadAsync(buffer)) > 0)
|
||||
{
|
||||
await tempFile.WriteAsync(buffer[..read]);
|
||||
totalRead += read;
|
||||
|
||||
DownloadProgress?.Invoke(
|
||||
this,
|
||||
new DownloadProgress
|
||||
{
|
||||
BytesReceived = totalRead,
|
||||
TotalBytesToReceive = contentLength,
|
||||
ProgressPercentage = contentLength > 0 ? 100d * totalRead / contentLength : 0
|
||||
});
|
||||
}
|
||||
|
||||
return zipFile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to download the upgrade: {bundle}", upgradeProperties.ZipUrl);
|
||||
return null;
|
||||
var message = "An error occurred while checking for app upgrades.";
|
||||
Serilog.Log.Logger.Error(ex, message);
|
||||
OnUpgradeFailed(message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,11 @@ namespace LibationWinForms.Login
|
||||
{
|
||||
public class WinformLoginChoiceEager : WinformLoginBase, ILoginChoiceEager
|
||||
{
|
||||
/// <summary>Convenience method. Recommended when wiring up Winforms to <see cref="ApplicationServices.LibraryCommands.ImportAccountAsync"/></summary>
|
||||
public static Func<Account, Task<ApiExtended>> CreateApiExtendedFunc(IWin32Window owner) => a => ApiExtendedFunc(a, owner);
|
||||
|
||||
private static async Task<ApiExtended> ApiExtendedFunc(Account account, IWin32Window owner)
|
||||
=> await ApiExtended.CreateAsync(account, new WinformLoginChoiceEager(account, owner));
|
||||
|
||||
public ILoginCallback LoginCallback { get; private set; }
|
||||
|
||||
private Account _account { get; }
|
||||
|
||||
private WinformLoginChoiceEager(Account account, IWin32Window owner) : base(owner)
|
||||
public WinformLoginChoiceEager(Account account, IWin32Window owner) : base(owner)
|
||||
{
|
||||
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
LoginCallback = new WinformLoginCallback(_account, owner);
|
||||
|
||||
@@ -206,10 +206,28 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"Your must remove account(s) from Libation and then re-add them to enable widwvine content.",
|
||||
var choice = MessageBox.Show(this,
|
||||
"In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" +
|
||||
"Do you want Libation to clear your current account settings and prompt you to login before the next download?",
|
||||
"Widevine Content Unavailable",
|
||||
MessageBoxButtons.OK);
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question,
|
||||
MessageBoxDefaultButton.Button2);
|
||||
|
||||
if (choice == DialogResult.Yes)
|
||||
{
|
||||
foreach (var account in accounts.AccountsSettings.Accounts.ToArray())
|
||||
{
|
||||
if (account.IdentityTokens.DeviceType != AudibleApi.Resources.DeviceType)
|
||||
{
|
||||
accounts.AccountsSettings.Delete(account);
|
||||
var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name);
|
||||
acc.AccountName = account.AccountName;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
useWidevineCbox.Checked = false;
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DataLayer;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -11,19 +12,13 @@ namespace LibationWinForms
|
||||
private void Configure_Liberate() { }
|
||||
|
||||
//GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread
|
||||
private void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null)
|
||||
private async void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
SetQueueCollapseState(false);
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up all library books");
|
||||
|
||||
processBookQueue1.AddDownloadDecrypt(
|
||||
ApplicationServices.DbContexts
|
||||
.GetLibrary_Flat_NoTracking()
|
||||
.UnLiberated()
|
||||
);
|
||||
var unliberated = await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
|
||||
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated))
|
||||
SetQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -33,9 +28,8 @@ namespace LibationWinForms
|
||||
|
||||
private async void beginPdfBackupsToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
{
|
||||
SetQueueCollapseState(false);
|
||||
await Task.Run(() => processBookQueue1.AddDownloadPdf(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()
|
||||
.Where(lb => lb.Book.UserDefinedItem.PdfStatus is DataLayer.LiberatedStatus.NotLiberated)));
|
||||
if (processBookQueue1.ViewModel.QueueDownloadPdf(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking())))
|
||||
SetQueueCollapseState(false);
|
||||
}
|
||||
|
||||
private async void convertAllM4bToMp3ToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
@@ -48,13 +42,8 @@ namespace LibationWinForms
|
||||
"Convert all M4b => Mp3?",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning);
|
||||
if (result == DialogResult.Yes)
|
||||
{
|
||||
if (result == DialogResult.Yes && processBookQueue1.ViewModel.QueueConvertToMp3(await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking())))
|
||||
SetQueueCollapseState(false);
|
||||
await Task.Run(() => processBookQueue1.AddConvertMp3(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()
|
||||
.Where(lb => lb.Book.UserDefinedItem.BookStatus is DataLayer.LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product)));
|
||||
}
|
||||
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using LibationUiBase.GridView;
|
||||
using LibationWinForms.ProcessQueue;
|
||||
using System;
|
||||
@@ -14,7 +16,7 @@ namespace LibationWinForms
|
||||
int WidthChange = 0;
|
||||
private void Configure_ProcessQueue()
|
||||
{
|
||||
processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut;
|
||||
processBookQueue1.PopoutButton.Click += ProcessBookQueue1_PopOut;
|
||||
|
||||
WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth;
|
||||
int width = this.Width;
|
||||
@@ -27,49 +29,16 @@ namespace LibationWinForms
|
||||
{
|
||||
try
|
||||
{
|
||||
if (libraryBooks.Length == 1)
|
||||
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(libraryBooks))
|
||||
SetQueueCollapseState(false);
|
||||
else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists())
|
||||
{
|
||||
var item = libraryBooks[0];
|
||||
|
||||
//Remove this item from the queue if it's already present and completed.
|
||||
//Only do this when adding a single book at a time to prevent accidental
|
||||
//extra downloads when queueing in batches.
|
||||
processBookQueue1.RemoveCompleted(item);
|
||||
|
||||
if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||
// liberated: open explorer to file
|
||||
var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId);
|
||||
if (!Go.To.File(filePath?.ShortPathName))
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item);
|
||||
SetQueueCollapseState(false);
|
||||
processBookQueue1.AddDownloadDecrypt(item);
|
||||
}
|
||||
else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
|
||||
SetQueueCollapseState(false);
|
||||
processBookQueue1.AddDownloadPdf(item);
|
||||
}
|
||||
else if (item.Book.Audio_Exists())
|
||||
{
|
||||
// liberated: open explorer to file
|
||||
var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId);
|
||||
if (!Go.To.File(filePath?.ShortPathName))
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
|
||||
MessageBox.Show($"File not found" + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var toLiberate
|
||||
= libraryBooks
|
||||
.Where(x => x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
|
||||
.ToArray();
|
||||
|
||||
if (toLiberate.Length > 0)
|
||||
{
|
||||
SetQueueCollapseState(false);
|
||||
processBookQueue1.AddDownloadDecrypt(toLiberate);
|
||||
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
|
||||
MessageBox.Show($"File not found" + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,11 +52,10 @@ namespace LibationWinForms
|
||||
{
|
||||
try
|
||||
{
|
||||
SetQueueCollapseState(false);
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
|
||||
|
||||
processBookQueue1.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated());
|
||||
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
|
||||
SetQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -99,13 +67,8 @@ namespace LibationWinForms
|
||||
{
|
||||
try
|
||||
{
|
||||
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray();
|
||||
if (preLiberated.Length > 0)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
|
||||
if (processBookQueue1.ViewModel.QueueConvertToMp3(libraryBooks))
|
||||
SetQueueCollapseState(false);
|
||||
processBookQueue1.AddConvertMp3(preLiberated);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -124,10 +87,14 @@ namespace LibationWinForms
|
||||
}
|
||||
else if (!collapsed && splitContainer1.Panel2Collapsed)
|
||||
{
|
||||
if (!processBookQueue1.PopoutButton.Visible)
|
||||
//Queue is in popout mode. Do nothing.
|
||||
return;
|
||||
|
||||
Width += WidthChange;
|
||||
splitContainer1.Panel2.Controls.Add(processBookQueue1);
|
||||
splitContainer1.Panel2Collapsed = false;
|
||||
processBookQueue1.popoutBtn.Visible = true;
|
||||
processBookQueue1.PopoutButton.Visible = true;
|
||||
}
|
||||
|
||||
Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed));
|
||||
@@ -147,7 +114,7 @@ namespace LibationWinForms
|
||||
dockForm.FormClosing += DockForm_FormClosing;
|
||||
splitContainer1.Panel2.Controls.Remove(processBookQueue1);
|
||||
splitContainer1.Panel2Collapsed = true;
|
||||
processBookQueue1.popoutBtn.Visible = false;
|
||||
processBookQueue1.PopoutButton.Visible = false;
|
||||
dockForm.PassControl(processBookQueue1);
|
||||
dockForm.Show();
|
||||
this.Width -= dockForm.WidthChange;
|
||||
@@ -164,7 +131,7 @@ namespace LibationWinForms
|
||||
this.Width += dockForm.WidthChange;
|
||||
splitContainer1.Panel2.Controls.Add(dockForm.RegainControl());
|
||||
splitContainer1.Panel2Collapsed = false;
|
||||
processBookQueue1.popoutBtn.Visible = true;
|
||||
processBookQueue1.PopoutButton.Visible = true;
|
||||
dockForm.SaveSizeAndLocation(Configuration.Instance);
|
||||
this.Focus();
|
||||
toggleQueueHideBtn.Visible = true;
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace LibationWinForms
|
||||
// in autoScan, new books SHALL NOT show dialog
|
||||
try
|
||||
{
|
||||
Task importAsync() => LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), accounts);
|
||||
Task importAsync() => LibraryCommands.ImportAccountAsync(accounts);
|
||||
if (InvokeRequired)
|
||||
await Invoke(importAsync);
|
||||
else
|
||||
|
||||
@@ -74,7 +74,7 @@ namespace LibationWinForms
|
||||
{
|
||||
try
|
||||
{
|
||||
var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), accounts);
|
||||
var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(accounts);
|
||||
|
||||
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
|
||||
if (Configuration.Instance.ShowImportedStats && newAdded > 0)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using LibationUiBase;
|
||||
using LibationWinForms.Dialogs;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace LibationWinForms
|
||||
{
|
||||
@@ -9,22 +10,25 @@ namespace LibationWinForms
|
||||
private void Configure_Upgrade()
|
||||
{
|
||||
setProgressVisible(false);
|
||||
#if !DEBUG
|
||||
#pragma warning disable CS8321 // Local function is declared but never used
|
||||
Task upgradeAvailable(UpgradeEventArgs e)
|
||||
{
|
||||
var notificationResult = new UpgradeNotificationDialog(e.UpgradeProperties).ShowDialog(this);
|
||||
|
||||
e.Ignore = notificationResult == System.Windows.Forms.DialogResult.Ignore;
|
||||
e.InstallUpgrade = notificationResult == System.Windows.Forms.DialogResult.Yes;
|
||||
e.Ignore = notificationResult == DialogResult.Ignore;
|
||||
e.InstallUpgrade = notificationResult == DialogResult.Yes;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
#pragma warning restore CS8321 // Local function is declared but never used
|
||||
|
||||
var upgrader = new Upgrader();
|
||||
upgrader.DownloadProgress += (_, e) => Invoke(() => upgradePb.Value = int.Max(0, int.Min(100, (int)(e.ProgressPercentage ?? 0))));
|
||||
upgrader.DownloadBegin += (_, _) => Invoke(() => setProgressVisible(true));
|
||||
upgrader.DownloadCompleted += (_, _) => Invoke(() => setProgressVisible(false));
|
||||
upgrader.UpgradeFailed += (_, message) => Invoke(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); });
|
||||
|
||||
#if !DEBUG
|
||||
Shown += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Windows.Forms;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core.Threading;
|
||||
using LibationUiBase;
|
||||
using LibationWinForms.Dialogs;
|
||||
|
||||
namespace LibationWinForms
|
||||
@@ -25,9 +26,17 @@ namespace LibationWinForms
|
||||
}
|
||||
private async void setLiberatedVisibleMenuItemAsync(object _, object __)
|
||||
=> await Task.Run(setLiberatedVisibleMenuItem);
|
||||
|
||||
private static DateTime lastVisibleCountUpdated;
|
||||
void setLiberatedVisibleMenuItem()
|
||||
{
|
||||
//Assume that all calls to update arrive in order,
|
||||
//Only display results of the latest book count.
|
||||
var updaterTime = lastVisibleCountUpdated = DateTime.UtcNow;
|
||||
var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible());
|
||||
if (updaterTime < lastVisibleCountUpdated)
|
||||
return;
|
||||
|
||||
this.UIThreadSync(() =>
|
||||
{
|
||||
if (libraryStats.HasPendingBooks)
|
||||
@@ -50,15 +59,8 @@ namespace LibationWinForms
|
||||
{
|
||||
try
|
||||
{
|
||||
SetQueueCollapseState(false);
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up visible library books");
|
||||
|
||||
processBookQueue1.AddDownloadDecrypt(
|
||||
productsDisplay
|
||||
.GetVisible()
|
||||
.UnLiberated()
|
||||
);
|
||||
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(productsDisplay.GetVisible().UnLiberated().ToArray()))
|
||||
SetQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -4,8 +4,11 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using LibationWinForms.Login;
|
||||
using Octokit;
|
||||
|
||||
namespace LibationWinForms
|
||||
{
|
||||
@@ -56,6 +59,7 @@ namespace LibationWinForms
|
||||
=> Invoke(() => productsDisplay.DisplayAsync(fullLibrary));
|
||||
}
|
||||
Shown += Form1_Shown;
|
||||
ApiExtended.LoginChoiceFactory = account => new WinformLoginChoiceEager(account, this);
|
||||
}
|
||||
|
||||
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
|
||||
|
||||
@@ -351,7 +351,7 @@ namespace LibationWinForms.GridView
|
||||
.Select(lbe => lbe.LibraryBook)
|
||||
.Where(lb => !lb.Book.HasLiberated());
|
||||
|
||||
var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), lib, accounts);
|
||||
var removedBooks = await LibraryCommands.FindInactiveBooks(lib, accounts);
|
||||
|
||||
var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]";
|
||||
}
|
||||
}
|
||||
|
||||
14
Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs
Normal file
14
Source/LibationWinForms/ProcessQueue/ProcessBookViewModel.cs
Normal 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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user