Compare commits

..

99 Commits

Author SHA1 Message Date
rmcrackan
0f7ffacdf8 incr ver 2025-07-22 10:20:39 -04:00
rmcrackan
829b35c5a8 Merge pull request #1311 from Mbucari/master
Fix serilog dynamic assembly loading issue (#1310)
2025-07-22 10:18:33 -04:00
Michael Bucari-Tovo
614b05d5ff Fix serilog dynamic assembly loading issue (#1310) 2025-07-22 08:00:31 -06:00
rmcrackan
26ccc77b47 incr ver 2025-07-22 07:24:26 -04:00
rmcrackan
64fb2ccf7c Merge pull request #1308 from Mbucari/master
Refactors, bug fixes, and performance improvements.
2025-07-22 07:22:35 -04:00
MBucari
890747a902 Do library scan on background thread 2025-07-22 00:20:16 -06:00
Michael Bucari-Tovo
1fdcea929f Form thread safety 2025-07-21 22:52:17 -06:00
Michael Bucari-Tovo
7848366818 Write logs to text .log file instead of .zip file
The ZipFile sink could cause program hangs. Additionally, the only reason it was ever used was to package verbose AudibleApi account login errors, saving the returned Html page as a file. Otherwise, the zip file only contains a .log text file.

- Removed Serilog.Sinks.ZipFile
- Add Serilog configuration migration
- Added a custom destructure to handle logging files. If any files are logged, they will be written to "LogyyyyMM_AdditionalFiles.zip"
2025-07-21 22:19:55 -06:00
Michael Bucari-Tovo
40b4915b65 Improve download/decrypt cancellation 2025-07-21 15:56:41 -06:00
Michael Bucari-Tovo
80b86086ca Consolidate process queue view models
Remove classic and chardonnay-specific implementations
Refactor TrackedQueue into an IList with INotifyCollectionChanged
2025-07-21 15:56:30 -06:00
Michael Bucari-Tovo
bff9b67b72 Remove GridEntry derrived types and interfaces
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images

Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
2025-07-21 10:47:10 -06:00
Mbucari
657a7bb6bc Improve podcast episode GridEntry creation performance.
Tested on a library with ~5000 podcast episodes on an AMD Ryzen 7700X. Startup time decreases by ~400 ms in Release mode.
2025-07-21 09:49:25 -06:00
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
195 changed files with 3333 additions and 3692 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.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)
@@ -24,29 +25,41 @@ namespace AaxDecrypter
public override async Task CancelAsync()
{
IsCanceled = true;
await base.CancelAsync();
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload();
}
private Mp4File Open()
{
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 +116,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()
@@ -115,7 +120,12 @@ namespace AaxDecrypter
}
}
public abstract Task CancelAsync();
public virtual Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt) { }
@@ -177,7 +187,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 +198,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

@@ -17,13 +17,6 @@ namespace AaxDecrypter
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
}
public override Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
await InputFileStream.DownloadTask;

View File

@@ -2,15 +2,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.3.1.1</Version>
<Version>12.4.9.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. -->
<!-- Only File 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="7.0.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />

View File

