Compare commits

...

88 Commits

Author SHA1 Message Date
rmcrackan
f0d7a7bf64 incr ver 2025-07-18 07:19:09 -04:00
rmcrackan
8bc098e7bd Merge pull request #1303 from Mbucari/master
Fix upgrade bug when Libation's working dir isn't program files dir
2025-07-18 07:16:46 -04:00
Michael Bucari-Tovo
9280b29512 Fix upgrade bug when Libation's working dir isn't program files dir
Add MockUpgrader for testing the Upgrade process.
Fixes issue #1302
2025-07-17 13:10:42 -06:00
rmcrackan
d8e9b9c505 incr ver 2025-07-17 08:07:08 -04:00
rmcrackan
554b308364 Merge pull request #1299 from Mbucari/master
Bugfixes and minor improvements
2025-07-17 08:04:43 -04:00
MBucari
8d7872a376 UI tweak and optimization 2025-07-16 23:31:34 -06:00
MBucari
747451d243 Refactor Classic process queue
The queue is now more MVVM-like.
2025-07-16 22:58:03 -06:00
MBucari
7e79e98771 Fix possible cross-threading errors with MessageBoxBase 2025-07-16 22:57:25 -06:00
Michael Bucari-Tovo
4b7939541a Code cleanup and refactoring for clarity 2025-07-16 22:55:57 -06:00
MBucari
a3734c76b1 Use SynchronizeInvoker's Invoke() method. 2025-07-15 23:22:42 -06:00
MBucari
ced4ea6c17 Improve sorting by Liberate status by grouping books with PDFs 2025-07-15 22:50:53 -06:00
MBucari
35ca6f2621 Use built-in comparer and ReactiveObject types 2025-07-15 22:50:28 -06:00
MBucari
4dab16837e Move ProcessQueueViewModel logic into LibationUiBase
Fix UI bug in classic when queue is in popped-out mode.
2025-07-15 22:31:17 -06:00
MBucari
1cf889eed7 Move ProcessBookViewModel logic into LiationUiBase 2025-07-15 15:05:33 -06:00
MBucari
b65b1e819b Consolidate queue commands into UI base 2025-07-15 13:32:42 -06:00
MBucari
3d50643ab0 Fix visible book counts being incorrect on startup
If quick filters are applied on startup, a race condition was created between the initial library load book counting and the visible books counting. Only display results of the latest book count.
2025-07-15 11:49:20 -06:00
MBucari
abd18d74b0 Fix crash when setting drive root as custom directory (#1300) 2025-07-15 11:44:45 -06:00
MBucari
0e49df06b8 Add message box handler to LibationUiBase 2025-07-15 11:40:01 -06:00
MBucari
38cc3e9725 Revert change to release title 2025-07-15 08:54:22 -06:00
MBucari
c9af2bba4b Reduce GitHub API calls when no upgrades are available 2025-07-14 14:43:48 -06:00
MBucari
2191c1536d Prepare Libation for win-arm64 releases
Also add support for four-part version numbers in releases.
2025-07-14 14:20:57 -06:00
MBucari
5b9bf2fbb0 Remove duplicate tests 2025-07-14 12:53:47 -06:00
MBucari
9b1ce8c1d7 Update dependencies 2025-07-14 12:43:53 -06:00
MBucari
9f8075041b Only remove a LibraryBook from queue if we are trying to re-download. 2025-07-14 12:42:05 -06:00
MBucari
944645379e Fix message box text truncation when there is no icon (#1294) 2025-07-14 12:19:26 -06:00
Mbucari
cc72517284 Merge branch 'rmcrackan:master' into master 2025-07-14 11:45:44 -06:00
rmcrackan
0044820415 Update README.md 2025-07-07 16:31:09 -04:00
rmcrackan
9f24027de1 Update README.md 2025-07-07 16:29:46 -04:00
rmcrackan
24f95cb03d Update GettingStarted.md 2025-07-07 16:27:59 -04:00
rmcrackan
3aeea54615 Update FrequentlyAskedQuestions.md 2025-07-07 16:26:10 -04:00
rmcrackan
f511041781 Create a cue sheet: default false 2025-06-25 12:43:50 -04:00
rmcrackan
da9dc91469 incr ver for docker enhancement 2025-06-25 06:58:14 -04:00
rmcrackan
e04e70d333 Merge pull request #1265 from vipervire/master
Update Books directory to use LIBATION_BOOKS_DIR if populated
2025-06-25 06:57:01 -04:00
rmcrackan
e0b566ee60 Merge pull request #1277 from dev-nicolaos/patch-1
Update deb/rpm Installation Instructions
2025-06-24 07:50:35 -04:00
Nicolaos Skimas
bf15d7302e Update Deb/RHEL/Fed Installation Instructions 2025-06-23 22:39:45 -07:00
rmcrackan
8f01c644c0 Update bug_report.md 2025-06-19 07:21:21 -04:00
Mbucari
ebd2cc96c5 Merge branch 'rmcrackan:master' into master 2025-06-18 12:13:14 -06:00
rmcrackan
0d1cc42ca7 Bugfix #1269 : Chardonnay. Bad filter string causes infinite loop 2025-06-16 13:19:48 -04:00
vipervire
e126dd09ce Update Books directory to use LIBATION_BOOKS_DIR if populated 2025-06-05 23:26:52 +00:00
Michael Bucari-Tovo
ec497f4f81 Use virtualized list to improve large queue performance 2025-05-19 10:40:41 -06:00
rmcrackan
248fdfd2bc Probably unnecessary paranoid incr ver. Everything looks correct but I've never actually released relying on the ver's 4th part. I'm incrementing just in case 2025-05-10 16:53:04 -04:00
MBucari
35862d619a Increment version 2025-05-09 21:10:38 -06:00
Mbucari
ac2c67985d Merge pull request #1253 from Mbucari/master
Fix download error (#1252 )

I'm merging this one and releasing ASAP.
2025-05-09 21:07:59 -06:00
MBucari
f8ae303417 Fix download error (#1252 ) 2025-05-09 21:07:01 -06:00
rmcrackan
0d24caeac2 incr ver 2025-05-09 21:10:19 -04:00
rmcrackan
7f1b357c52 Merge pull request #1250 from Mbucari/master
Bug fixes and a change to license request logic
2025-05-09 21:08:19 -04:00
Michael Bucari-Tovo
ef67ae9d6a Ask users to clear the accounts when enabling widevine (#1249) 2025-05-09 17:52:14 -06:00
Michael Bucari-Tovo
f35c82d59d Change ApiExtended to always allow provide login option
Previously, only some calls to ApiExtended.CreateAsync() would prompt users to login if necessary. Other calls would only work if the account already had a valid identity, and they would throw exceptions otherwise.

Changed ApiExtended so that the UI registers a static ILoginChoiceEager factory delegate that ApiExtended will use in the event that a login is required.
2025-05-09 17:32:12 -06:00
Michael Bucari-Tovo
10c01f4147 Fix occasional error of audio downloads hanging. 2025-05-09 16:32:59 -06:00
Michael Bucari-Tovo
9366b3baca Default to E-AC-3 spatial audio format. 2025-05-09 13:39:59 -06:00
Michael Bucari-Tovo
20e792c589 Always change the last chapter's length to coincide with the end of the audio file. 2025-05-09 13:36:07 -06:00
Michael Bucari-Tovo
dfb63d3275 Add contributor 2025-05-09 13:15:18 -06:00
Michael Bucari-Tovo
19db226f5a Use Libation settings to decide which DRM is downloaded. 2025-05-09 13:13:39 -06:00
Mbucari
203ab00865 Merge branch 'rmcrackan:master' into master 2025-05-08 12:15:26 -06:00
MBucari
b11a4887d7 Pad final chapter to prevent tuncation from incorrect chapter info (#1246) 2025-05-08 12:13:55 -06:00
rmcrackan
e73fc5e1eb Merge pull request #1247 from starry-shivam/patch-2
Small typo fix in DownloadOptions.Factory.cs
2025-05-08 07:12:42 -04:00
Stɑrry Shivɑm
8561a15061 Small typo fix in DownloadOptions.Factory.cs 2025-05-08 10:48:02 +05:30
MBucari
28ba62aead Fix dash files not being saved (#1236) 2025-05-07 23:15:44 -06:00
rmcrackan
176294cc55 Merge pull request #1245 from Mbucari/master
Minor bugfixes
2025-05-07 19:28:45 -04:00
Michael Bucari-Tovo
152b0e362d Update message box icons 2025-05-07 16:10:03 -06:00
Michael Bucari-Tovo
4600d029dc Re-add converter resource inadvertantly removed in 0df17a22 2025-05-07 14:23:58 -06:00
Michael Bucari-Tovo
1a5684799c Update Hangover styles and behaviors 2025-05-07 13:16:44 -06:00
Michael Bucari-Tovo
0df17a2296 Remove retired ItemsRepeater control 2025-05-07 13:12:12 -06:00
Michael Bucari-Tovo
45472abd1f Update dependencies 2025-05-07 11:15:32 -06:00
Mbucari
f2ea4539f2 Merge branch 'rmcrackan:master' into master 2025-05-07 11:13:32 -06:00
Michael Bucari-Tovo
52d3b9cb67 Disable warning 2025-05-07 11:13:26 -06:00
rmcrackan
3d87f2cd9b Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-07 12:39:10 -04:00
rmcrackan
e4a3d2ac79 better logging for api errors #1240 2025-05-07 12:39:02 -04:00
Michael Bucari-Tovo
8aa157f2f6 Re-add completed audiobooks to queue (#1219) 2025-05-06 15:43:58 -06:00
Michael Bucari-Tovo
5ab6c1fe70 Update AAXClean to fix metadata reader (#1243 ) 2025-05-06 15:33:38 -06:00
Michael Bucari-Tovo
b23c46f79f Fix incorrect chapters in some audiobooks (#1210) 2025-05-06 15:32:59 -06:00
Mbucari
0e987eef00 Fix error in download speed throttle (#1242) 2025-05-06 14:48:40 -06:00
rmcrackan
ace3d80e41 Merge pull request #1241 from cherez/patch-1
Fixed doubled first name in templates
2025-05-06 16:29:29 -04:00
Mbucari
4bfb4e73ce Fix aax file getting inadvertently deleted (#1236) 2025-05-06 12:45:43 -06:00
Steven Wallace
7805a3ef11 Fixed broken single word name test
This expected the name duplication that the previous commit fixed to be the behavior, changed to expect the single word to be the last name.
2025-05-06 09:58:09 -05:00
Steven Wallace
08ca2a2db3 Fixed doubled first name in templates
v12.3.0 caused a regression with contributors with a single word name, causing the name to be doubled. This was caused by using that name as both the first and last name, so swap the first name with the (blank) last name rather than duplicate them.
2025-05-05 10:37:28 -05:00
rmcrackan
64a85b6aab Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-02 22:15:36 -04:00
rmcrackan
1a38273d5f incr ver 2025-05-02 22:15:32 -04:00
rmcrackan
303dd7c471 Merge pull request #1233 from Mbucari/master
Bugfixes and Feature Requests
2025-05-02 22:14:33 -04:00
MBucari
313e3846c3 Remove AudioFormat from library book exporter (5f455182) 2025-05-02 15:39:47 -06:00
Michael Bucari-Tovo
422c86345e Add logging 2025-05-02 14:50:33 -06:00
Michael Bucari-Tovo
ce952417fb Don't replace library properties in queued item with null/empty 2025-05-02 13:07:53 -06:00
Michael Bucari-Tovo
5f4551822b Remove Book.AudioFormat property
This property was set to the highest quality returned by the library scan. Since adding quality option settings, it is no longer guaranteed to reflect the file that is downloaded. Also, the library scan qualities don't contain spatial audio or widevine-specific qualities., only ADRM.
2025-05-02 12:39:12 -06:00
Michael Bucari-Tovo
3aebc7c885 Improve download performance. 2025-05-02 12:19:32 -06:00
Michael Bucari-Tovo
3982edd0f1 Add codec tag and use real bitrate/samplerate (#1227) 2025-05-02 11:20:58 -06:00
Michael Bucari-Tovo
f4dafac28f Try to solve #1226 2025-05-01 13:19:03 -06:00
Michael Bucari-Tovo
1090d29f74 Add fine-grained options for downloading widevine content 2025-05-01 13:03:03 -06:00
rmcrackan
1c336e1fe9 bug fix 2025-04-28 18:55:20 -04:00
155 changed files with 2551 additions and 2817 deletions

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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"

View File

@@ -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.

View File

@@ -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](images/Export.png)
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.

View File

@@ -8,23 +8,23 @@
[![Packaging status](https://repology.org/badge/vertical-allrepos/libation.svg)](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
```
---

View File

@@ -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)

View File

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

View File

@@ -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.");
@@ -103,8 +117,8 @@ namespace AaxDecrypter
OnInitialized();
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
return !IsCanceled;

View File

@@ -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,23 +193,28 @@ 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}");
FileUtility.SaferMove(tempFilePath, aaxPath);
if (tempFilePath != aaxPath)
FileUtility.SaferMove(tempFilePath, aaxPath);
OnFileCreated(aaxPath);
OnFileCreated(keyPath);

View File

@@ -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; }

View File

@@ -61,9 +61,6 @@ namespace AaxDecrypter
#region Constants
//Size of each range request. Android app uses 64MB chunks.
private const int RANGE_REQUEST_SZ = 64 * 1024 * 1024;
//Download memory buffer size
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
@@ -113,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);
}
}
@@ -161,7 +160,7 @@ namespace AaxDecrypter
//Initiate connection with the first request block and
//get the total content length before returning.
using var client = new HttpClient();
var client = new HttpClient();
var response = await RequestNextByteRangeAsync(client);
if (ContentLength != 0 && ContentLength != response.FileSize)
@@ -170,38 +169,59 @@ namespace AaxDecrypter
ContentLength = response.FileSize;
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Hand off the open request to the downloader to download and write data to file.
DownloadTask = Task.Run(() => DownloadLoopInternal(response), _cancellationSource.Token);
//Hand off the client and the open request to the downloader to download and write data to file.
DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token);
}
private async Task DownloadLoopInternal(BlockResponse initialResponse)
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
{
await DownloadToFile(initialResponse);
initialResponse.Dispose();
try
{
using var client = new HttpClient();
long startPosition = WritePosition;
while (WritePosition < ContentLength && !IsCancelled)
{
using var response = await RequestNextByteRangeAsync(client);
await DownloadToFile(response);
try
{
await DownloadToFile(blockResponse);
}
catch (HttpIOException e)
when (e.HttpRequestError is HttpRequestError.ResponseEnded
&& WritePosition != startPosition
&& WritePosition < ContentLength && !IsCancelled)
{
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
//the download made *some* progress since the last attempt.
//Try again to complete the download from where it left off.
//Make sure to rewind file to last flush position.
_writeFile.Position = startPosition = WritePosition;
blockResponse.Dispose();
blockResponse = await RequestNextByteRangeAsync(client);
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
}
}
}
finally
{
_writeFile.Close();
_writeFile.Dispose();
blockResponse.Dispose();
client.Dispose();
}
}
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
{
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
//Just in case it snuck in the saved json (Issue #1232)
RequestHeaders.Remove("Range");
foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value);
request.Headers.Add("Range", $"bytes={WritePosition}-{WritePosition + RANGE_REQUEST_SZ - 1}");
request.Headers.Add("Range", $"bytes={WritePosition}-");
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
@@ -226,7 +246,7 @@ namespace AaxDecrypter
private async Task DownloadToFile(BlockResponse block)
{
var endPosition = WritePosition + block.BlockSize;
var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
@@ -259,11 +279,11 @@ namespace AaxDecrypter
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
if (delayMS > 0)
await Task.Delay(delayMS, _cancellationSource.Token);
startTime = DateTime.Now;
startTime = DateTime.UtcNow;
bytesReadSinceThrottle = 0;
}
@@ -286,9 +306,8 @@ namespace AaxDecrypter
}
finally
{
networkStream.Close();
_downloadedPiece.Set();
OnUpdate();
OnUpdate(waitForWrite: true);
}
}
@@ -385,7 +404,7 @@ namespace AaxDecrypter
_cancellationSource?.Dispose();
_readFile.Dispose();
_writeFile.Dispose();
OnUpdate();
OnUpdate(waitForWrite: true);
}
disposed = true;

View File

@@ -2,14 +2,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.3.0.1</Version>
<Version>12.4.7.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks -->
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
</ItemGroup>
<ItemGroup>

View File

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

View File

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

View File

@@ -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));

View File

@@ -104,9 +104,6 @@ namespace ApplicationServices
[Name("Content Type")]
public string ContentType { get; set; }
[Name("Audio Format")]
public string AudioFormat { get; set; }
[Name("Language")]
public string Language { get; set; }
@@ -152,7 +149,6 @@ namespace ApplicationServices
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
AudioFormat = a.Book.AudioFormat.ToString(),
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
@@ -228,7 +224,6 @@ namespace ApplicationServices
nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
@@ -299,7 +294,6 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language);
if (dto.LastDownloaded.HasValue)

View File

@@ -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>()

View File

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.0.1" />
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
<PackageReference Include="AudibleApi" Version="9.4.1.1" />
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -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;
}

View File

@@ -19,7 +19,6 @@ namespace DataLayer.Configurations
//
entity.Ignore(nameof(Book.Authors));
entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
entity.Ignore(nameof(Book.TitleWithSubtitle));
entity.Ignore(b => b.Categories);

View File

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

View File

@@ -1,65 +0,0 @@
using System;
namespace DataLayer
{
internal enum AudioFormatEnum : long
{
//Defining the enum this way ensures that when comparing:
//LC_128_44100_stereo > LC_64_44100_stereo > LC_64_22050_stereo > LC_64_22050_stereo
//This matches how audible interprets these codecs when specifying quality using AudibleApi.DownloadQuality
//I've never seen mono formats.
Unknown = 0,
LC_32_22050_stereo = (32L << 18) | (22050 << 2) | 2,
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
AAX_22_32 = LC_32_22050_stereo,
AAX_22_64 = LC_64_22050_stereo,
AAX_44_64 = LC_64_44100_stereo,
AAX_44_128 = LC_128_44100_stereo
}
public class AudioFormat : IComparable<AudioFormat>, IComparable
{
internal int AudioFormatID { get; private set; }
public int Bitrate { get; private init; }
public int SampleRate { get; private init; }
public int Channels { get; private init; }
public bool IsValid => Bitrate != 0 && SampleRate != 0 && Channels != 0;
public static AudioFormat FromString(string formatStr)
{
if (Enum.TryParse(formatStr, ignoreCase: true, out AudioFormatEnum enumVal))
return FromEnum(enumVal);
return FromEnum(AudioFormatEnum.Unknown);
}
internal static AudioFormat FromEnum(AudioFormatEnum enumVal)
{
var val = (long)enumVal;
return new()
{
Bitrate = (int)(val >> 18),
SampleRate = (int)(val >> 2) & ushort.MaxValue,
Channels = (int)(val & 3)
};
}
internal AudioFormatEnum ToEnum()
{
var val = (AudioFormatEnum)(((long)Bitrate << 18) | ((long)SampleRate << 2) | (long)Channels);
return Enum.IsDefined(val) ?
val : AudioFormatEnum.Unknown;
}
public override string ToString()
=> IsValid ?
$"{Bitrate} Kbps, {SampleRate / 1000d:F1} kHz, {(Channels == 2 ? "Stereo" : Channels)}" :
"Unknown";
public int CompareTo(AudioFormat other) => ToEnum().CompareTo(other.ToEnum());
public int CompareTo(object obj) => CompareTo(obj as AudioFormat);
}
}

View File

@@ -43,9 +43,11 @@ namespace DataLayer
public ContentType ContentType { get; private set; }
public string Locale { get; private set; }
internal AudioFormatEnum _audioFormat;
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); }
//This field is now unused, however, there is little sense in adding a
//database migration to remove an unused field. Leave it for compatibility.
#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0
internal long _audioFormat;
#pragma warning restore CS0649
// mutable
public string PictureId { get; set; }

View File

@@ -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());
}
}

View File

@@ -154,9 +154,6 @@ namespace DtoImporterService
// Update the book titles, since formatting can change
book.UpdateTitle(item.Title, item.Subtitle);
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
book.AudioFormat = codec;
// set/update book-specific info which may have changed
if (item.PictureId is not null)
book.PictureId = item.PictureId;

View File

@@ -19,21 +19,24 @@ namespace FileLiberator
protected void OnTitleDiscovered(object _, string title)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
TitleDiscovered?.Invoke(this, title);
if (title != null)
TitleDiscovered?.Invoke(this, title);
}
protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors);
protected void OnAuthorsDiscovered(object _, string authors)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
AuthorsDiscovered?.Invoke(this, authors);
if (authors != null)
AuthorsDiscovered?.Invoke(this, authors);
}
protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators);
protected void OnNarratorsDiscovered(object _, string narrators)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
NarratorsDiscovered?.Invoke(this, narrators);
if (narrators != null)
NarratorsDiscovered?.Invoke(this, narrators);
}
protected byte[] OnRequestCoverArt()

View File

@@ -39,14 +39,20 @@ namespace FileLiberator
/// Path: in progress directory.
/// File name: final file name.
/// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file already exists

View File

@@ -47,13 +47,18 @@ namespace FileLiberator
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var config = Configuration.Instance;
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook);
bool success = false;
try
{
FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(libraryBook);
success = await downloadAudiobookAsync(api, config, downloadOptions);
}
finally
{
@@ -78,12 +83,12 @@ namespace FileLiberator
var finalStorageDir = getDestinationDirectory(libraryBook);
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
Task[] finalTasks = new[]
{
Task.Run(() => downloadCoverArt(libraryBook)),
Task[] finalTasks =
[
Task.Run(() => downloadCoverArt(downloadOptions)),
moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
};
];
try
{
@@ -116,16 +121,9 @@ namespace FileLiberator
}
}
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
{
var config = Configuration.Instance;
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
using var dlOptions = await DownloadOptions.InitiateDownloadAsync(api, libraryBook, config);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
@@ -149,7 +147,7 @@ namespace FileLiberator
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path);
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
@@ -158,12 +156,12 @@ namespace FileLiberator
{
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(libraryBook, metadataFile);
OnFileCreated(dlOptions.LibraryBook, metadataFile);
}
return success;
}
@@ -173,7 +171,30 @@ namespace FileLiberator
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
return;
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
#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));
tags.AlbumArtists ??= tags.Artist;
@@ -280,7 +301,7 @@ namespace FileLiberator
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private static void downloadCoverArt(LibraryBook libraryBook)
private static void downloadCoverArt(DownloadOptions options)
{
if (!Configuration.Instance.DownloadCoverArt) return;
@@ -288,24 +309,24 @@ namespace FileLiberator
try
{
var destinationDir = getDestinationDirectory(libraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
var destinationDir = getDestinationDirectory(options.LibraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath);
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native));
if (picBytes.Length > 0)
{
File.WriteAllBytes(coverPath, picBytes);
SetFileTime(libraryBook, coverPath);
SetFileTime(options.LibraryBook, coverPath);
}
}
catch (Exception ex)
{
//Failure to download cover art should not be considered a failure to download the book
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
}
}
}

View File

@@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
#nullable enable
@@ -23,162 +24,115 @@ public partial class DownloadOptions
/// <summary>
/// Initiate an audiobook download from the audible api.
/// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, LibraryBook libraryBook, Configuration config)
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook)
{
var license = await ChooseContent(api, libraryBook, config);
//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).
var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId);
if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs)
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
var options = BuildDownloadOptions(libraryBook, config, license);
return options;
}
private static async Task<ContentLicense> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
private class LicenseInfo
{
var cdm = await Cdm.GetCdmAsync();
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;
ContentLicense? contentLic = null;
ContentLicense? fallback = null;
if (cdm is null)
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
{
//Doesn't matter what the user chose. We can't get a CDM so we must fall back to AAX(C)
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
}
else
{
var spatial = config.FileDownloadQuality is Configuration.DownloadQuality.Spatial;
try
{
var codecChoice = config.SpatialAudioCodec switch
{
Configuration.SpatialCodec.EC_3 => Ec3Codec,
Configuration.SpatialCodec.AC_4 => Ac4Codec,
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
};
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality, ChapterTitlesType.Tree, DrmType.Widevine, spatial, codecChoice);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
}
if (contentLic is null)
{
//We failed to get a widevine license, so fall back to AAX(C)
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
}
else 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;
}
}
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
return new LicenseInfo(license);
}
if (contentLic.DrmType == DrmType.Widevine && cdm is not null)
try
{
try
{
using var client = new HttpClient();
var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
//try to request a widevine content license using the user's spatial audio settings
var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec;
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
var contentLic
= await api.GetDownloadLicenseAsync(
libraryBook.Book.AudibleProductId,
dlQuality,
ChapterTitlesType.Tree,
DrmType.Widevine,
config.RequestSpatial,
codecChoice);
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
if (contentLic.DrmType is not DrmType.Widevine)
return new LicenseInfo(contentLic);
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)
};
using var client = new HttpClient();
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
}
catch
{
if (fallback != null)
return fallback;
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
//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;
}
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 content license. Depending on the
//failure reason, users can potentially still download this audiobook
//by disabling the "Use Widevine DRM" feature.
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;
//Set the requested AudioFormat for use in file naming templates
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
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),
};
if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
{
dlOptions.LibraryBookDto.BitRate = bitrate;
dlOptions.LibraryBookDto.SampleRate = sampleRate;
dlOptions.LibraryBookDto.Channels = channels;
}
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
.OrderBy(c => c.StartOffsetMs)
.ToList();
@@ -194,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));
}
@@ -202,6 +156,43 @@ public partial class DownloadOptions
return dlOptions;
}
/// <summary>
/// The most reliable way to get these audio file properties is from the filename itself.
/// Using AAXClean to read the metadata works well for everything except AC-4 bitrate.
/// </summary>
private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels)
{
bitrate = sampleRate = channels = null;
if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri))
return false;
var file = Path.GetFileName(uri.LocalPath);
var match = AdrmAudioProperties().Match(file);
if (match.Success)
{
bitrate = int.Parse(match.Groups[1].Value);
sampleRate = int.Parse(match.Groups[2].Value);
channels = int.Parse(match.Groups[3].Value);
return true;
}
else if ((match = WidevineAudioProperties().Match(file)).Success)
{
bitrate = int.Parse(match.Groups[2].Value);
sampleRate = int.Parse(match.Groups[1].Value) * 1000;
channels = match.Groups[3].Value switch
{
"ec3" => 6,
"ac4" => 3,
_ => null
};
return true;
}
return false;
}
public static LameConfig GetLameOptions(Configuration config)
{
LameConfig lameConfig = new()
@@ -317,7 +308,7 @@ public partial class DownloadOptions
else if (titleConcat is null)
{
chaps.Add(c);
chaps.AddRange(flattenChapters(c.Chapters));
chaps.AddRange(flattenChapters(c.Chapters, titleConcat));
}
else
{
@@ -330,7 +321,7 @@ public partial class DownloadOptions
else
chaps.Add(c);
var children = flattenChapters(c.Chapters);
var children = flattenChapters(c.Chapters, titleConcat);
foreach (var child in children)
child.Title = $"{c.Title}{titleConcat}{child.Title}";
@@ -359,4 +350,9 @@ public partial class DownloadOptions
static double RelativePercentDifference(long num1, long num2)
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
[GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex WidevineAudioProperties();
[GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex AdrmAudioProperties();
}

View File

@@ -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

View File

@@ -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;
}
@@ -55,9 +61,6 @@ namespace FileLiberator
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
Channels = libraryBook.Book.AudioFormat.Channels,
Language = libraryBook.Book.Language
};
}

View File

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

View File

@@ -6,7 +6,30 @@
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme/>
</Application.Styles>
<Application.Styles>
<FluentTheme>
<FluentTheme.Palettes>
<ColorPaletteResources x:Key="Light" />
<ColorPaletteResources x:Key="Dark" />
</FluentTheme.Palettes>
</FluentTheme>
<Style Selector="TextBox[IsReadOnly=true]">
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
<Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
<Style Selector="^ /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
</Style>
</Style>
<Style Selector="Button">
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Style Selector="^">
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
</Style>
</Style>
<Style Selector="ScrollBar">
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/>
</Style>
</Application.Styles>
</Application>

View File

@@ -4,27 +4,16 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="HangoverAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
ItemsSource="{Binding CheckboxItems}"
ItemTemplate="{StaticResource elementFactory}" />
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>

View File

@@ -2,103 +2,18 @@ using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using HangoverAvalonia.ViewModels;
using ReactiveUI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace HangoverAvalonia.Controls
namespace HangoverAvalonia.Controls;
public partial class CheckedListBox : UserControl
{
public partial class CheckedListBox : UserControl
public static readonly StyledProperty<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
public CheckedListBox()
{
public event EventHandler<ItemCheckEventArgs> ItemCheck;
public static readonly StyledProperty<IEnumerable> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, IEnumerable>(nameof(Items));
public IEnumerable Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public IEnumerable<object> CheckedItems =>
_viewModel
.CheckboxItems
.Where(i => i.IsChecked)
.Select(i => i.Item);
public void SetItemChecked(int i, bool isChecked) => _viewModel.CheckboxItems[i].IsChecked = isChecked;
public void SetItemChecked(object item, bool isChecked)
{
var obj = _viewModel.CheckboxItems.SingleOrDefault(i => i.Item == item);
if (obj is not null)
obj.IsChecked = isChecked;
}
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
_viewModel.CheckedChanged += _viewModel_CheckedChanged;
}
private void _viewModel_CheckedChanged(object sender, CheckBoxViewModel e)
{
var args = new ItemCheckEventArgs { Item = e.Item, ItemIndex = _viewModel.CheckboxItems.IndexOf(e), IsChecked = e.IsChecked };
ItemCheck?.Invoke(this, args);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.SetItems(Items);
base.OnPropertyChanged(change);
}
public class CheckedListBoxViewModel : ViewModelBase
{
public event EventHandler<CheckBoxViewModel> CheckedChanged;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get; private set; }
public void SetItems(IEnumerable items)
{
UnsubscribeFromItems(CheckboxItems);
CheckboxItems = new(items.OfType<object>().Select(o => new CheckBoxViewModel { Item = o }));
SubscribeToItems(CheckboxItems);
this.RaisePropertyChanged(nameof(CheckboxItems));
}
private void SubscribeToItems(IEnumerable objects)
{
foreach (var i in objects.OfType<INotifyPropertyChanged>())
i.PropertyChanged += I_PropertyChanged;
}
private void UnsubscribeFromItems(AvaloniaList<CheckBoxViewModel> objects)
{
if (objects is null) return;
foreach (var i in objects)
i.PropertyChanged -= I_PropertyChanged;
}
private void I_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
CheckedChanged?.Invoke(this, (CheckBoxViewModel)sender);
}
}
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}
}
public class ItemCheckEventArgs : EventArgs
{
public int ItemIndex { get; init; }
public bool IsChecked { get; init; }
public object Item { get; init; }
InitializeComponent();
}
}

View File

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

View File

@@ -0,0 +1,11 @@
using ReactiveUI;
namespace HangoverAvalonia.ViewModels;
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}

View File

@@ -1,41 +1,8 @@
using ApplicationServices;
using DataLayer;
using ReactiveUI;
using System.Collections.Generic;
namespace HangoverAvalonia.ViewModels;
namespace HangoverAvalonia.ViewModels
public partial class MainVM
{
public partial class MainVM
{
private List<LibraryBook> _deletedBooks;
public List<LibraryBook> DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
public TrashBinViewModel TrashBinViewModel { get; } = new();
private int _totalBooksCount = 0;
private int _checkedBooksCount = 0;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
}
private void Load_deletedVM()
{
reload();
}
public void reload()
{
DeletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
_checkedBooksCount = 0;
_totalBooksCount = DeletedBooks.Count;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
private void Load_deletedVM() { }
}

View File

@@ -0,0 +1,117 @@
using ApplicationServices;
using Avalonia.Collections;
using DataLayer;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
public class TrashBinViewModel : ViewModelBase, IDisposable
{
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
private bool _controlsEnabled = true;
public bool ControlsEnabled { get => _controlsEnabled; set => this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
private bool? everythingChecked = false;
public bool? EverythingChecked
{
get => everythingChecked;
set
{
everythingChecked = value ?? false;
if (everythingChecked is true)
CheckAll();
else if (everythingChecked is false)
UncheckAll();
}
}
private int _totalBooksCount = 0;
private int _checkedBooksCount = -1;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
everythingChecked
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
: _checkedBooksCount == _totalBooksCount ? true
: null;
this.RaisePropertyChanged(nameof(EverythingChecked));
}
}
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
public TrashBinViewModel()
{
DeletedBooks = new()
{
ResetBehavior = ResetBehavior.Remove
};
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
Reload();
}
public void CheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = true;
}
public void UncheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = false;
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public void Reload()
{
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
_totalBooksCount = DeletedBooks.Count;
CheckedBooksCount = 0;
}
private IDisposable tracker;
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
{
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
}
public void Dispose() => tracker?.Dispose();
}

View File

@@ -1,40 +1,12 @@
using ApplicationServices;
using DataLayer;
using HangoverAvalonia.Controls;
using System.Linq;
namespace HangoverAvalonia.Views;
namespace HangoverAvalonia.Views
public partial class MainWindow
{
public partial class MainWindow
private void deletedTab_VisibleChanged(bool isVisible)
{
private void deletedTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
if (!isVisible)
return;
if (_viewModel.DeletedBooks.Count == 0)
_viewModel.reload();
}
public void Deleted_CheckedListBox_ItemCheck(object sender, ItemCheckEventArgs args)
{
_viewModel.CheckedBooksCount = deletedCbl.CheckedItems.Count();
}
public void Deleted_CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, true);
}
public void Deleted_UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, false);
}
public void Deleted_Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
var qtyChanges = libraryBooksToRestore.RestoreBooks();
if (qtyChanges > 0)
_viewModel.reload();
}
_viewModel.TrashBinViewModel.Reload();
}
}

View File

@@ -15,13 +15,11 @@
<TabControl Name="tabControl1" Grid.Row="0">
<TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="23"/>
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="33"/>
</Style>
<Style Selector="TabItem">
<Setter Property="MinHeight" Value="40"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="8,2,8,5"/>
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
<Setter Property="Height" Value="33"/>
</Style>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
@@ -51,6 +49,7 @@
<TextBox
Margin="0,5,0,5"
AcceptsReturn="True"
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
<Button
@@ -73,33 +72,58 @@
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
</TabItem.Header>
<Grid
DataContext="{Binding TrashBinViewModel}"
RowDefinitions="Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="To restore deleted book, check box and save" />
Text="Check books you want to permanently delete from or restore to Libation" />
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
Name="deletedCbl"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,Auto,*">
ColumnDefinitions="Auto,Auto,*,Auto">
<Button Grid.Column="0" Margin="0,0,20,0" Content="Check All" Click="Deleted_CheckAll_Click" />
<Button Grid.Column="1" Margin="0,0,20,0" Content="Uncheck All" Click="Deleted_UncheckAll_Click" />
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding CheckedCountText}" />
<Button Grid.Column="3" HorizontalAlignment="Right" Content="Save" Click="Deleted_Save_Click" />
<CheckBox
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,20,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Content="Restore"
Command="{Binding RestoreCheckedAsync}"/>
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Command="{Binding PermanentlyDeleteCheckedAsync}" >
<TextBlock
TextAlignment="Center"
Text="Permanently Delete&#xa;from Libation" />
</Button>
</Grid>
</Grid>
</TabItem>

View File

@@ -18,7 +18,6 @@ namespace HangoverAvalonia.Views
public void OnLoad()
{
deletedCbl.ItemCheck += Deleted_CheckedListBox_ItemCheck;
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View File

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

View File

@@ -5,26 +5,15 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LibationAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
ItemsSource="{Binding CheckboxItems}"
ItemTemplate="{StaticResource elementFactory}" />
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</UserControl>

View File

@@ -12,24 +12,10 @@ namespace LibationAvalonia.Controls
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.CheckboxItems = Items;
base.OnPropertyChanged(change);
}
private class CheckedListBoxViewModel : ViewModelBase
{
private AvaloniaList<CheckBoxViewModel> _checkboxItems;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get => _checkboxItems; set => this.RaiseAndSetIfChanged(ref _checkboxItems, value); }
}
}

View File

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

View File

@@ -43,14 +43,40 @@
<controls:WheelComboBox
Margin="5,0,0,0"
Grid.Column="1"
SelectionChanged="Quality_SelectionChanged"
ItemsSource="{CompiledBinding DownloadQualities}"
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,5,0,0"
IsEnabled="{CompiledBinding SpatialSelected}"
<Grid ColumnDefinitions="*,*">
<CheckBox
IsChecked="{CompiledBinding UseWidevine, Mode=TwoWay}"
IsCheckedChanged="UseWidevine_IsCheckedChanged"
ToolTip.Tip="{CompiledBinding UseWidevineTip}">
<TextBlock Text="{CompiledBinding UseWidevineText}" />
</CheckBox>
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
ToolTip.Tip="{CompiledBinding RequestSpatialTip}"
IsEnabled="{CompiledBinding UseWidevine}"
IsChecked="{CompiledBinding RequestSpatial, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding RequestSpatialText}" />
</CheckBox>
</Grid>
<Grid ColumnDefinitions="*,Auto"
ToolTip.Tip="{CompiledBinding SpatialAudioCodecTip}">
<Grid.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<MultiBinding.Bindings>
<CompiledBinding Path="UseWidevine"/>
<CompiledBinding Path="RequestSpatial"/>
</MultiBinding.Bindings>
</MultiBinding>
</Grid.IsEnabled>
<TextBlock
VerticalAlignment="Center"
@@ -62,6 +88,11 @@
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
</Grid>
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />

View File

@@ -4,6 +4,7 @@ using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationFileManager.Templates;
using LibationUiBase.Forms;
using System.Linq;
using System.Threading.Tasks;
@@ -22,21 +23,40 @@ namespace LibationAvalonia.Controls.Settings
}
}
public async void Quality_SelectionChanged(object sender, SelectionChangedEventArgs e)
private async void UseWidevine_IsCheckedChanged(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_viewModel.SpatialSelected)
if (sender is CheckBox cbox && cbox.IsChecked is true)
{
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
{
await MessageBox.Show(VisualRoot as Window,
"Your must remove account(s) from Libation and then re-add them to enable spatial audiobook downloads.",
"Spatial Audio Unavailable",
MessageBoxButtons.OK);
if (VisualRoot is Window parent)
{
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.YesNo,
MessageBoxIcon.Question,
MessageBoxDefaultButton.Button2);
_viewModel.FileDownloadQuality = _viewModel.DownloadQualities[1];
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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,7 +114,6 @@ Title: {title}
Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {libraryBook.DateAdded:d}
Language: {Book.Language}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
using AudibleApi;
using AudibleUtilities;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Threading.Tasks;
@@ -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;

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Dialogs"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110"
MinWidth="265" MinHeight="110"
x:DataType="vm:MessageBoxViewModel"
x:Class="LibationAvalonia.Dialogs.MessageBoxWindow"
Title="{Binding Caption}" ShowInTaskbar="True">
Title="{CompiledBinding Caption}" ShowInTaskbar="True">
<Grid ColumnDefinitions="*" RowDefinitions="*,Auto">
@@ -14,14 +15,22 @@
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
VerticalAlignment="Top">
<Panel Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
<Image IsVisible="{Binding IsAsterisk}" Stretch="None" Source="/Assets/MBIcons/Asterisk.png"/>
<Image IsVisible="{Binding IsError}" Stretch="None" Source="/Assets/MBIcons/error.png"/>
<Image IsVisible="{Binding IsQuestion}" Stretch="None" Source="/Assets/MBIcons/Question.png"/>
<Image IsVisible="{Binding IsExclamation}" Stretch="None" Source="/Assets/MBIcons/Exclamation.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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,10 +41,10 @@
<None Remove="Assets\img-coverart-prod-unavailable_500x500.jpg" />
<None Remove="Assets\img-coverart-prod-unavailable_80x80.jpg" />
<None Remove="Assets\1x1.png" />
<None Remove="Assets\MBIcons\Asterisk.png" />
<None Remove="Assets\MBIcons\error.png" />
<None Remove="Assets\MBIcons\Exclamation.png" />
<None Remove="Assets\MBIcons\Question.png" />
<None Remove="Assets\MBIcons\Asterisk_64.png" />
<None Remove="Assets\MBIcons\Error_64.png" />
<None Remove="Assets\MBIcons\Exclamation_64.png" />
<None Remove="Assets\MBIcons\Question_64.png" />
</ItemGroup>
<ItemGroup>
@@ -73,14 +73,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.2.8" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.8" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.8" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.2" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.2" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.3.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,6 +6,7 @@ using DataLayer;
using Dinah.Core.Logging;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Dialogs;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -13,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);
}
}
}
}

View File

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

View File

@@ -4,6 +4,7 @@ using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using LibationFileManager;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
using System.Linq;
@@ -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);
}
}

View File

@@ -7,6 +7,7 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Input;
using LibationUiBase.Forms;
#nullable enable
namespace LibationAvalonia.ViewModels
@@ -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)

View File

@@ -4,6 +4,9 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using LibationUiBase.Forms;
using LibationUiBase;
using System.Collections.Generic;
#nullable enable
namespace LibationAvalonia.ViewModels
@@ -12,19 +15,14 @@ namespace LibationAvalonia.ViewModels
{
public void Configure_Liberate() { }
public void BackupAllBooks()
public async Task BackupAllBooks()
{
try
{
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)

View File

@@ -1,10 +1,11 @@
using LibationFileManager;
using System;
using System.Linq;
using DataLayer;
using DataLayer;
using Dinah.Core;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia.ViewModels
@@ -37,44 +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];
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);
}
}
}
@@ -88,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)
{
@@ -104,13 +76,8 @@ namespace LibationAvalonia.ViewModels
{
try
{
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray();
if (preLiberated.Length > 0)
{
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
if (ProcessQueue.QueueConvertToMp3(libraryBooks))
setQueueCollapseState(false);
ProcessQueue.AddConvertMp3(preLiberated);
}
}
catch (Exception ex)
{

View File

@@ -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)
{

View File

@@ -5,6 +5,9 @@ using DataLayer;
using Avalonia.Threading;
using LibationAvalonia.Dialogs;
using ReactiveUI;
using LibationUiBase.Forms;
using System.Linq;
using LibationUiBase;
#nullable enable
namespace LibationAvalonia.ViewModels
@@ -71,15 +74,8 @@ namespace LibationAvalonia.ViewModels
{
try
{
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)
{

View File

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

View File

@@ -1,284 +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 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>();
}
}

View File

@@ -5,8 +5,8 @@ using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using Dinah.Core.Collections.Generic;
using LibationAvalonia.Dialogs.Login;
using LibationFileManager;
using LibationUiBase.Forms;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
@@ -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();

View File

@@ -67,6 +67,8 @@ namespace LibationAvalonia.ViewModels.Settings
FileDownloadQuality = DownloadQualities.SingleOrDefault(s => s.Value == config.FileDownloadQuality) ?? DownloadQualities[0];
SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate) ?? SampleRates[0];
SelectedEncoderQuality = config.LameEncoderQuality;
UseWidevine = config.UseWidevine;
RequestSpatial = config.RequestSpatial;
}
public void SaveSettings(Configuration config)
@@ -96,12 +98,13 @@ namespace LibationAvalonia.ViewModels.Settings
config.MaxSampleRate = SelectedSampleRate?.Value ?? config.MaxSampleRate;
config.FileDownloadQuality = FileDownloadQuality?.Value ?? config.FileDownloadQuality;
config.SpatialAudioCodec = SpatialAudioCodec?.Value ?? config.SpatialAudioCodec;
config.UseWidevine = UseWidevine;
config.RequestSpatial = RequestSpatial;
}
public AvaloniaList<EnumDisplay<Configuration.DownloadQuality>> DownloadQualities { get; } = new([
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Normal),
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.High),
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Spatial, "Spatial (if available)"),
]);
public AvaloniaList<EnumDisplay<Configuration.SpatialCodec>> SpatialAudioCodecs { get; } = new([
new EnumDisplay<Configuration.SpatialCodec>(Configuration.SpatialCodec.EC_3, "Dolby Digital Plus (E-AC-3)"),
@@ -109,6 +112,10 @@ namespace LibationAvalonia.ViewModels.Settings
]);
public AvaloniaList<Configuration.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
public string FileDownloadQualityText { get; } = Configuration.GetDescription(nameof(Configuration.FileDownloadQuality));
public string UseWidevineText { get; } = Configuration.GetDescription(nameof(Configuration.UseWidevine));
public string UseWidevineTip { get; } = Configuration.GetHelpText(nameof(Configuration.UseWidevine));
public string RequestSpatialText { get; } = Configuration.GetDescription(nameof(Configuration.RequestSpatial));
public string RequestSpatialTip { get; } = Configuration.GetHelpText(nameof(Configuration.RequestSpatial));
public string SpatialAudioCodecText { get; } = Configuration.GetDescription(nameof(Configuration.SpatialAudioCodec));
public string SpatialAudioCodecTip { get; } = Configuration.GetHelpText(nameof(Configuration.SpatialAudioCodec));
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
@@ -133,19 +140,13 @@ namespace LibationAvalonia.ViewModels.Settings
public string RetainAaxFileTip => Configuration.GetHelpText(nameof(RetainAaxFile));
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
public bool SpatialSelected { get; private set; }
private EnumDisplay<Configuration.DownloadQuality>? _fileDownloadQuality;
public EnumDisplay<Configuration.DownloadQuality> FileDownloadQuality
{
get => _fileDownloadQuality ?? DownloadQualities[0];
set
{
SpatialSelected = value?.Value == Configuration.DownloadQuality.Spatial;
this.RaiseAndSetIfChanged(ref _fileDownloadQuality, value);
this.RaisePropertyChanged(nameof(SpatialSelected));
}
}
private bool _useWidevine;
private bool _requestSpatial;
public bool UseWidevine { get => _useWidevine; set => this.RaiseAndSetIfChanged(ref _useWidevine, value); }
public bool RequestSpatial { get => _requestSpatial; set => this.RaiseAndSetIfChanged(ref _requestSpatial, value); }
public EnumDisplay<Configuration.DownloadQuality> FileDownloadQuality { get; set; }
public EnumDisplay<Configuration.SpatialCodec> SpatialAudioCodec { get; set; }
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
public bool MergeOpeningAndEndCredits { get; set; }

View File

@@ -7,6 +7,7 @@ using FileManager;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.Forms;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
@@ -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
}

Some files were not shown because too many files have changed in this diff Show More