@@ -115,11 +115,22 @@ namespace AppScaffolding
{
if (config.GetObject("Serilog") is JObject serilog)
{
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink)
bool fileChanged = false;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
{
fileSink["Name"] = "ZipFile";
config.SetNonString(serilog.DeepClone(), "Serilog");
zipFileSink["Name"] = "File";
fileChanged = true;
}
var hooks = $"{nameof(LibationFileManager)}.{nameof(FileSinkHook)}, {nameof(LibationFileManager)}";
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
{
fileSinkArgs["hooks"] = hooks;
fileChanged = true;
}
if (fileChanged)
config.SetNonString(serilog.DeepClone(), "Serilog");
return;
}
@@ -129,17 +140,17 @@ namespace AppScaffolding
{ "WriteTo", new JArray
{
// ABOUT SINKS
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use.
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
new JObject
{
{ "Name", "ZipFile" },
{ "Name", "File" },
{ "Args",
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
{ "path", Path.Combine(config.LibationFiles, "Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@@ -274,7 +285,7 @@ namespace AppScaffolding
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
});
if (InteropFactory.InteropFunctionsType is null)
if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
}
@@ -290,33 +301,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 +332,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 +366,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

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AaxDecrypter;
using ApplicationServices;
@@ -18,10 +19,15 @@ namespace FileLiberator
{
public override string Name => "Download & Decrypt";
private AudiobookDownloadBase abDownloader;
private readonly CancellationTokenSource cancellationTokenSource = new();
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override async Task CancelAsync()
{
cancellationTokenSource.Cancel();
if (abDownloader is not null)
await abDownloader.CancelAsync();
}
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
@@ -41,19 +47,25 @@ namespace FileLiberator
}
OnBegin(libraryBook);
var cancellationToken = cancellationTokenSource.Token;
try
try
{
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, cancellationToken);
bool success = false;
try
{
FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(libraryBook);
success = await downloadAudiobookAsync(api, config, downloadOptions);
}
finally
{
@@ -69,38 +81,32 @@ namespace FileLiberator
.Where(f => f.FileType != FileType.AAXC)
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
return
abDownloader?.IsCanceled is true
? new StatusHandler { "Cancelled" }
: new StatusHandler { "Decrypt failed" };
cancellationToken.ThrowIfCancellationRequested();
return new StatusHandler { "Decrypt failed" };
}
var finalStorageDir = getDestinationDirectory(libraryBook);
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
Task[] finalTasks = new[]
{
Task.Run(() => downloadCoverArt(libraryBook)),
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken));
Task[] finalTasks =
[
Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)),
moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
};
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
];
try
{
await Task.WhenAll(finalTasks);
}
catch
{
await Task.WhenAll(finalTasks);
}
catch when (!moveFilesTask.IsFaulted)
{
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
//Only fail if the downloaded audio files failed to move to Books directory
if (moveFilesTask.IsFaulted)
{
throw;
}
}
finally
//Only fail if the downloaded audio files failed to move to Books directory
}
finally
{
if (moveFilesTask.IsCompletedSuccessfully)
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
{
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
@@ -109,23 +115,21 @@ namespace FileLiberator
}
return new StatusHandler();
}
finally
}
catch when (cancellationToken.IsCancellationRequested)
{
Serilog.Log.Logger.Information("Download/Decrypt was cancelled. {@Book}", libraryBook.LogFriendly());
return new StatusHandler { "Cancelled" };
}
finally
{
OnCompleted(libraryBook);
}
}
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 +153,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 +162,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 +177,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;
@@ -236,16 +263,17 @@ namespace FileLiberator
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries, CancellationToken cancellationToken)
{
// create final directory. move each file into it
var destinationDir = getDestinationDirectory(libraryBook);
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
var realDest
var realDest
= FileUtility.SaferMoveToValidPath(
entry.Path,
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
@@ -257,7 +285,8 @@ namespace FileLiberator
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
entries[i] = entry with { Path = realDest };
}
cancellationToken.ThrowIfCancellationRequested();
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
@@ -266,7 +295,8 @@ namespace FileLiberator
SetFileTime(libraryBook, cue.Path);
}
AudibleFileStorage.Audio.Refresh();
cancellationToken.ThrowIfCancellationRequested();
AudibleFileStorage.Audio.Refresh();
}
private static string getDestinationDirectory(LibraryBook libraryBook)
@@ -280,7 +310,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, CancellationToken cancellationToken)
{
if (!Configuration.Instance.DownloadCoverArt) return;
@@ -288,24 +318,25 @@ 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), cancellationToken);
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.");
throw;
}
}
}

View File

@@ -10,6 +10,8 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
@@ -23,162 +25,117 @@ 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, CancellationToken token)
{
var license = await ChooseContent(api, libraryBook, config);
var options = BuildDownloadOptions(libraryBook, config, license);
var license = await ChooseContent(api, libraryBook, config, token);
token.ThrowIfCancellationRequested();
return options;
//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;
token.ThrowIfCancellationRequested();
return BuildDownloadOptions(libraryBook, config, license);
}
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, CancellationToken token)
{
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;
}
}
token.ThrowIfCancellationRequested();
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
return new LicenseInfo(license);
}
if (contentLic.DrmType == DrmType.Widevine && cdm is not null)
token.ThrowIfCancellationRequested();
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, token);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
}
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 +151,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 +159,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 +311,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 +324,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 +353,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

@@ -9,7 +9,7 @@ namespace LibationAvalonia.Controls
{
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
ele.IsThreeState = dataItem is ISeriesEntry;
ele.IsThreeState = dataItem is SeriesEntry;
return ele;
}
}

View File

@@ -34,11 +34,11 @@ namespace LibationAvalonia.Controls
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
{
if (sender is DataGridCell cell &&
cell.DataContext is IGridEntry clickedEntry &&
cell.DataContext is GridEntry clickedEntry &&
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
OwningGridProperty.GetValue(column) is DataGrid grid)
{
var allSelected = grid.SelectedItems.OfType<IGridEntry>().ToArray();
var allSelected = grid.SelectedItems.OfType<GridEntry>().ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1)
{
@@ -101,7 +101,7 @@ namespace LibationAvalonia.Controls
private static string RemoveLineBreaks(string text)
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
private string GetRowClipboardContents(IGridEntry gridEntry)
private string GetRowClipboardContents(GridEntry gridEntry)
{
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
return string.Join("\t", contents);
@@ -109,7 +109,7 @@ namespace LibationAvalonia.Controls
public required DataGrid Grid { get; init; }
public required DataGridColumn Column { get; init; }
public required IGridEntry[] GridEntries { get; init; }
public required GridEntry[] GridEntries { get; init; }
public required ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.ItemsSource as AvaloniaList<Control>;

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

@@ -58,10 +58,5 @@ namespace LibationAvalonia.Controls.Settings
}
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
}
public void OpenLogFolderButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
}
}
}

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;
@@ -38,11 +36,11 @@ public partial class ThemePreviewControl : UserControl
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
}
QueuedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Queued };
WorkingBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Working };
CompletedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Completed };
CancelledBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Cancelled };
FailedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Failed };
QueuedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Queued };
WorkingBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Working };
CompletedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Completed };
CancelledBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Cancelled };
FailedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Failed };
//Set the current processable so that the empty queue doesn't try to advance.
QueuedBook.AddDownloadPdf();

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,7 @@
using AudibleApi;
using AudibleUtilities;
using Avalonia.Threading;
using LibationUiBase.Forms;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs.Login
@@ -16,42 +18,46 @@ namespace LibationAvalonia.Dialogs.Login
}
public async Task<string> Get2faCodeAsync(string prompt)
{
var dialog = new _2faCodeDialog(prompt);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return dialog.Code;
return null;
}
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var dialog = new _2faCodeDialog(prompt);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return dialog.Code;
return null;
});
public async Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
{
var dialog = new CaptchaDialog(password, captchaImage);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (dialog.Password, dialog.Answer);
return (null, null);
}
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var dialog = new CaptchaDialog(password, captchaImage);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (dialog.Password, dialog.Answer);
return (null, null);
});
public async Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
{
var dialog = new MfaDialog(mfaConfig);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (dialog.SelectedName, dialog.SelectedValue);
return (null, null);
}
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var dialog = new MfaDialog(mfaConfig);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (dialog.SelectedName, dialog.SelectedValue);
return (null, null);
});
public async Task<(string email, string password)> GetLoginAsync()
{
var dialog = new LoginCallbackDialog(_account);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (_account.AccountId, dialog.Password);
return (null, null);
}
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var dialog = new LoginCallbackDialog(_account);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (_account.AccountId, dialog.Password);
return (null, null);
});
public async Task ShowApprovalNeededAsync()
{
var dialog = new ApprovalNeededDialog();
await dialog.ShowDialogAsync();
}
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var dialog = new ApprovalNeededDialog();
await dialog.ShowDialogAsync();
});
}
}

View File

@@ -1,6 +1,8 @@
using AudibleApi;
using AudibleUtilities;
using Avalonia.Threading;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Threading.Tasks;
@@ -9,10 +11,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;
@@ -24,6 +22,9 @@ namespace LibationAvalonia.Dialogs.Login
}
public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
=> await Dispatcher.UIThread.InvokeAsync(() => StartAsyncInternal(choiceIn));
private async Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn)
{
if (Configuration.IsWindows && Environment.OSVersion.Version.Major >= 10)
{

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,20 +0,0 @@
using Avalonia.Media.Imaging;
using DataLayer;
using LibationUiBase.GridView;
using System;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable
{
private AvaloniaEntryStatus(LibraryBook libraryBook) : base(libraryBook) { }
public static EntryStatus Create(LibraryBook libraryBook) => new AvaloniaEntryStatus(libraryBook);
protected override Bitmap LoadImage(byte[] picture)
=> AvaloniaUtils.TryLoadImageOrDefault(picture, LibationFileManager.PictureSize._80x80);
//Button icons are handled by LiberateStatusButton
protected override Bitmap? GetResourceImage(string rescName) => null;
}
}

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 Task.Run(() => 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);
}
}
}
@@ -84,15 +57,14 @@ namespace LibationAvalonia.ViewModels
}
}
public void LiberateSeriesClicked(ISeriesEntry series)
public void LiberateSeriesClicked(SeriesEntry series)
{
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

@@ -5,6 +5,7 @@ using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
@@ -27,7 +28,7 @@ namespace LibationAvalonia.ViewModels
// in autoScan, new books SHALL NOT show dialog
try
{
await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts);
await Task.Run(() => 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)
{

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