Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed42916cb2 | ||
|
|
0bb5bba3c8 | ||
|
|
a887bf4619 | ||
|
|
53eebcd6ba | ||
|
|
a09ae1316d | ||
|
|
7088bd4b8d | ||
|
|
b27325cdcb | ||
|
|
accedeb1b1 | ||
|
|
c98c7c095a | ||
|
|
9b217a4e18 | ||
|
|
a62a9ffc5b | ||
|
|
08aebf8ecf | ||
|
|
2f082a9656 | ||
|
|
1f473039e1 | ||
|
|
0f4197924e | ||
|
|
0f7ffacdf8 | ||
|
|
829b35c5a8 | ||
|
|
614b05d5ff | ||
|
|
26ccc77b47 | ||
|
|
64fb2ccf7c | ||
|
|
890747a902 | ||
|
|
1fdcea929f | ||
|
|
7848366818 | ||
|
|
40b4915b65 | ||
|
|
80b86086ca | ||
|
|
bff9b67b72 | ||
|
|
657a7bb6bc | ||
|
|
f0d7a7bf64 | ||
|
|
8bc098e7bd | ||
|
|
9280b29512 | ||
|
|
d8e9b9c505 | ||
|
|
554b308364 | ||
|
|
8d7872a376 | ||
|
|
747451d243 | ||
|
|
7e79e98771 | ||
|
|
4b7939541a | ||
|
|
a3734c76b1 | ||
|
|
ced4ea6c17 | ||
|
|
35ca6f2621 | ||
|
|
4dab16837e | ||
|
|
1cf889eed7 | ||
|
|
b65b1e819b | ||
|
|
3d50643ab0 | ||
|
|
abd18d74b0 | ||
|
|
0e49df06b8 | ||
|
|
38cc3e9725 | ||
|
|
c9af2bba4b | ||
|
|
2191c1536d | ||
|
|
5b9bf2fbb0 | ||
|
|
9b1ce8c1d7 | ||
|
|
9f8075041b | ||
|
|
944645379e | ||
|
|
cc72517284 | ||
|
|
0044820415 | ||
|
|
9f24027de1 | ||
|
|
24f95cb03d | ||
|
|
3aeea54615 | ||
|
|
f511041781 | ||
|
|
da9dc91469 | ||
|
|
e04e70d333 | ||
|
|
e0b566ee60 | ||
|
|
bf15d7302e | ||
|
|
8f01c644c0 | ||
|
|
ebd2cc96c5 | ||
|
|
0d1cc42ca7 | ||
|
|
e126dd09ce | ||
|
|
ec497f4f81 | ||
|
|
248fdfd2bc | ||
|
|
35862d619a | ||
|
|
ac2c67985d | ||
|
|
f8ae303417 | ||
|
|
0d24caeac2 | ||
|
|
7f1b357c52 | ||
|
|
ef67ae9d6a | ||
|
|
f35c82d59d | ||
|
|
10c01f4147 | ||
|
|
9366b3baca | ||
|
|
20e792c589 | ||
|
|
dfb63d3275 | ||
|
|
19db226f5a | ||
|
|
203ab00865 | ||
|
|
b11a4887d7 | ||
|
|
e73fc5e1eb | ||
|
|
8561a15061 | ||
|
|
28ba62aead | ||
|
|
176294cc55 | ||
|
|
152b0e362d | ||
|
|
4600d029dc | ||
|
|
1a5684799c | ||
|
|
0df17a2296 | ||
|
|
45472abd1f | ||
|
|
f2ea4539f2 | ||
|
|
52d3b9cb67 | ||
|
|
3d87f2cd9b | ||
|
|
e4a3d2ac79 | ||
|
|
8aa157f2f6 | ||
|
|
5ab6c1fe70 | ||
|
|
b23c46f79f | ||
|
|
0e987eef00 | ||
|
|
ace3d80e41 | ||
|
|
4bfb4e73ce | ||
|
|
7805a3ef11 | ||
|
|
08ca2a2db3 | ||
|
|
64a85b6aab | ||
|
|
1a38273d5f | ||
|
|
303dd7c471 | ||
|
|
313e3846c3 | ||
|
|
422c86345e | ||
|
|
ce952417fb | ||
|
|
5f4551822b | ||
|
|
3aebc7c885 | ||
|
|
3982edd0f1 | ||
|
|
f4dafac28f | ||
|
|
1090d29f74 | ||
|
|
1c336e1fe9 | ||
|
|
c7e9e9ac1e | ||
|
|
8232b2b5e5 | ||
|
|
9ca879cc3d | ||
|
|
ece48eb6d7 | ||
|
|
bffaea6026 | ||
|
|
e2aae85fd7 | ||
|
|
1777dc5a7e | ||
|
|
2dfe00f428 | ||
|
|
2cd0a022ff | ||
|
|
5d7ac699e6 | ||
|
|
7d806e0f3e | ||
|
|
0a9e489f48 |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"CdmUrls": [
|
||||||
|
"https://ollj0gz40d.execute-api.us-west-2.amazonaws.com/default/AudibleCdm"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -27,4 +27,4 @@ If applicable, add screenshots to help explain your problem.
|
|||||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||||
|
|
||||||
**Log Files**
|
**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.
|
||||||
@@ -15,6 +15,10 @@ on:
|
|||||||
description: "Skip running unit tests"
|
description: "Skip running unit tests"
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
|
architecture:
|
||||||
|
type: string
|
||||||
|
description: "CPU architecture targeted by the build."
|
||||||
|
required: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_CONFIGURATION: "Release"
|
DOTNET_CONFIGURATION: "Release"
|
||||||
@@ -22,8 +26,11 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: "${{ matrix.os }}-${{ matrix.release_name }}"
|
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||||
|
RUNTIME_ID: "win-${{ inputs.architecture }}"
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [Windows]
|
os: [Windows]
|
||||||
@@ -63,46 +70,42 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
LibationCli/LibationCli.csproj `
|
LibationCli/LibationCli.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:DefineConstants="${{ matrix.release_name }}" `
|
-p:DefineConstants="${{ matrix.release_name }}" `
|
||||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
|
||||||
- name: Zip artifact
|
- name: Zip artifact
|
||||||
id: zip
|
id: zip
|
||||||
working-directory: ./Source/bin/Publish
|
working-directory: ./Source/bin/Publish
|
||||||
run: |
|
run: |
|
||||||
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
|
$bin_dir = "${{ env.OUTPUT_NAME }}\"
|
||||||
$delfiles = @(
|
$delfiles = @(
|
||||||
"libmp3lame.x64.so",
|
|
||||||
"libmp3lame.arm64.so",
|
|
||||||
"libmp3lame.x64.dylib",
|
|
||||||
"libmp3lame.arm64.dylib",
|
|
||||||
"ffmpegaac.x64.so",
|
|
||||||
"ffmpegaac.arm64.so",
|
|
||||||
"ffmpegaac.x64.dylib",
|
|
||||||
"ffmpegaac.arm64.dylib",
|
|
||||||
"WindowsConfigApp.exe",
|
"WindowsConfigApp.exe",
|
||||||
"WindowsConfigApp.runtimeconfig.json",
|
"WindowsConfigApp.runtimeconfig.json",
|
||||||
"WindowsConfigApp.deps.json"
|
"WindowsConfigApp.deps.json"
|
||||||
)
|
)
|
||||||
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
||||||
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
|
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||||
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
windows:
|
windows:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
architecture: [x64]
|
||||||
uses: ./.github/workflows/build-windows.yml
|
uses: ./.github/workflows/build-windows.yml
|
||||||
with:
|
with:
|
||||||
version_override: ${{ inputs.version_override }}
|
version_override: ${{ inputs.version_override }}
|
||||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
|
architecture: ${{ matrix.architecture }}
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
strategy:
|
strategy:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
|
||||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
|
||||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm",
|
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
|
||||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
|
||||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
|
||||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm",
|
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
|
||||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ main() {
|
|||||||
init_config_file Settings.json
|
init_config_file Settings.json
|
||||||
|
|
||||||
info "loading settings"
|
info "loading settings"
|
||||||
update_settings Settings.json Books /data
|
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
|
||||||
update_settings Settings.json InProgress /tmp
|
update_settings Settings.json InProgress /tmp
|
||||||
|
|
||||||
info "loading database"
|
info "loading database"
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
# Frequently Asked Questions
|
# 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'?
|
## Q: What's the difference between 'Classic' and 'Chardonnay'?
|
||||||
|
|
||||||
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
- [Download PDF attachments](#download-pdf-attachments)
|
- [Download PDF attachments](#download-pdf-attachments)
|
||||||
- [Details of downloaded files](#details-of-downloaded-files)
|
- [Details of downloaded files](#details-of-downloaded-files)
|
||||||
- [Export your library](#export-your-library)
|
- [Export your library](#export-your-library)
|
||||||
|
- [I still need help](#i-still-need-help)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -148,3 +149,7 @@ When you set up Libation, you'll specify a Books directory. Libation looks insid
|
|||||||

|

|
||||||
|
|
||||||
Export your library to Excel, CSV, or JSON
|
Export your library to Excel, CSV, or JSON
|
||||||
|
|
||||||
|
### I still need help
|
||||||
|
|
||||||
|
[You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||||
@@ -8,23 +8,23 @@
|
|||||||
|
|
||||||
[](https://repology.org/project/libation/versions)
|
[](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
|
### Debian
|
||||||
```Console
|
```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
|
sudo apt install ./libation.deb
|
||||||
```
|
```
|
||||||
### Redhat and CentOS
|
### Redhat and CentOS
|
||||||
```Console
|
```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
|
sudo yum install ./libation.rpm
|
||||||
```
|
```
|
||||||
### Fedora
|
### Fedora
|
||||||
```Console
|
```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
|
sudo dnf5 install ./libation.rpm
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ These templates apply to both GUI and CLI.
|
|||||||
- [Conditional Tags](#conditional-tags)
|
- [Conditional Tags](#conditional-tags)
|
||||||
- [Tag Formatters](#tag-formatters)
|
- [Tag Formatters](#tag-formatters)
|
||||||
- [Text Formatters](#text-formatters)
|
- [Text Formatters](#text-formatters)
|
||||||
|
- [Series Formatters](#series-formatters)
|
||||||
|
- [Series List Formatters](#series-list-formatters)
|
||||||
|
- [Name Formatters](#name-formatters)
|
||||||
- [Name List Formatters](#name-list-formatters)
|
- [Name List Formatters](#name-list-formatters)
|
||||||
- [Number Formatters](#number-formatters)
|
- [Number Formatters](#number-formatters)
|
||||||
- [Date Formatters](#date-formatters)
|
- [Date Formatters](#date-formatters)
|
||||||
@@ -32,32 +35,36 @@ These tags will be replaced in the template with the audiobook's values.
|
|||||||
|Tag|Description|Type|
|
|Tag|Description|Type|
|
||||||
|-|-|-|
|
|-|-|-|
|
||||||
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||||
|\<title\>|Full title with subtitle|Text|
|
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|
||||||
|\<title short\>|Title. Stop at first colon|Text|
|
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|
||||||
|\<audible title\>|Audible's title (does not include subtitle)|Text|
|
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|
||||||
|\<audible subtitle\>|Audible's subtitle|Text|
|
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|
||||||
|\<author\>|Author(s)|Name List|
|
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|
||||||
|\<first author\>|First author|Text|
|
|\<first author\>|First author|[Name](#name-formatters)|
|
||||||
|\<narrator\>|Narrator(s)|Name List|
|
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|
||||||
|\<first narrator\>|First narrator|Text|
|
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|
||||||
|\<series\>|Name of series|Text|
|
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
||||||
|\<series#\>|Number order in series|Number|
|
|\<first series\>|First series|[Series](#series-formatters)|
|
||||||
|\<bitrate\>|File's original bitrate (Kbps)|Number|
|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||||
|\<samplerate\>|File's original audio sample rate|Number|
|
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|\<channels\>|Number of audio channels|Number|
|
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|\<account\>|Audible account of this book|Text|
|
|\<channels\>|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|\<account nickname\>|Audible account nickname of this book|Text|
|
|\<codec\>|Audio codec of the last downloaded audiobook|[Text](#text-formatters)|
|
||||||
|\<locale\>|Region/country|Text|
|
|\<file version\>|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)|
|
||||||
|\<year\>|Year published|Number|
|
|\<libation version\>|Libation version used during last download of the audiobook|[Text](#text-formatters)|
|
||||||
|\<language\>|Book's language|Text|
|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|
||||||
|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||||
|
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||||
|
|\<year\>|Year published|[Number](#number-formatters)|
|
||||||
|
|\<language\>|Book's language|[Text](#text-formatters)|
|
||||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||||
|\<file date\>|File creation date/time.|DateTime|
|
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|
||||||
|\<pub date\>|Audiobook publication date|DateTime|
|
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|
||||||
|\<date added\>|Date the book added to your Audible account|DateTime|
|
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|
||||||
|\<ch count\> **‡**|Number of chapters|Number|
|
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|
||||||
|\<ch title\> **‡**|Chapter title|Text|
|
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|
||||||
|\<ch#\> **‡**|Chapter number|Number|
|
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|
||||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number|
|
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
|
||||||
|
|
||||||
**†** Does not support custom formatting
|
**†** Does not support custom formatting
|
||||||
|
|
||||||
@@ -95,11 +102,28 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|
|||||||
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
||||||
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
||||||
|
|
||||||
|
## Series Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1, B08376S3R2|
|
||||||
|
|
||||||
|
## Series List Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|separator()|Speficy the text used to join<br>multiple series names.<br><br>Default is ", "|`<series[separator(; )]>`|Sherlock Holmes; Some Other Series|
|
||||||
|
|format(\{N \| # \| ID\})|Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above.|`<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<author[format({L}, {ID}) separator(; )]>`|Sherlock Holmes, 1; Some Other Series, 1<hr>herlock Holmes, B08376S3R2; Some Other Series, B000000000|
|
||||||
|
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
|
||||||
|
|
||||||
|
## Name Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|\{T \| F \| M \| L \| S \| ID\}|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|
||||||
|
|
||||||
## Name List Formatters
|
## Name List Formatters
|
||||||
|Formatter|Description|Example Usage|Example Result|
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|-|-|-|-|
|
|-|-|-|-|
|
||||||
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||||
|format(\{T \| F \| M \| L \| S\})|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<author[format({L}, {F})`<br>`separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
|format(\{T \| F \| M \| L \| S \| ID\})|Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above.|`<author[format({L}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>`|Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|
||||||
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||||
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
||||||
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
||||||
- [Export your library](Documentation/GettingStarted.md#export-your-library)
|
- [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)
|
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
|
||||||
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
||||||
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
||||||
|
|||||||
@@ -53,13 +53,7 @@ if [ $? -ne 0 ]
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||||
if [[ "$ARCH" == "arm64" ]]
|
|
||||||
then
|
|
||||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
|
||||||
else
|
|
||||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
|
||||||
fi
|
|
||||||
|
|
||||||
for n in "${delfiles[@]}"
|
for n in "${delfiles[@]}"
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -82,18 +82,7 @@ echo "Set CFBundleVersion to $VERSION"
|
|||||||
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
||||||
|
|
||||||
|
|
||||||
delfiles=( 'libmp3lame.arm64.so' 'libmp3lame.x64.so' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.so' 'ffmpegaac.x64.so' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||||
if [[ "$ARCH" == "arm64" ]]
|
|
||||||
then
|
|
||||||
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
|
|
||||||
mv $BUNDLE_MACOS/ffmpegaac.arm64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
|
|
||||||
mv $BUNDLE_MACOS/libmp3lame.arm64.dylib $BUNDLE_MACOS/libmp3lame.dylib
|
|
||||||
else
|
|
||||||
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
|
|
||||||
mv $BUNDLE_MACOS/ffmpegaac.x64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
|
|
||||||
mv $BUNDLE_MACOS/libmp3lame.x64.dylib $BUNDLE_MACOS/libmp3lame.dylib
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
for n in "${delfiles[@]}"
|
for n in "${delfiles[@]}"
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -38,14 +38,12 @@ fi
|
|||||||
|
|
||||||
BASEDIR=$(pwd)
|
BASEDIR=$(pwd)
|
||||||
|
|
||||||
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||||
if [[ "$ARCH" == "x64" ]]
|
if [[ "$ARCH" == "x64" ]]
|
||||||
then
|
then
|
||||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
|
||||||
ARCH_RPM="x86_64"
|
ARCH_RPM="x86_64"
|
||||||
ARCH="amd64"
|
ARCH="amd64"
|
||||||
else
|
else
|
||||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
|
||||||
ARCH_RPM="aarch64"
|
ARCH_RPM="aarch64"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="1.1.4" />
|
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
public event EventHandler<AppleTags>? RetrievedMetadata;
|
||||||
|
|
||||||
protected AaxFile AaxFile { get; private set; }
|
public Mp4File? AaxFile { get; private set; }
|
||||||
protected Mp4Operation AaxConversion { get; set; }
|
protected Mp4Operation? AaxConversion { get; set; }
|
||||||
|
|
||||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outDirectory, cacheDirectory, dlOptions) { }
|
||||||
|
|
||||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||||
public override void SetCoverArt(byte[] coverArt)
|
public override void SetCoverArt(byte[] coverArt)
|
||||||
@@ -24,19 +27,65 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
public override async Task CancelAsync()
|
public override async Task CancelAsync()
|
||||||
{
|
{
|
||||||
IsCanceled = true;
|
await base.CancelAsync();
|
||||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||||
FinalizeDownload();
|
}
|
||||||
|
|
||||||
|
private Mp4File Open()
|
||||||
|
{
|
||||||
|
if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0)
|
||||||
|
throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file.");
|
||||||
|
else 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 = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||||
|
|
||||||
|
var dash = new DashFile(InputFileStream);
|
||||||
|
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}");
|
||||||
|
|
||||||
|
keys[0] = keys[kidIndex];
|
||||||
|
var keyId = keys[kidIndex].KeyPart1;
|
||||||
|
var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
|
||||||
|
dash.SetDecryptionKey(keyId, key);
|
||||||
|
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
|
||||||
|
return dash;
|
||||||
|
}
|
||||||
|
else if (DownloadOptions.InputType is FileType.Aax)
|
||||||
|
{
|
||||||
|
var aax = new AaxFile(InputFileStream);
|
||||||
|
var key = keys[0].KeyPart1;
|
||||||
|
aax.SetDecryptionKey(keys[0].KeyPart1);
|
||||||
|
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
|
||||||
|
return aax;
|
||||||
|
}
|
||||||
|
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||||
|
{
|
||||||
|
var aax = new AaxFile(InputFileStream);
|
||||||
|
var key = keys[0].KeyPart1;
|
||||||
|
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
|
||||||
|
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
|
||||||
|
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
|
||||||
|
return aax;
|
||||||
|
}
|
||||||
|
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||||
|
|
||||||
|
void WriteKeyFile(string contents)
|
||||||
|
{
|
||||||
|
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
|
||||||
|
File.WriteAllText(keyFile, contents + Environment.NewLine);
|
||||||
|
OnTempFileCreated(new(keyFile));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_GetMetadata()
|
protected bool Step_GetMetadata()
|
||||||
{
|
{
|
||||||
AaxFile = new AaxFile(InputFileStream);
|
AaxFile = Open();
|
||||||
|
|
||||||
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null)
|
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
|
|
||||||
else
|
|
||||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
|
||||||
|
|
||||||
if (DownloadOptions.StripUnabridged)
|
if (DownloadOptions.StripUnabridged)
|
||||||
{
|
{
|
||||||
@@ -83,12 +132,10 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
OnInitialized();
|
OnInitialized();
|
||||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
|
||||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
|
||||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||||
|
|
||||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
|
||||||
|
|
||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,20 +5,20 @@ using System;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||||
private FileStream workingFileStream;
|
private FileStream? workingFileStream;
|
||||||
|
|
||||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions)
|
: base(outDirectory, cacheDirectory, dlOptions)
|
||||||
{
|
{
|
||||||
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||||
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
@@ -59,6 +59,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
*/
|
*/
|
||||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
|
if (AaxFile is null) return false;
|
||||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||||
|
|
||||||
// Ensure split files are at least minChapterLength in duration.
|
// Ensure split files are at least minChapterLength in duration.
|
||||||
@@ -83,10 +84,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await (AaxConversion = decryptMultiAsync(splitChapters));
|
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
|
||||||
|
|
||||||
if (AaxConversion.IsCompletedSuccessfully)
|
if (AaxConversion.IsCompletedSuccessfully)
|
||||||
await moveMoovToBeginning(workingFileStream?.Name);
|
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
|
||||||
|
|
||||||
return AaxConversion.IsCompletedSuccessfully;
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
}
|
}
|
||||||
@@ -97,52 +98,51 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
|
private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters)
|
||||||
{
|
{
|
||||||
var chapterCount = 0;
|
var chapterCount = 0;
|
||||||
return
|
return
|
||||||
DownloadOptions.OutputFormat == OutputFormat.M4b
|
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||||
? AaxFile.ConvertToMultiMp4aAsync
|
? aaxFile.ConvertToMultiMp4aAsync
|
||||||
(
|
(
|
||||||
splitChapters,
|
splitChapters,
|
||||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
||||||
)
|
)
|
||||||
: AaxFile.ConvertToMultiMp3Async
|
: aaxFile.ConvertToMultiMp3Async
|
||||||
(
|
(
|
||||||
splitChapters,
|
splitChapters,
|
||||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||||
DownloadOptions.LameConfig
|
DownloadOptions.LameConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
|
||||||
{
|
{
|
||||||
|
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
|
||||||
|
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||||
MultiConvertFileProperties props = new()
|
MultiConvertFileProperties props = new()
|
||||||
{
|
{
|
||||||
OutputFileName = OutputFileName,
|
OutputFileName = newTempFile.FilePath,
|
||||||
PartsPosition = currentChapter,
|
PartsPosition = currentChapter,
|
||||||
PartsTotal = splitChapters.Count,
|
PartsTotal = splitChapters.Count,
|
||||||
Title = newSplitCallback?.Chapter?.Title,
|
Title = newSplitCallback.Chapter?.Title,
|
||||||
};
|
};
|
||||||
|
|
||||||
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
|
|
||||||
|
|
||||||
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||||
newSplitCallback.TrackNumber = currentChapter;
|
newSplitCallback.TrackNumber = currentChapter;
|
||||||
newSplitCallback.TrackCount = splitChapters.Count;
|
newSplitCallback.TrackCount = splitChapters.Count;
|
||||||
|
|
||||||
OnFileCreated(workingFileStream.Name);
|
OnTempFileCreated(newTempFile with { PartProperties = props });
|
||||||
}
|
}
|
||||||
|
|
||||||
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||||
{
|
{
|
||||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
|
||||||
FileUtility.SaferDelete(fileName);
|
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mp4Operation moveMoovToBeginning(string filename)
|
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
|
||||||
{
|
{
|
||||||
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||||
&& DownloadOptions.MoveMoovToBeginning
|
&& DownloadOptions.MoveMoovToBeginning
|
||||||
@@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
{
|
{
|
||||||
return Mp4File.RelocateMoovAsync(filename);
|
return Mp4File.RelocateMoovAsync(filename);
|
||||||
}
|
}
|
||||||
else return Mp4Operation.CompletedOperation;
|
else return Mp4Operation.FromCompleted(aaxFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,16 @@ using System;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
private readonly AverageSpeed averageSpeed = new();
|
private readonly AverageSpeed averageSpeed = new();
|
||||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
private TempFile? outputTempFile;
|
||||||
: base(outFileName, cacheDirectory, dlOptions)
|
|
||||||
|
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
|
: base(outDirectory, cacheDirectory, dlOptions)
|
||||||
{
|
{
|
||||||
var step = 1;
|
var step = 1;
|
||||||
|
|
||||||
@@ -21,7 +24,6 @@ namespace AaxDecrypter
|
|||||||
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||||
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
||||||
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
|
||||||
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +41,16 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
if (AaxFile is null) return false;
|
||||||
|
outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||||
|
FileUtility.SaferDelete(outputTempFile.FilePath);
|
||||||
|
|
||||||
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
OnFileCreated(OutputFileName);
|
OnTempFileCreated(outputTempFile);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await (AaxConversion = decryptAsync(outputFile));
|
await (AaxConversion = decryptAsync(AaxFile, outputFile));
|
||||||
|
|
||||||
return AaxConversion.IsCompletedSuccessfully;
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
}
|
}
|
||||||
@@ -58,14 +62,15 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
private async Task<bool> Step_MoveMoov()
|
private async Task<bool> Step_MoveMoov()
|
||||||
{
|
{
|
||||||
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
|
if (outputTempFile is null) return false;
|
||||||
|
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
|
||||||
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
||||||
await AaxConversion;
|
await AaxConversion;
|
||||||
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
||||||
return AaxConversion.IsCompletedSuccessfully;
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
|
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
|
||||||
{
|
{
|
||||||
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
||||||
|
|
||||||
@@ -84,20 +89,20 @@ namespace AaxDecrypter
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mp4Operation decryptAsync(Stream outputFile)
|
private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
|
||||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
||||||
? AaxFile.ConvertToMp3Async
|
? aaxFile.ConvertToMp3Async
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.LameConfig,
|
DownloadOptions.LameConfig,
|
||||||
DownloadOptions.ChapterInfo
|
DownloadOptions.ChapterInfo
|
||||||
)
|
)
|
||||||
: DownloadOptions.FixupFile
|
: DownloadOptions.FixupFile
|
||||||
? AaxFile.ConvertToMp4aAsync
|
? aaxFile.ConvertToMp4aAsync
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.ChapterInfo
|
DownloadOptions.ChapterInfo
|
||||||
)
|
)
|
||||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
: aaxFile.ConvertToMp4aAsync(outputFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,55 +6,50 @@ using System;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public enum OutputFormat { M4b, Mp3 }
|
public enum OutputFormat { M4b, Mp3 }
|
||||||
|
|
||||||
public abstract class AudiobookDownloadBase
|
public abstract class AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
public event EventHandler<string> RetrievedTitle;
|
public event EventHandler<string?>? RetrievedTitle;
|
||||||
public event EventHandler<string> RetrievedAuthors;
|
public event EventHandler<string?>? RetrievedAuthors;
|
||||||
public event EventHandler<string> RetrievedNarrators;
|
public event EventHandler<string?>? RetrievedNarrators;
|
||||||
public event EventHandler<byte[]> RetrievedCoverArt;
|
public event EventHandler<byte[]?>? RetrievedCoverArt;
|
||||||
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
|
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
|
||||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
|
||||||
public event EventHandler<string> FileCreated;
|
public event EventHandler<TempFile>? TempFileCreated;
|
||||||
|
|
||||||
public bool IsCanceled { get; protected set; }
|
public bool IsCanceled { get; protected set; }
|
||||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||||
protected string OutputFileName { get; }
|
protected string OutputDirectory { get; }
|
||||||
protected IDownloadOptions DownloadOptions { get; }
|
public IDownloadOptions DownloadOptions { get; }
|
||||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
|
||||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||||
private bool downloadFinished;
|
private bool downloadFinished;
|
||||||
|
|
||||||
private readonly NetworkFileStreamPersister nfsPersister;
|
private NetworkFileStreamPersister? m_nfsPersister;
|
||||||
|
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
|
||||||
private readonly DownloadProgress zeroProgress;
|
private readonly DownloadProgress zeroProgress;
|
||||||
private readonly string jsonDownloadState;
|
private readonly string jsonDownloadState;
|
||||||
private readonly string tempFilePath;
|
private readonly string tempFilePath;
|
||||||
|
|
||||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
{
|
{
|
||||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
|
||||||
|
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||||
|
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
||||||
|
|
||||||
var outDir = Path.GetDirectoryName(OutputFileName);
|
if (!Directory.Exists(OutputDirectory))
|
||||||
if (!Directory.Exists(outDir))
|
Directory.CreateDirectory(OutputDirectory);
|
||||||
Directory.CreateDirectory(outDir);
|
|
||||||
|
|
||||||
if (!Directory.Exists(cacheDirectory))
|
if (!Directory.Exists(cacheDirectory))
|
||||||
Directory.CreateDirectory(cacheDirectory);
|
Directory.CreateDirectory(cacheDirectory);
|
||||||
|
|
||||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
|
||||||
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||||
|
|
||||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
|
||||||
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
|
||||||
|
|
||||||
// delete file after validation is complete
|
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
|
||||||
|
|
||||||
nfsPersister = OpenNetworkFileStream();
|
|
||||||
|
|
||||||
zeroProgress = new DownloadProgress
|
zeroProgress = new DownloadProgress
|
||||||
{
|
{
|
||||||
BytesReceived = 0,
|
BytesReceived = 0,
|
||||||
@@ -65,19 +60,30 @@ namespace AaxDecrypter
|
|||||||
OnDecryptProgressUpdate(zeroProgress);
|
OnDecryptProgressUpdate(zeroProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected TempFile GetNewTempFilePath(string extension)
|
||||||
|
{
|
||||||
|
extension = FileUtility.GetStandardizedExtension(extension);
|
||||||
|
var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension);
|
||||||
|
return new(path, extension);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> RunAsync()
|
public async Task<bool> RunAsync()
|
||||||
{
|
{
|
||||||
await InputFileStream.BeginDownloadingAsync();
|
await InputFileStream.BeginDownloadingAsync();
|
||||||
var progressTask = Task.Run(reportProgress);
|
var progressTask = Task.Run(reportProgress);
|
||||||
|
|
||||||
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
|
||||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
(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;
|
await progressTask;
|
||||||
|
|
||||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||||
|
|
||||||
|
NfsPersister.Dispose();
|
||||||
return success;
|
return success;
|
||||||
|
|
||||||
async Task reportProgress()
|
async Task reportProgress()
|
||||||
@@ -115,54 +121,52 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task CancelAsync();
|
public virtual Task CancelAsync()
|
||||||
|
{
|
||||||
|
IsCanceled = true;
|
||||||
|
FinalizeDownload();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||||
|
|
||||||
public virtual void SetCoverArt(byte[] coverArt) { }
|
public virtual void SetCoverArt(byte[] coverArt) { }
|
||||||
|
protected void OnRetrievedTitle(string? title)
|
||||||
protected void OnRetrievedTitle(string title)
|
|
||||||
=> RetrievedTitle?.Invoke(this, title);
|
=> RetrievedTitle?.Invoke(this, title);
|
||||||
protected void OnRetrievedAuthors(string authors)
|
protected void OnRetrievedAuthors(string? authors)
|
||||||
=> RetrievedAuthors?.Invoke(this, authors);
|
=> RetrievedAuthors?.Invoke(this, authors);
|
||||||
protected void OnRetrievedNarrators(string narrators)
|
protected void OnRetrievedNarrators(string? narrators)
|
||||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
protected void OnRetrievedCoverArt(byte[]? coverArt)
|
||||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||||
protected void OnFileCreated(string path)
|
public void OnTempFileCreated(TempFile path)
|
||||||
=> FileCreated?.Invoke(this, path);
|
=> TempFileCreated?.Invoke(this, path);
|
||||||
|
|
||||||
protected virtual void FinalizeDownload()
|
protected virtual void FinalizeDownload()
|
||||||
{
|
{
|
||||||
nfsPersister?.Dispose();
|
NfsPersister.Dispose();
|
||||||
downloadFinished = true;
|
downloadFinished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
|
|
||||||
{
|
|
||||||
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
|
|
||||||
{
|
|
||||||
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
|
|
||||||
|
|
||||||
if (File.Exists(recordsFile))
|
|
||||||
OnFileCreated(recordsFile);
|
|
||||||
}
|
|
||||||
return !IsCanceled;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async Task<bool> Step_CreateCueAsync()
|
protected async Task<bool> Step_CreateCueAsync()
|
||||||
{
|
{
|
||||||
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
||||||
|
|
||||||
|
if (DownloadOptions.ChapterInfo.Count <= 1)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
|
||||||
|
return !IsCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
// not a critical step. its failure should not prevent future steps from running
|
// not a critical step. its failure should not prevent future steps from running
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
var tempFile = GetNewTempFilePath(".cue");
|
||||||
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
|
||||||
OnFileCreated(path);
|
OnTempFileCreated(tempFile);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -171,39 +175,9 @@ namespace AaxDecrypter
|
|||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> CleanupAsync()
|
|
||||||
{
|
|
||||||
if (IsCanceled) return false;
|
|
||||||
|
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
|
||||||
DownloadOptions.RetainEncryptedFile)
|
|
||||||
{
|
|
||||||
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
|
||||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
|
||||||
|
|
||||||
//Write aax decryption key
|
|
||||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
|
||||||
FileUtility.SaferDelete(keyPath);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
|
|
||||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
|
||||||
else
|
|
||||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
|
||||||
|
|
||||||
OnFileCreated(aaxPath);
|
|
||||||
OnFileCreated(keyPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
FileUtility.SaferDelete(tempFilePath);
|
|
||||||
|
|
||||||
return !IsCanceled;
|
|
||||||
}
|
|
||||||
|
|
||||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||||
{
|
{
|
||||||
NetworkFileStreamPersister nfsp = default;
|
NetworkFileStreamPersister? nfsp = default;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!File.Exists(jsonDownloadState))
|
if (!File.Exists(jsonDownloadState))
|
||||||
@@ -217,14 +191,21 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
nfsp?.Target?.Dispose();
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
FileUtility.SaferDelete(tempFilePath);
|
FileUtility.SaferDelete(tempFilePath);
|
||||||
return nfsp = newNetworkFilePersister();
|
return nfsp = newNetworkFilePersister();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
|
||||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
if (nfsp is not null)
|
||||||
|
{
|
||||||
|
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||||
|
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||||
|
OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString()));
|
||||||
|
OnTempFileCreated(new(jsonDownloadState));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkFileStreamPersister newNetworkFilePersister()
|
NetworkFileStreamPersister newNetworkFilePersister()
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ public class AverageSpeed
|
|||||||
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
||||||
|
|
||||||
/// <param name="slowWindow">Total moving average time window</param>
|
/// <param name="slowWindow">Total moving average time window</param>
|
||||||
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
/// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||||
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
|
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
|
||||||
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
|
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
|
||||||
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
|
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
|
||||||
@@ -119,7 +119,7 @@ public class AverageSpeed
|
|||||||
/// <summary>Add a new position to the moving average</summary>
|
/// <summary>Add a new position to the moving average</summary>
|
||||||
public void AddPosition(double position)
|
public void AddPosition(double position)
|
||||||
{
|
{
|
||||||
var now = DateTime.Now;
|
var now = DateTime.UtcNow;
|
||||||
if (start == default)
|
if (start == default)
|
||||||
start = now;
|
start = now;
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,54 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
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;
|
event EventHandler<long> DownloadSpeedChanged;
|
||||||
string DownloadUrl { get; }
|
string DownloadUrl { get; }
|
||||||
string UserAgent { get; }
|
string UserAgent { get; }
|
||||||
string AudibleKey { get; }
|
KeyData[]? DecryptionKeys { get; }
|
||||||
string AudibleIV { get; }
|
|
||||||
TimeSpan RuntimeLength { get; }
|
TimeSpan RuntimeLength { get; }
|
||||||
OutputFormat OutputFormat { get; }
|
OutputFormat OutputFormat { get; }
|
||||||
bool TrimOutputToChapterLength { get; }
|
|
||||||
bool RetainEncryptedFile { get; }
|
|
||||||
bool StripUnabridged { get; }
|
bool StripUnabridged { get; }
|
||||||
bool CreateCueSheet { get; }
|
bool CreateCueSheet { get; }
|
||||||
bool DownloadClipsBookmarks { get; }
|
|
||||||
long DownloadSpeedBps { get; }
|
long DownloadSpeedBps { get; }
|
||||||
ChapterInfo ChapterInfo { get; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool FixupFile { get; }
|
bool FixupFile { get; }
|
||||||
string AudibleProductId { get; }
|
string? AudibleProductId { get; }
|
||||||
string Title { get; }
|
string? Title { get; }
|
||||||
string Subtitle { get; }
|
string? Subtitle { get; }
|
||||||
string Publisher { get; }
|
string? Publisher { get; }
|
||||||
string Language { get; }
|
string? Language { get; }
|
||||||
string SeriesName { get; }
|
string? SeriesName { get; }
|
||||||
float? SeriesNumber { get; }
|
float? SeriesNumber { get; }
|
||||||
NAudio.Lame.LameConfig LameConfig { get; }
|
NAudio.Lame.LameConfig? LameConfig { get; }
|
||||||
bool Downsample { get; }
|
bool Downsample { get; }
|
||||||
bool MatchSourceBitrate { get; }
|
bool MatchSourceBitrate { get; }
|
||||||
bool MoveMoovToBeginning { get; }
|
bool MoveMoovToBeginning { get; }
|
||||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
|
||||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
public FileType? InputType { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,18 +55,20 @@ namespace AaxDecrypter
|
|||||||
private CancellationTokenSource _cancellationSource { get; } = new();
|
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||||
private EventWaitHandle _downloadedPiece { get; set; }
|
private EventWaitHandle _downloadedPiece { get; set; }
|
||||||
|
|
||||||
|
private DateTime NextUpdateTime { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constants
|
#region Constants
|
||||||
|
|
||||||
//Download buffer size
|
//Download memory buffer size
|
||||||
private const int DOWNLOAD_BUFF_SZ = 32 * 1024;
|
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
||||||
|
|
||||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||||
|
|
||||||
//Number of times per second the download rate is checkd and throttled
|
//Number of times per second the download rate is checked and throttled
|
||||||
private const int THROTTLE_FREQUENCY = 8;
|
private const int THROTTLE_FREQUENCY = 8;
|
||||||
|
|
||||||
//Minimum throttle rate. The minimum amount of data that can be throttled
|
//Minimum throttle rate. The minimum amount of data that can be throttled
|
||||||
@@ -98,6 +100,12 @@ namespace AaxDecrypter
|
|||||||
Position = WritePosition
|
Position = WritePosition
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (_writeFile.Length < WritePosition)
|
||||||
|
{
|
||||||
|
_writeFile.Dispose();
|
||||||
|
throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}");
|
||||||
|
}
|
||||||
|
|
||||||
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||||
|
|
||||||
SetUriForSameFile(uri);
|
SetUriForSameFile(uri);
|
||||||
@@ -108,12 +116,18 @@ namespace AaxDecrypter
|
|||||||
#region Downloader
|
#region Downloader
|
||||||
|
|
||||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||||
private void OnUpdate()
|
private void OnUpdate(bool waitForWrite = false)
|
||||||
{
|
{
|
||||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Updated?.Invoke(this, EventArgs.Empty);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -135,7 +149,6 @@ namespace AaxDecrypter
|
|||||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||||
|
|
||||||
Uri = uriToSameFile;
|
Uri = uriToSameFile;
|
||||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||||
@@ -151,39 +164,103 @@ namespace AaxDecrypter
|
|||||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
//Initiate connection with the first request block and
|
||||||
|
//get the total content length before returning.
|
||||||
|
var client = new HttpClient();
|
||||||
|
var response = await RequestNextByteRangeAsync(client);
|
||||||
|
|
||||||
|
if (ContentLength != 0 && ContentLength != response.FileSize)
|
||||||
|
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
|
||||||
|
|
||||||
|
ContentLength = response.FileSize;
|
||||||
|
|
||||||
|
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||||
|
//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(HttpClient client, BlockResponse blockResponse)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long startPosition = WritePosition;
|
||||||
|
|
||||||
|
while (WritePosition < ContentLength && !IsCancelled)
|
||||||
|
{
|
||||||
|
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.Dispose();
|
||||||
|
blockResponse.Dispose();
|
||||||
|
client.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
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)
|
foreach (var header in RequestHeaders)
|
||||||
request.Headers.Add(header.Key, header.Value);
|
request.Headers.Add(header.Key, header.Value);
|
||||||
|
|
||||||
var response = await new HttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
request.Headers.Add("Range", $"bytes={WritePosition}-");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||||
|
|
||||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||||
|
|
||||||
//Content length is the length of the range request, and it is only equal
|
var totalSize = response.Content.Headers.ContentRange?.Length ??
|
||||||
//to the complete file length if requesting Range: bytes=0-
|
throw new WebException("The response did not contain a total content length.");
|
||||||
if (WritePosition == 0)
|
|
||||||
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
|
|
||||||
|
|
||||||
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
var rangeSize = response.Content.Headers.ContentLength ??
|
||||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
|
||||||
|
|
||||||
//Download the file in the background.
|
return new BlockResponse(response, rangeSize, totalSize);
|
||||||
|
}
|
||||||
|
|
||||||
DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() => Response?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||||
private async Task DownloadFile(Stream networkStream)
|
private async Task DownloadToFile(BlockResponse block)
|
||||||
{
|
{
|
||||||
|
var endPosition = WritePosition + block.BlockSize;
|
||||||
|
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||||
|
|
||||||
var downloadPosition = WritePosition;
|
var downloadPosition = WritePosition;
|
||||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DateTime startTime = DateTime.Now;
|
DateTime startTime = DateTime.UtcNow;
|
||||||
long bytesReadSinceThrottle = 0;
|
long bytesReadSinceThrottle = 0;
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
do
|
do
|
||||||
@@ -208,24 +285,25 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
|
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)
|
if (delayMS > 0)
|
||||||
await Task.Delay(delayMS, _cancellationSource.Token);
|
await Task.Delay(delayMS, _cancellationSource.Token);
|
||||||
|
|
||||||
startTime = DateTime.Now;
|
startTime = DateTime.UtcNow;
|
||||||
bytesReadSinceThrottle = 0;
|
bytesReadSinceThrottle = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
|
||||||
|
|
||||||
|
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||||
WritePosition = downloadPosition;
|
WritePosition = downloadPosition;
|
||||||
|
|
||||||
if (!IsCancelled && WritePosition < ContentLength)
|
if (!IsCancelled && WritePosition < endPosition)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
if (WritePosition > ContentLength)
|
if (WritePosition > endPosition)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
@@ -234,10 +312,8 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
networkStream.Close();
|
|
||||||
_writeFile.Close();
|
|
||||||
_downloadedPiece.Set();
|
_downloadedPiece.Set();
|
||||||
OnUpdate();
|
OnUpdate(waitForWrite: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +410,7 @@ namespace AaxDecrypter
|
|||||||
_cancellationSource?.Dispose();
|
_cancellationSource?.Dispose();
|
||||||
_readFile.Dispose();
|
_readFile.Dispose();
|
||||||
_writeFile.Dispose();
|
_writeFile.Dispose();
|
||||||
OnUpdate();
|
OnUpdate(waitForWrite: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using FileManager;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AaxDecrypter;
|
||||||
|
|
||||||
|
public record TempFile
|
||||||
|
{
|
||||||
|
public LongPath FilePath { get; init; }
|
||||||
|
public string Extension { get; }
|
||||||
|
public MultiConvertFileProperties? PartProperties { get; init; }
|
||||||
|
public TempFile(LongPath filePath, string? extension = null)
|
||||||
|
{
|
||||||
|
FilePath = filePath;
|
||||||
|
extension ??= System.IO.Path.GetExtension(filePath);
|
||||||
|
Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using FileManager;
|
using FileManager;
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
@@ -8,20 +7,12 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
protected override long InputFilePosition => InputFileStream.WritePosition;
|
protected override long InputFilePosition => InputFileStream.WritePosition;
|
||||||
|
|
||||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
|
||||||
: base(outFileName, cacheDirectory, dlLic)
|
: base(outDirectory, cacheDirectory, dlLic)
|
||||||
{
|
{
|
||||||
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||||
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
|
||||||
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task CancelAsync()
|
|
||||||
{
|
|
||||||
IsCanceled = true;
|
|
||||||
FinalizeDownload();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
@@ -33,8 +24,9 @@ namespace AaxDecrypter
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
FinalizeDownload();
|
FinalizeDownload();
|
||||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
|
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||||
OnFileCreated(OutputFileName);
|
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
|
||||||
|
OnTempFileCreated(tempFile);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Version>12.2.0.1</Version>
|
<Version>12.4.10.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||||
<!-- Do not remove unused Serilog.Sinks -->
|
<!-- 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.Console" Version="6.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||||
|
|||||||
@@ -115,11 +115,22 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
if (config.GetObject("Serilog") is JObject serilog)
|
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";
|
zipFileSink["Name"] = "File";
|
||||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,17 +140,17 @@ namespace AppScaffolding
|
|||||||
{ "WriteTo", new JArray
|
{ "WriteTo", new JArray
|
||||||
{
|
{
|
||||||
// ABOUT SINKS
|
// 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", "Console" } }, // this has caused more problems than it's solved
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
{ "Name", "ZipFile" },
|
{ "Name", "File" },
|
||||||
{ "Args",
|
{ "Args",
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
// for this sink to work, a path must be provided. we override this below
|
// 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" },
|
{ "rollingInterval", "Month" },
|
||||||
// Serilog template formatting examples
|
// Serilog template formatting examples
|
||||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
// - 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),
|
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");
|
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,33 +301,24 @@ namespace AppScaffolding
|
|||||||
public static UpgradeProperties GetLatestRelease()
|
public static UpgradeProperties GetLatestRelease()
|
||||||
{
|
{
|
||||||
// timed out
|
// timed out
|
||||||
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
(var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
if (latest is null || zip is null)
|
if (version is null || latest is null || zip is null)
|
||||||
return null;
|
|
||||||
|
|
||||||
var latestVersionString = latest.TagName.Trim('v');
|
|
||||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// we're up to date
|
|
||||||
if (latestRelease <= BuildVersion)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// we have an update
|
// we have an update
|
||||||
|
|
||||||
var zipUrl = zip?.BrowserDownloadUrl;
|
var zipUrl = zip?.BrowserDownloadUrl;
|
||||||
|
|
||||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||||
{
|
{
|
||||||
latestRelease = latestRelease.ToString(),
|
latestRelease = version.ToString(),
|
||||||
latest.HtmlUrl,
|
latest.HtmlUrl,
|
||||||
zipUrl
|
zipUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
|
return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body);
|
||||||
}
|
}
|
||||||
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -330,15 +332,23 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||||
}
|
}
|
||||||
return (null, null);
|
return (null, null, null);
|
||||||
}
|
}
|
||||||
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||||
{
|
{
|
||||||
const string ownerAccount = "rmcrackan";
|
const string ownerAccount = "rmcrackan";
|
||||||
const string repoName = "Libation";
|
const string repoName = "Libation";
|
||||||
|
|
||||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||||
|
|
||||||
|
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||||
|
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
||||||
|
|
||||||
|
//Ensure that latest release is greater than the current version
|
||||||
|
var latestVersionString = latestRelease.TagName.Trim('v');
|
||||||
|
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
|
||||||
|
return (null, null, null);
|
||||||
|
|
||||||
//Download the release index
|
//Download the release index
|
||||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||||
@@ -356,10 +366,7 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||||
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
|
||||||
|
|
||||||
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +425,6 @@ namespace AppScaffolding
|
|||||||
public List<string> Filters { get; set; } = new();
|
public List<string> Filters { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static void migrate_to_v12_0_1(Configuration config)
|
public static void migrate_to_v12_0_1(Configuration config)
|
||||||
{
|
{
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="NPOI" Version="2.7.2" />
|
<PackageReference Include="NPOI" Version="2.7.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ namespace ApplicationServices
|
|||||||
ScanEnd += (_, __) => Scanning = false;
|
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();
|
logRestart();
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ namespace ApplicationServices
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = libraryItems.Count;
|
var totalCount = libraryItems.Count;
|
||||||
@@ -101,7 +101,7 @@ namespace ApplicationServices
|
|||||||
}
|
}
|
||||||
|
|
||||||
#region FULL LIBRARY scan and import
|
#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();
|
logRestart();
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ namespace ApplicationServices
|
|||||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
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");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = importItems.Count;
|
var totalCount = importItems.Count;
|
||||||
@@ -262,7 +262,7 @@ namespace ApplicationServices
|
|||||||
return null;
|
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>>>();
|
var tasks = new List<Task<List<ImportItem>>>();
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ namespace ApplicationServices
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
// 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
|
// add scanAccountAsync as a TASK: do not await
|
||||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||||
@@ -521,8 +521,8 @@ namespace ApplicationServices
|
|||||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||||
});
|
});
|
||||||
|
|
||||||
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion)
|
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
|
||||||
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
|
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
|
||||||
|
|
||||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using System.Linq;
|
|||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration.Attributes;
|
using CsvHelper.Configuration.Attributes;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using NPOI.XSSF.UserModel;
|
using NPOI.XSSF.UserModel;
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace ApplicationServices
|
namespace ApplicationServices
|
||||||
{
|
{
|
||||||
@@ -104,9 +104,6 @@ namespace ApplicationServices
|
|||||||
[Name("Content Type")]
|
[Name("Content Type")]
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
|
|
||||||
[Name("Audio Format")]
|
|
||||||
public string AudioFormat { get; set; }
|
|
||||||
|
|
||||||
[Name("Language")]
|
[Name("Language")]
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
|
||||||
@@ -118,7 +115,29 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
[Name("IsFinished")]
|
[Name("IsFinished")]
|
||||||
public bool IsFinished { get; set; }
|
public bool IsFinished { get; set; }
|
||||||
}
|
|
||||||
|
[Name("IsSpatial")]
|
||||||
|
public bool IsSpatial { get; set; }
|
||||||
|
|
||||||
|
[Name("Last Downloaded File Version")]
|
||||||
|
public string LastDownloadedFileVersion { get; set; }
|
||||||
|
|
||||||
|
[Ignore /* csv ignore */]
|
||||||
|
public AudioFormat LastDownloadedFormat { get; set; }
|
||||||
|
|
||||||
|
[Name("Last Downloaded Codec"), JsonIgnore]
|
||||||
|
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
|
||||||
|
|
||||||
|
[Name("Last Downloaded Sample rate"), JsonIgnore]
|
||||||
|
public int? SampleRate => LastDownloadedFormat?.SampleRate;
|
||||||
|
|
||||||
|
[Name("Last Downloaded Audio Channels"), JsonIgnore]
|
||||||
|
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
|
||||||
|
|
||||||
|
[Name("Last Downloaded Bitrate"), JsonIgnore]
|
||||||
|
public int? BitRate => LastDownloadedFormat?.BitRate;
|
||||||
|
}
|
||||||
|
|
||||||
public static class LibToDtos
|
public static class LibToDtos
|
||||||
{
|
{
|
||||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||||
@@ -138,26 +157,30 @@ namespace ApplicationServices
|
|||||||
HasPdf = a.Book.HasPdf(),
|
HasPdf = a.Book.HasPdf(),
|
||||||
SeriesNames = a.Book.SeriesNames(),
|
SeriesNames = a.Book.SeriesNames(),
|
||||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||||
CommunityRatingOverall = a.Book.Rating?.OverallRating,
|
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
|
||||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
|
||||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
|
||||||
PictureId = a.Book.PictureId,
|
PictureId = a.Book.PictureId,
|
||||||
IsAbridged = a.Book.IsAbridged,
|
IsAbridged = a.Book.IsAbridged,
|
||||||
DatePublished = a.Book.DatePublished,
|
DatePublished = a.Book.DatePublished,
|
||||||
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
||||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
|
||||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
|
||||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
|
||||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||||
ContentType = a.Book.ContentType.ToString(),
|
ContentType = a.Book.ContentType.ToString(),
|
||||||
AudioFormat = a.Book.AudioFormat.ToString(),
|
|
||||||
Language = a.Book.Language,
|
Language = a.Book.Language,
|
||||||
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||||
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||||
IsFinished = a.Book.UserDefinedItem.IsFinished
|
IsFinished = a.Book.UserDefinedItem.IsFinished,
|
||||||
}).ToList();
|
IsSpatial = a.Book.IsSpatial,
|
||||||
|
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
|
||||||
|
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
private static float? ZeroIsNull(this float value) => value is 0 ? null : value;
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
{
|
{
|
||||||
@@ -166,7 +189,6 @@ namespace ApplicationServices
|
|||||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||||
if (!dtos.Any())
|
if (!dtos.Any())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
@@ -178,7 +200,7 @@ namespace ApplicationServices
|
|||||||
public static void ToJson(string saveFilePath)
|
public static void ToJson(string saveFilePath)
|
||||||
{
|
{
|
||||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||||
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
|
var json = JsonConvert.SerializeObject(dtos, Formatting.Indented);
|
||||||
System.IO.File.WriteAllText(saveFilePath, json);
|
System.IO.File.WriteAllText(saveFilePath, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,11 +250,16 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.BookStatus),
|
nameof(ExportDto.BookStatus),
|
||||||
nameof(ExportDto.PdfStatus),
|
nameof(ExportDto.PdfStatus),
|
||||||
nameof(ExportDto.ContentType),
|
nameof(ExportDto.ContentType),
|
||||||
nameof(ExportDto.AudioFormat),
|
|
||||||
nameof(ExportDto.Language),
|
nameof(ExportDto.Language),
|
||||||
nameof(ExportDto.LastDownloaded),
|
nameof(ExportDto.LastDownloaded),
|
||||||
nameof(ExportDto.LastDownloadedVersion),
|
nameof(ExportDto.LastDownloadedVersion),
|
||||||
nameof(ExportDto.IsFinished)
|
nameof(ExportDto.IsFinished),
|
||||||
|
nameof(ExportDto.IsSpatial),
|
||||||
|
nameof(ExportDto.LastDownloadedFileVersion),
|
||||||
|
nameof(ExportDto.CodecString),
|
||||||
|
nameof(ExportDto.SampleRate),
|
||||||
|
nameof(ExportDto.ChannelCount),
|
||||||
|
nameof(ExportDto.BitRate)
|
||||||
};
|
};
|
||||||
var col = 0;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
@@ -253,15 +280,10 @@ namespace ApplicationServices
|
|||||||
foreach (var dto in dtos)
|
foreach (var dto in dtos)
|
||||||
{
|
{
|
||||||
col = 0;
|
col = 0;
|
||||||
|
row = sheet.CreateRow(rowIndex++);
|
||||||
row = sheet.CreateRow(rowIndex);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle;
|
||||||
var dateCell = row.CreateCell(col++);
|
|
||||||
dateCell.CellStyle = dateStyle;
|
|
||||||
dateCell.SetCellValue(dto.DateAdded);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||||
@@ -274,57 +296,46 @@ namespace ApplicationServices
|
|||||||
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
||||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall);
|
||||||
col = createCell(row, col, dto.CommunityRatingOverall);
|
row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance);
|
||||||
col = createCell(row, col, dto.CommunityRatingPerformance);
|
row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory);
|
||||||
col = createCell(row, col, dto.CommunityRatingStory);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
||||||
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle;
|
||||||
var datePubCell = row.CreateCell(col++);
|
|
||||||
datePubCell.CellStyle = dateStyle;
|
|
||||||
if (dto.DatePublished.HasValue)
|
|
||||||
datePubCell.SetCellValue(dto.DatePublished.Value);
|
|
||||||
else
|
|
||||||
datePubCell.SetCellValue("");
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.MyRatingOverall);
|
||||||
col = createCell(row, col, dto.MyRatingOverall);
|
row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance);
|
||||||
col = createCell(row, col, dto.MyRatingPerformance);
|
row.CreateCell(col++).SetCellValue(dto.MyRatingStory);
|
||||||
col = createCell(row, col, dto.MyRatingStory);
|
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle;
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion);
|
||||||
if (dto.LastDownloaded.HasValue)
|
row.CreateCell(col++).SetCellValue(dto.IsFinished);
|
||||||
{
|
row.CreateCell(col++).SetCellValue(dto.IsSpatial);
|
||||||
dateCell = row.CreateCell(col);
|
row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion);
|
||||||
dateCell.CellStyle = dateStyle;
|
row.CreateCell(col++).SetCellValue(dto.CodecString);
|
||||||
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
row.CreateCell(col++).SetCellValue(dto.SampleRate);
|
||||||
}
|
row.CreateCell(col++).SetCellValue(dto.ChannelCount);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.BitRate);
|
||||||
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
|
||||||
row.CreateCell(++col).SetCellValue(dto.IsFinished);
|
|
||||||
|
|
||||||
rowIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||||
workbook.Write(fileData);
|
workbook.Write(fileData);
|
||||||
}
|
}
|
||||||
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
|
|
||||||
{
|
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate)
|
||||||
if (nullableFloat.HasValue)
|
=> nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value)
|
||||||
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
|
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||||
else
|
|
||||||
row.CreateCell(col++).SetCellValue("");
|
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt)
|
||||||
return col;
|
=> nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value)
|
||||||
}
|
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||||
|
|
||||||
|
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat)
|
||||||
|
=> nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value)
|
||||||
|
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,22 @@ namespace AudibleUtilities
|
|||||||
update_no_validate();
|
update_no_validate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string _cdm;
|
||||||
|
[JsonProperty]
|
||||||
|
public string Cdm
|
||||||
|
{
|
||||||
|
get => _cdm;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cdm = value;
|
||||||
|
update_no_validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ using Polly;
|
|||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AudibleUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||||
public class ApiExtended
|
public class ApiExtended
|
||||||
{
|
{
|
||||||
|
public static Func<Account, ILoginChoiceEager>? LoginChoiceFactory { get; set; }
|
||||||
public Api Api { get; private set; }
|
public Api Api { get; private set; }
|
||||||
|
|
||||||
private const int MaxConcurrency = 10;
|
private const int MaxConcurrency = 10;
|
||||||
@@ -24,52 +26,46 @@ namespace AudibleUtilities
|
|||||||
private ApiExtended(Api api) => Api = api;
|
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>
|
/// <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)
|
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||||
|
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||||
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
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);
|
var api = await EzApiCreator.GetApiAsync(
|
||||||
}
|
account.Locale,
|
||||||
|
AudibleApiStorage.AccountsSettingsFile,
|
||||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
account.GetIdentityTokensJsonPath());
|
||||||
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
|
return new ApiExtended(api);
|
||||||
{
|
}
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
catch
|
||||||
{
|
{
|
||||||
Username = username.ToMask(),
|
if (LoginChoiceFactory is null)
|
||||||
LocaleName = localeName,
|
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||||
Localization.Get(localeName),
|
{
|
||||||
|
LoginType = nameof(ILoginChoiceEager),
|
||||||
|
Account = account.MaskedLogEntry ?? "[null]",
|
||||||
|
LocaleName = account.Locale?.Name
|
||||||
|
});
|
||||||
|
|
||||||
|
var api = await EzApiCreator.GetApiAsync(
|
||||||
|
LoginChoiceFactory(account),
|
||||||
|
account.Locale,
|
||||||
AudibleApiStorage.AccountsSettingsFile,
|
AudibleApiStorage.AccountsSettingsFile,
|
||||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
|
account.GetIdentityTokensJsonPath());
|
||||||
return new ApiExtended(api);
|
|
||||||
}
|
return new ApiExtended(api);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static AsyncRetryPolicy policy { get; }
|
private static AsyncRetryPolicy policy { get; }
|
||||||
= Policy.Handle<Exception>()
|
= Policy.Handle<Exception>()
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="9.3.2.1" />
|
<PackageReference Include="AudibleApi" Version="9.4.2.1" />
|
||||||
|
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -20,4 +21,9 @@
|
|||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="Widevine\Cdm.*.cs">
|
||||||
|
<DependentUpon>Cdm.cs</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using AudibleApi;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Net.Http;
|
||||||
|
using AudibleApi.Cryptography;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Dinah.Core.Net.Http;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
public partial class Cdm
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get a <see cref="Cdm"/> from <see cref="AccountsSettings"/> or from the API.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Cdm"/> if successful, otherwise <see cref="null"/></returns>
|
||||||
|
public static async Task<Cdm?> GetCdmAsync()
|
||||||
|
{
|
||||||
|
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||||
|
|
||||||
|
//Check if there are any Android accounts. If not, we can't use Widevine.
|
||||||
|
if (!persister.Target.Accounts.Any(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(persister.Target.Cdm))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cdm = Convert.FromBase64String(persister.Target.Cdm);
|
||||||
|
return new Cdm(new Device(cdm));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error loading CDM from account settings.");
|
||||||
|
persister.Target.Cdm = string.Empty;
|
||||||
|
//Clear the stored Cdm and try getting a fresh one from the server.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(persister.Target.Cdm))
|
||||||
|
{
|
||||||
|
using var client = new HttpClient();
|
||||||
|
if (await GetCdmUris(client) is not Uri[] uris)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
//try to get a CDM file for any account that's registered as an android device.
|
||||||
|
//CDMs are not account-specific, so it doesn't matter which account we're successful with.
|
||||||
|
foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestMessage = CreateApiRequest(account);
|
||||||
|
|
||||||
|
await TestApiRequest(client, new JsonObject { { "body", requestMessage.ToString() } });
|
||||||
|
|
||||||
|
//Try all CDM URIs until a CDM has been retrieved successfully
|
||||||
|
foreach (var uri in uris)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await client.PostAsync(uri, ((HttpBody)requestMessage).Content);
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var message = await resp.Content.ReadAsStringAsync();
|
||||||
|
throw new ApiErrorException(uri, null, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cdmBts = await resp.Content.ReadAsByteArrayAsync();
|
||||||
|
var device = new Device(cdmBts);
|
||||||
|
persister.Target.Cdm = Convert.ToBase64String(cdmBts);
|
||||||
|
return new Cdm(device);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error getting a CDM from URI: " + uri);
|
||||||
|
//try the next URI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error getting a CDM for account: " + account.MaskedLogEntry);
|
||||||
|
//try the next Account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a list of CDM API URIs from the main Gitgub repository's .cdmurls.json file.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>If successful, an array of URIs to try. Otherwise null</returns>
|
||||||
|
private static async Task<Uri[]?> GetCdmUris(HttpClient httpClient)
|
||||||
|
{
|
||||||
|
const string CdmUrlListFile = "https://raw.githubusercontent.com/rmcrackan/Libation/refs/heads/master/.cdmurls.json";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileContents = await httpClient.GetStringAsync(CdmUrlListFile);
|
||||||
|
var releaseIndex = JObject.Parse(fileContents);
|
||||||
|
var urlArray = releaseIndex["CdmUrls"] as JArray;
|
||||||
|
if (urlArray is null)
|
||||||
|
throw new System.IO.InvalidDataException("CDM url list not found in JSON: " + fileContents);
|
||||||
|
|
||||||
|
var uris = urlArray.Select(u => u.Value<string>()).OfType<string>().Select(u => new Uri(u)).ToArray();
|
||||||
|
|
||||||
|
if (uris.Length == 0)
|
||||||
|
throw new System.IO.InvalidDataException("No CDM url found in JSON: " + fileContents);
|
||||||
|
|
||||||
|
return uris;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error getting CDM URLs");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static readonly string[] TLDs = ["com", "co.uk", "com.au", "com.br", "ca", "fr", "de", "in", "it", "co.jp", "es"];
|
||||||
|
|
||||||
|
//Ensure that the request can be made successfully before sending it to the API
|
||||||
|
//The API uses System.Text.Json, so perform test with same.
|
||||||
|
private static async Task TestApiRequest(HttpClient client, JsonObject input)
|
||||||
|
{
|
||||||
|
if (input["body"]?.GetValue<string>() is not string body
|
||||||
|
|| JsonNode.Parse(body) is not JsonNode bodyJson)
|
||||||
|
throw new Exception("Api request doesn't contain a body");
|
||||||
|
|
||||||
|
if (bodyJson?["Url"]?.GetValue<string>() is not string url
|
||||||
|
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
throw new Exception("Api request doesn't contain a url");
|
||||||
|
|
||||||
|
if (!TLDs.Select(tld => "api.audible." + tld).Contains(uri.Host.ToLower()))
|
||||||
|
throw new Exception($"Unknown Audible Api domain: {uri.Host}");
|
||||||
|
|
||||||
|
if (bodyJson?["Headers"] is not JsonObject headers)
|
||||||
|
throw new Exception($"Api request doesn't contain any headers");
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
|
||||||
|
Dictionary<string, string>? headersDict = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
headersDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(headers);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to read Audible Api headers.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headersDict is null)
|
||||||
|
throw new Exception("Failed to read Audible Api headers.");
|
||||||
|
|
||||||
|
foreach (var kvp in headersDict)
|
||||||
|
request.Headers.Add(kvp.Key, kvp.Value);
|
||||||
|
|
||||||
|
using var resp = await client.SendAsync(request);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a request body to send to the API
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">An authenticated account</param>
|
||||||
|
private static JObject CreateApiRequest(Account account)
|
||||||
|
{
|
||||||
|
const string ACCOUNT_INFO_PATH = "/1.0/account/information";
|
||||||
|
|
||||||
|
var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH);
|
||||||
|
message.SignRequest(
|
||||||
|
DateTime.UtcNow,
|
||||||
|
account.IdentityTokens.AdpToken,
|
||||||
|
account.IdentityTokens.PrivateKey);
|
||||||
|
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
{ "Url", new Uri(account.Locale.AudibleApiUri(), ACCOUNT_INFO_PATH) },
|
||||||
|
{ "Headers", JObject.FromObject(message.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Single())) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
public enum KeyType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exactly one key of this type must appear.
|
||||||
|
/// </summary>
|
||||||
|
Signing = 1,
|
||||||
|
/// <summary>
|
||||||
|
/// Content key.
|
||||||
|
/// </summary>
|
||||||
|
Content = 2,
|
||||||
|
/// <summary>
|
||||||
|
/// Key control block for license renewals. No key.
|
||||||
|
/// </summary>
|
||||||
|
KeyControl = 3,
|
||||||
|
/// <summary>
|
||||||
|
/// wrapped keys for auxiliary crypto operations.
|
||||||
|
/// </summary>
|
||||||
|
OperatorSession = 4,
|
||||||
|
/// <summary>
|
||||||
|
/// Entitlement keys.
|
||||||
|
/// </summary>
|
||||||
|
Entitlement = 5,
|
||||||
|
/// <summary>
|
||||||
|
/// Partner-specific content key.
|
||||||
|
/// </summary>
|
||||||
|
OemContent = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISession : IDisposable
|
||||||
|
{
|
||||||
|
string? GetLicenseChallenge(MpegDash dash);
|
||||||
|
WidevineKey[] ParseLicense(string licenseMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WidevineKey
|
||||||
|
{
|
||||||
|
public Guid Kid { get; }
|
||||||
|
public KeyType Type { get; }
|
||||||
|
public byte[] Key { get; }
|
||||||
|
internal WidevineKey(Guid kid, License.Types.KeyContainer.Types.KeyType type, byte[] key)
|
||||||
|
{
|
||||||
|
Kid = kid;
|
||||||
|
Type = (KeyType)type;
|
||||||
|
Key = key;
|
||||||
|
}
|
||||||
|
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Cdm
|
||||||
|
{
|
||||||
|
public static Guid WidevineContentProtection { get; } = new("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
|
||||||
|
private const int MAX_NUM_OF_SESSIONS = 16;
|
||||||
|
internal Device Device { get; }
|
||||||
|
|
||||||
|
private ConcurrentDictionary<Guid, Session> Sessions { get; } = new(-1, MAX_NUM_OF_SESSIONS);
|
||||||
|
|
||||||
|
internal Cdm(Device device)
|
||||||
|
{
|
||||||
|
Device = device;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ISession OpenSession()
|
||||||
|
{
|
||||||
|
if (Sessions.Count == MAX_NUM_OF_SESSIONS)
|
||||||
|
throw new Exception("Too Many Sessions");
|
||||||
|
|
||||||
|
var session = new Session(Sessions.Count + 1, this);
|
||||||
|
|
||||||
|
var ddd = Sessions.TryAdd(session.Id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Session
|
||||||
|
|
||||||
|
internal class Session : ISession
|
||||||
|
{
|
||||||
|
public Guid Id { get; } = Guid.NewGuid();
|
||||||
|
private int SessionNumber { get; }
|
||||||
|
private Cdm Cdm { get; }
|
||||||
|
private byte[]? EncryptionContext { get; set; }
|
||||||
|
private byte[]? AuthenticationContext { get; set; }
|
||||||
|
|
||||||
|
public Session(int number, Cdm cdm)
|
||||||
|
{
|
||||||
|
SessionNumber = number;
|
||||||
|
Cdm = cdm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRequestId()
|
||||||
|
=> $"{RandomUint():x8}00000000{Convert.ToHexString(BitConverter.GetBytes((long)SessionNumber)).ToLowerInvariant()}";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Cdm.Sessions.ContainsKey(Id))
|
||||||
|
Cdm.Sessions.TryRemove(Id, out var session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetLicenseChallenge(MpegDash dash)
|
||||||
|
{
|
||||||
|
if (!dash.TryGetPssh(Cdm.WidevineContentProtection, out var pssh))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var licRequest = new LicenseRequest
|
||||||
|
{
|
||||||
|
ClientId = Cdm.Device.ClientId,
|
||||||
|
ContentId = new()
|
||||||
|
{
|
||||||
|
WidevinePsshData = new()
|
||||||
|
{
|
||||||
|
LicenseType = LicenseType.Offline,
|
||||||
|
RequestId = ByteString.CopyFrom(GetRequestId(), Encoding.ASCII)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Type = LicenseRequest.Types.RequestType.New,
|
||||||
|
RequestTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
|
ProtocolVersion = ProtocolVersion.Version21,
|
||||||
|
KeyControlNonce = RandomUint()
|
||||||
|
};
|
||||||
|
|
||||||
|
licRequest.ContentId.WidevinePsshData.PsshData.Add(ByteString.CopyFrom(pssh.InitData));
|
||||||
|
|
||||||
|
var licRequestBts = licRequest.ToByteArray();
|
||||||
|
EncryptionContext = CreateContext("ENCRYPTION", 128, licRequestBts);
|
||||||
|
AuthenticationContext = CreateContext("AUTHENTICATION", 512, licRequestBts);
|
||||||
|
|
||||||
|
var signedMessage = new SignedMessage
|
||||||
|
{
|
||||||
|
Type = SignedMessage.Types.MessageType.LicenseRequest,
|
||||||
|
Msg = ByteString.CopyFrom(licRequestBts),
|
||||||
|
Signature = ByteString.CopyFrom(Cdm.Device.SignMessage(licRequestBts))
|
||||||
|
};
|
||||||
|
|
||||||
|
return Convert.ToBase64String(signedMessage.ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public WidevineKey[] ParseLicense(string licenseMessage)
|
||||||
|
{
|
||||||
|
if (EncryptionContext is null || AuthenticationContext is null)
|
||||||
|
throw new InvalidOperationException($"{nameof(GetLicenseChallenge)}() must be called before calling {nameof(ParseLicense)}()");
|
||||||
|
|
||||||
|
var signedMessage = SignedMessage.Parser.ParseFrom(Convert.FromBase64String(licenseMessage));
|
||||||
|
if (signedMessage.Type != SignedMessage.Types.MessageType.License)
|
||||||
|
throw new InvalidDataException("Invalid license");
|
||||||
|
|
||||||
|
var sessionKey = Cdm.Device.DecryptSessionKey(signedMessage.SessionKey.ToByteArray());
|
||||||
|
|
||||||
|
if (!VerifySignature(signedMessage, AuthenticationContext, sessionKey))
|
||||||
|
throw new InvalidDataException("Message signature is invalid");
|
||||||
|
|
||||||
|
var license = License.Parser.ParseFrom(signedMessage.Msg);
|
||||||
|
var keyToTheKeys = DeriveKey(sessionKey, EncryptionContext, 1);
|
||||||
|
|
||||||
|
return DecryptKeys(keyToTheKeys, license.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WidevineKey[] DecryptKeys(byte[] keyToTheKeys, IList<License.Types.KeyContainer> licenseKeys)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = keyToTheKeys;
|
||||||
|
var keys = new WidevineKey[licenseKeys.Count];
|
||||||
|
|
||||||
|
for (int i = 0; i < licenseKeys.Count; i++)
|
||||||
|
{
|
||||||
|
var keyContainer = licenseKeys[i];
|
||||||
|
|
||||||
|
var keyBytes = aes.DecryptCbc(keyContainer.Key.ToByteArray(), keyContainer.Iv.ToByteArray(), PaddingMode.PKCS7);
|
||||||
|
var id = keyContainer.Id.ToByteArray();
|
||||||
|
|
||||||
|
if (id.Length > 16)
|
||||||
|
{
|
||||||
|
var tryB64 = new byte[id.Length * 3 / 4];
|
||||||
|
if (Convert.TryFromBase64String(Encoding.ASCII.GetString(id), tryB64, out int bytesWritten))
|
||||||
|
{
|
||||||
|
id = tryB64;
|
||||||
|
}
|
||||||
|
Array.Resize(ref id, 16);
|
||||||
|
}
|
||||||
|
else if (id.Length < 16)
|
||||||
|
{
|
||||||
|
id = id.Append(new byte[16 - id.Length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
keys[i] = new WidevineKey(new Guid(id,bigEndian: true), keyContainer.Type, keyBytes);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifySignature(SignedMessage signedMessage, byte[] authContext, byte[] sessionKey)
|
||||||
|
{
|
||||||
|
var mac_key_server = DeriveKey(sessionKey, authContext, 1).Append(DeriveKey(sessionKey, authContext, 2));
|
||||||
|
|
||||||
|
var hmacData = (signedMessage.OemcryptoCoreMessage?.ToByteArray() ?? []).Append(signedMessage.Msg?.ToByteArray() ?? []);
|
||||||
|
|
||||||
|
var computed_signature = HMACSHA256.HashData(mac_key_server, hmacData);
|
||||||
|
|
||||||
|
return computed_signature.SequenceEqual(signedMessage.Signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DeriveKey(byte[] session_key, byte[] context, int counter)
|
||||||
|
{
|
||||||
|
var data = new byte[context.Length + 1];
|
||||||
|
Array.Copy(context, 0, data, 1, context.Length);
|
||||||
|
data[0] = (byte)counter;
|
||||||
|
|
||||||
|
return AESCMAC(session_key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] AESCMAC(byte[] key, byte[] data)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = key;
|
||||||
|
|
||||||
|
// SubKey generation
|
||||||
|
// step 1, AES-128 with key K is applied to an all-zero input block.
|
||||||
|
byte[] subKey = aes.EncryptCbc(new byte[16], new byte[16], PaddingMode.None);
|
||||||
|
|
||||||
|
nextSubKey();
|
||||||
|
|
||||||
|
// MAC computing
|
||||||
|
if ((data.Length == 0) || (data.Length % 16 != 0))
|
||||||
|
{
|
||||||
|
// If the size of the input message block is not equal to a positive
|
||||||
|
// multiple of the block size (namely, 128 bits), the last block shall
|
||||||
|
// be padded with 10^i
|
||||||
|
nextSubKey();
|
||||||
|
var padLen = 16 - data.Length % 16;
|
||||||
|
Array.Resize(ref data, data.Length + padLen);
|
||||||
|
data[^padLen] = 0x80;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the last block shall be exclusive-OR'ed with K1 before processing
|
||||||
|
for (int j = 0; j < subKey.Length; j++)
|
||||||
|
data[data.Length - 16 + j] ^= subKey[j];
|
||||||
|
|
||||||
|
// The result of the previous process will be the input of the last encryption.
|
||||||
|
byte[] encResult = aes.EncryptCbc(data, new byte[16], PaddingMode.None);
|
||||||
|
|
||||||
|
byte[] HashValue = new byte[16];
|
||||||
|
Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length);
|
||||||
|
|
||||||
|
return HashValue;
|
||||||
|
|
||||||
|
void nextSubKey()
|
||||||
|
{
|
||||||
|
const byte const_Rb = 0x87;
|
||||||
|
if (Rol(subKey) != 0)
|
||||||
|
subKey[15] ^= const_Rb;
|
||||||
|
|
||||||
|
static int Rol(byte[] b)
|
||||||
|
{
|
||||||
|
int carry = 0;
|
||||||
|
|
||||||
|
for (int i = b.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
ushort u = (ushort)(b[i] << 1);
|
||||||
|
b[i] = (byte)((u & 0xff) + carry);
|
||||||
|
carry = (u & 0xff00) >> 8;
|
||||||
|
}
|
||||||
|
return carry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CreateContext(string label, int keySize, byte[] licRequestBts)
|
||||||
|
{
|
||||||
|
var contextSize = label.Length + 1 + licRequestBts.Length + sizeof(int);
|
||||||
|
|
||||||
|
var context = new byte[contextSize];
|
||||||
|
var numChars = Encoding.ASCII.GetBytes(label.AsSpan(), context);
|
||||||
|
Array.Copy(licRequestBts, 0, context, numChars + 1, licRequestBts.Length);
|
||||||
|
|
||||||
|
var numBts = BitConverter.GetBytes(keySize);
|
||||||
|
if (BitConverter.IsLittleEndian)
|
||||||
|
Array.Reverse(numBts);
|
||||||
|
Array.Copy(numBts, 0, context, context.Length - sizeof(int), sizeof(int));
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint RandomUint()
|
||||||
|
{
|
||||||
|
var bts = new byte[4];
|
||||||
|
new Random().NextBytes(bts);
|
||||||
|
return BitConverter.ToUInt32(bts, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
internal enum DeviceTypes : byte
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Chrome = 1,
|
||||||
|
Android = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Device
|
||||||
|
{
|
||||||
|
public DeviceTypes Type { get; }
|
||||||
|
public int FileVersion { get; }
|
||||||
|
public int SecurityLevel { get; }
|
||||||
|
public int Flags { get; }
|
||||||
|
|
||||||
|
public RSA CdmKey { get; }
|
||||||
|
internal ClientIdentification ClientId { get; }
|
||||||
|
|
||||||
|
public Device(Span<byte> fileData)
|
||||||
|
{
|
||||||
|
if (fileData.Length < 7 || fileData[0] != 'W' || fileData[1] != 'V' || fileData[2] != 'D')
|
||||||
|
throw new InvalidDataException();
|
||||||
|
|
||||||
|
FileVersion = fileData[3];
|
||||||
|
Type = (DeviceTypes)fileData[4];
|
||||||
|
SecurityLevel = fileData[5];
|
||||||
|
Flags = fileData[6];
|
||||||
|
|
||||||
|
if (FileVersion != 2)
|
||||||
|
throw new InvalidDataException($"Unknown CDM File Version: '{FileVersion}'");
|
||||||
|
if (Type != DeviceTypes.Android)
|
||||||
|
throw new InvalidDataException($"Unknown CDM Type: '{Type}'");
|
||||||
|
if (SecurityLevel != 3)
|
||||||
|
throw new InvalidDataException($"Unknown CDM Security Level: '{SecurityLevel}'");
|
||||||
|
|
||||||
|
var privateKeyLength = (fileData[7] << 8) | fileData[8];
|
||||||
|
|
||||||
|
if (privateKeyLength <= 0 || fileData.Length < 9 + privateKeyLength + 2)
|
||||||
|
throw new InvalidDataException($"Invalid private key length: '{privateKeyLength}'");
|
||||||
|
|
||||||
|
var clientIdLength = (fileData[9 + privateKeyLength] << 8) | fileData[10 + privateKeyLength];
|
||||||
|
|
||||||
|
if (clientIdLength <= 0 || fileData.Length < 11 + privateKeyLength + clientIdLength)
|
||||||
|
throw new InvalidDataException($"Invalid client id length: '{clientIdLength}'");
|
||||||
|
|
||||||
|
ClientId = ClientIdentification.Parser.ParseFrom(fileData.Slice(11 + privateKeyLength));
|
||||||
|
CdmKey = RSA.Create();
|
||||||
|
CdmKey.ImportRSAPrivateKey(fileData.Slice(9, privateKeyLength), out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] SignMessage(byte[] message)
|
||||||
|
{
|
||||||
|
using var sha1 = SHA1.Create();
|
||||||
|
var digestion = sha1.ComputeHash(message);
|
||||||
|
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyMessage(byte[] message, byte[] signature)
|
||||||
|
{
|
||||||
|
using var sha1 = SHA1.Create();
|
||||||
|
var digestion = sha1.ComputeHash(message);
|
||||||
|
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] DecryptSessionKey(byte[] sessionKey)
|
||||||
|
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
internal static class Extensions
|
||||||
|
{
|
||||||
|
public static T[] Append<T>(this T[] message, T[] appendData)
|
||||||
|
{
|
||||||
|
var origLength = message.Length;
|
||||||
|
Array.Resize(ref message, origLength + appendData.Length);
|
||||||
|
Array.Copy(appendData, 0, message, origLength, appendData.Length);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Mpeg4Lib.Boxes;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.IO;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using System.Xml.XPath;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
public class MpegDash
|
||||||
|
{
|
||||||
|
private const string MpegDashNamespace = "urn:mpeg:dash:schema:mpd:2011";
|
||||||
|
private const string CencNamespace = "urn:mpeg:cenc:2013";
|
||||||
|
private const string UuidPreamble = "urn:uuid:";
|
||||||
|
private XElement DashMpd { get; }
|
||||||
|
private static XmlNamespaceManager NamespaceManager { get; } = new(new NameTable());
|
||||||
|
static MpegDash()
|
||||||
|
{
|
||||||
|
NamespaceManager.AddNamespace("dash", MpegDashNamespace);
|
||||||
|
NamespaceManager.AddNamespace("cenc", CencNamespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MpegDash(Stream contents)
|
||||||
|
{
|
||||||
|
DashMpd = XElement.Load(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetUri(Uri baseUri, [NotNullWhen(true)] out Uri? fileUri)
|
||||||
|
{
|
||||||
|
foreach (var baseUrl in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:Representation/dash:BaseURL", NamespaceManager))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fileUri = new Uri(baseUri, baseUrl.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
fileUri = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileUri = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetPssh(Guid protectionSystemId, [NotNullWhen(true)] out PsshBox? pssh)
|
||||||
|
{
|
||||||
|
foreach (var psshEle in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:ContentProtection/cenc:pssh", NamespaceManager))
|
||||||
|
{
|
||||||
|
if (psshEle?.Value?.Trim() is string psshStr
|
||||||
|
&& psshEle.Parent?.Attribute(XName.Get("schemeIdUri")) is XAttribute scheme
|
||||||
|
&& scheme.Value is string uuid
|
||||||
|
&& uuid.Equals(UuidPreamble + protectionSystemId.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Span<byte> buffer = new byte[psshStr.Length * 3 / 4];
|
||||||
|
if (Convert.TryFromBase64String(psshStr, buffer, out var written))
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(buffer.Slice(0, written).ToArray());
|
||||||
|
pssh = BoxFactory.CreateBox(ms, null) as PsshBox;
|
||||||
|
return pssh is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pssh = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace DataLayer;
|
||||||
|
|
||||||
|
public enum Codec : byte
|
||||||
|
{
|
||||||
|
Unknown,
|
||||||
|
Mp3,
|
||||||
|
AAC_LC,
|
||||||
|
xHE_AAC,
|
||||||
|
EC_3,
|
||||||
|
AC_4
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AudioFormat
|
||||||
|
{
|
||||||
|
public static AudioFormat Default => new(Codec.Unknown, 0, 0, 0);
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsDefault => Codec is Codec.Unknown && BitRate == 0 && SampleRate == 0 && ChannelCount == 0;
|
||||||
|
[JsonIgnore]
|
||||||
|
public Codec Codec { get; set; }
|
||||||
|
public int SampleRate { get; set; }
|
||||||
|
public int ChannelCount { get; set; }
|
||||||
|
public int BitRate { get; set; }
|
||||||
|
|
||||||
|
public AudioFormat(Codec codec, int bitRate, int sampleRate, int channelCount)
|
||||||
|
{
|
||||||
|
Codec = codec;
|
||||||
|
BitRate = bitRate;
|
||||||
|
SampleRate = sampleRate;
|
||||||
|
ChannelCount = channelCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CodecString => Codec switch
|
||||||
|
{
|
||||||
|
Codec.Mp3 => "mp3",
|
||||||
|
Codec.AAC_LC => "AAC-LC",
|
||||||
|
Codec.xHE_AAC => "xHE-AAC",
|
||||||
|
Codec.EC_3 => "EC-3",
|
||||||
|
Codec.AC_4 => "AC-4",
|
||||||
|
Codec.Unknown or _ => "[Unknown]",
|
||||||
|
};
|
||||||
|
|
||||||
|
//Property | Start | Num | Max | Current Max |
|
||||||
|
// | Bit | Bits | Value | Value Used |
|
||||||
|
//-----------------------------------------------------
|
||||||
|
//Codec | 35 | 4 | 15 | 5 |
|
||||||
|
//BitRate | 23 | 12 | 4_095 | 768 |
|
||||||
|
//SampleRate | 5 | 18 | 262_143 | 48_000 |
|
||||||
|
//ChannelCount | 0 | 5 | 31 | 6 |
|
||||||
|
public long Serialize() =>
|
||||||
|
((long)Codec << 35) |
|
||||||
|
((long)BitRate << 23) |
|
||||||
|
((long)SampleRate << 5) |
|
||||||
|
(long)ChannelCount;
|
||||||
|
|
||||||
|
public static AudioFormat Deserialize(long value)
|
||||||
|
{
|
||||||
|
var codec = (Codec)((value >> 35) & 15);
|
||||||
|
var bitRate = (int)((value >> 23) & 4_095);
|
||||||
|
var sampleRate = (int)((value >> 5) & 262_143);
|
||||||
|
var channelCount = (int)(value & 31);
|
||||||
|
return new AudioFormat(codec, bitRate, sampleRate, channelCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString()
|
||||||
|
=> IsDefault ? "[Unknown Audio Format]"
|
||||||
|
: $"{CodecString} ({ChannelCount}ch | {SampleRate:N0}Hz | {BitRate}kbps)";
|
||||||
|
}
|
||||||
@@ -13,13 +13,11 @@ namespace DataLayer.Configurations
|
|||||||
|
|
||||||
entity.OwnsOne(b => b.Rating);
|
entity.OwnsOne(b => b.Rating);
|
||||||
|
|
||||||
entity.Property(nameof(Book._audioFormat));
|
|
||||||
//
|
//
|
||||||
// CRUCIAL: ignore unmapped collections, even get-only
|
// CRUCIAL: ignore unmapped collections, even get-only
|
||||||
//
|
//
|
||||||
entity.Ignore(nameof(Book.Authors));
|
entity.Ignore(nameof(Book.Authors));
|
||||||
entity.Ignore(nameof(Book.Narrators));
|
entity.Ignore(nameof(Book.Narrators));
|
||||||
entity.Ignore(nameof(Book.AudioFormat));
|
|
||||||
entity.Ignore(nameof(Book.TitleWithSubtitle));
|
entity.Ignore(nameof(Book.TitleWithSubtitle));
|
||||||
entity.Ignore(b => b.Categories);
|
entity.Ignore(b => b.Categories);
|
||||||
|
|
||||||
@@ -51,6 +49,11 @@ namespace DataLayer.Configurations
|
|||||||
b_udi
|
b_udi
|
||||||
.Property(udi => udi.LastDownloadedVersion)
|
.Property(udi => udi.LastDownloadedVersion)
|
||||||
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
||||||
|
b_udi
|
||||||
|
.Property(udi => udi.LastDownloadedFormat)
|
||||||
|
.HasConversion(af => af.Serialize(), str => AudioFormat.Deserialize(str));
|
||||||
|
|
||||||
|
b_udi.Property(udi => udi.LastDownloadedFileVersion);
|
||||||
|
|
||||||
// owns it 1:1, store in same table
|
// owns it 1:1, store in same table
|
||||||
b_udi.OwnsOne(udi => udi.Rating);
|
b_udi.OwnsOne(udi => udi.Rating);
|
||||||
|
|||||||
@@ -12,12 +12,12 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.3">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,16 +43,13 @@ namespace DataLayer
|
|||||||
public ContentType ContentType { get; private set; }
|
public ContentType ContentType { get; private set; }
|
||||||
public string Locale { get; private set; }
|
public string Locale { get; private set; }
|
||||||
|
|
||||||
internal AudioFormatEnum _audioFormat;
|
|
||||||
|
|
||||||
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); }
|
|
||||||
|
|
||||||
// mutable
|
// mutable
|
||||||
public string PictureId { get; set; }
|
public string PictureId { get; set; }
|
||||||
public string PictureLarge { get; set; }
|
public string PictureLarge { get; set; }
|
||||||
|
|
||||||
// book details
|
// book details
|
||||||
public bool IsAbridged { get; private set; }
|
public bool IsAbridged { get; private set; }
|
||||||
|
public bool IsSpatial { get; private set; }
|
||||||
public DateTime? DatePublished { get; private set; }
|
public DateTime? DatePublished { get; private set; }
|
||||||
public string Language { get; private set; }
|
public string Language { get; private set; }
|
||||||
|
|
||||||
@@ -240,10 +237,11 @@ namespace DataLayer
|
|||||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||||
|
|
||||||
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language)
|
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string language)
|
||||||
{
|
{
|
||||||
// don't overwrite with default values
|
// don't overwrite with default values
|
||||||
IsAbridged |= isAbridged;
|
IsAbridged |= isAbridged;
|
||||||
|
IsSpatial |= isSpatial ?? false;
|
||||||
DatePublished = datePublished ?? DatePublished;
|
DatePublished = datePublished ?? DatePublished;
|
||||||
Language = language?.FirstCharToUpper() ?? Language;
|
Language = language?.FirstCharToUpper() ?? Language;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,5 +43,7 @@ namespace DataLayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
|
public void SetAudibleContributorId(string audibleContributorId)
|
||||||
|
=> AudibleContributorId = audibleContributorId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,24 +24,52 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
internal int BookId { get; private set; }
|
internal int BookId { get; private set; }
|
||||||
public Book Book { get; private set; }
|
public Book Book { get; private set; }
|
||||||
public DateTime? LastDownloaded { get; private set; }
|
/// <summary>
|
||||||
public Version LastDownloadedVersion { get; private set; }
|
/// Date the audio file was last downloaded.
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? LastDownloaded { get; private set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Version of Libation used the last time the audio file was downloaded.
|
||||||
|
/// </summary>
|
||||||
|
public Version LastDownloadedVersion { get; private set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Audio format of the last downloaded audio file.
|
||||||
|
/// </summary>
|
||||||
|
public AudioFormat LastDownloadedFormat { get; private set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Version of the audio file that was last downloaded.
|
||||||
|
/// </summary>
|
||||||
|
public string LastDownloadedFileVersion { get; private set; }
|
||||||
|
|
||||||
public void SetLastDownloaded(Version version)
|
public void SetLastDownloaded(Version libationVersion, AudioFormat audioFormat, string audioVersion)
|
||||||
{
|
{
|
||||||
if (LastDownloadedVersion != version)
|
if (LastDownloadedVersion != libationVersion)
|
||||||
{
|
{
|
||||||
LastDownloadedVersion = version;
|
LastDownloadedVersion = libationVersion;
|
||||||
OnItemChanged(nameof(LastDownloadedVersion));
|
OnItemChanged(nameof(LastDownloadedVersion));
|
||||||
}
|
}
|
||||||
|
if (LastDownloadedFormat != audioFormat)
|
||||||
|
{
|
||||||
|
LastDownloadedFormat = audioFormat;
|
||||||
|
OnItemChanged(nameof(LastDownloadedFormat));
|
||||||
|
}
|
||||||
|
if (LastDownloadedFileVersion != audioVersion)
|
||||||
|
{
|
||||||
|
LastDownloadedFileVersion = audioVersion;
|
||||||
|
OnItemChanged(nameof(LastDownloadedFileVersion));
|
||||||
|
}
|
||||||
|
|
||||||
if (version is null)
|
if (libationVersion is null)
|
||||||
|
{
|
||||||
LastDownloaded = null;
|
LastDownloaded = null;
|
||||||
|
LastDownloadedFormat = null;
|
||||||
|
LastDownloadedFileVersion = null;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LastDownloaded = DateTime.Now;
|
LastDownloaded = DateTime.Now;
|
||||||
OnItemChanged(nameof(LastDownloaded));
|
OnItemChanged(nameof(LastDownloaded));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserDefinedItem() { }
|
private UserDefinedItem() { }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Dinah.EntityFrameworkCore;
|
using Dinah.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
@@ -7,6 +8,7 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
|
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
|
||||||
=> optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
|
=> optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
|
||||||
|
.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20250725074123_AddAudioFormatData")]
|
||||||
|
partial class AddAudioFormatData
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("_categoriesCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryCategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSpatial")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("LengthInMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Locale")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureLarge")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Subtitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "CategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("BookCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryLadders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("ContributorId");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
b.ToTable("Contributors");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ContributorId = -1,
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("AbsentFromLastScan")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoriesCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsFinished")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedFileVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<long?>("LastDownloadedFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<int?>("PdfStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Tags")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("UserDefinedItem", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<int>("UserDefinedItemBookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b2.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.HasKey("UserDefinedItemBookId");
|
||||||
|
|
||||||
|
b2.ToTable("UserDefinedItem");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("UserDefinedItemBookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
|
||||||
|
b1.Navigation("Rating");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("CategoriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("CategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("CategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("ContributorsLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("ContributorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("SeriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Series", "Series")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("CategoriesLink");
|
||||||
|
|
||||||
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAudioFormatData : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "_audioFormat",
|
||||||
|
table: "Books",
|
||||||
|
newName: "IsSpatial");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LastDownloadedFileVersion",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "LastDownloadedFormat",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastDownloadedFileVersion",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastDownloadedFormat",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
|
||||||
|
migrationBuilder.RenameColumn(
|
||||||
|
name: "IsSpatial",
|
||||||
|
table: "Books",
|
||||||
|
newName: "_audioFormat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
|
||||||
|
|
||||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
{
|
{
|
||||||
@@ -53,6 +53,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<bool>("IsAbridged")
|
b.Property<bool>("IsAbridged")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSpatial")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -74,9 +77,6 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<long>("_audioFormat")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.HasKey("BookId");
|
b.HasKey("BookId");
|
||||||
|
|
||||||
b.HasIndex("AudibleProductId");
|
b.HasIndex("AudibleProductId");
|
||||||
@@ -318,6 +318,12 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<DateTime?>("LastDownloaded")
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedFileVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<long?>("LastDownloadedFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b1.Property<string>("LastDownloadedVersion")
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -103,13 +103,11 @@ namespace DataLayer
|
|||||||
) == true
|
) == true
|
||||||
).ToList();
|
).ToList();
|
||||||
|
|
||||||
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
public static bool NeedsPdfDownload(this LibraryBook libraryBook)
|
||||||
=> bookList
|
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
|
||||||
.Where(
|
public static bool NeedsBookDownload(this LibraryBook libraryBook)
|
||||||
lb =>
|
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload;
|
||||||
!lb.AbsentFromLastScan &&
|
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
||||||
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
=> bookList.Where(lb => lb.NeedsPdfDownload() || lb.NeedsBookDownload());
|
||||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,8 +137,6 @@ namespace DtoImporterService
|
|||||||
book.ReplacePublisher(publisher);
|
book.ReplacePublisher(publisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
|
||||||
|
|
||||||
if (item.PdfUrl is not null)
|
if (item.PdfUrl is not null)
|
||||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||||
|
|
||||||
@@ -154,9 +152,6 @@ namespace DtoImporterService
|
|||||||
// Update the book titles, since formatting can change
|
// Update the book titles, since formatting can change
|
||||||
book.UpdateTitle(item.Title, item.Subtitle);
|
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
|
// set/update book-specific info which may have changed
|
||||||
if (item.PictureId is not null)
|
if (item.PictureId is not null)
|
||||||
book.PictureId = item.PictureId;
|
book.PictureId = item.PictureId;
|
||||||
@@ -169,8 +164,9 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
// 2023-02-01
|
// 2023-02-01
|
||||||
// updateBook must update language on books which were imported before the migration which added language.
|
// updateBook must update language on books which were imported before the migration which added language.
|
||||||
// Can eventually delete this
|
// 2025-07-30
|
||||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
// updateBook must update isSpatial on books which were imported before the migration which added isSpatial.
|
||||||
|
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language);
|
||||||
|
|
||||||
book.UpdateProductRating(
|
book.UpdateProductRating(
|
||||||
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||||
|
|||||||
@@ -61,19 +61,19 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
private int upsertPeople(List<Person> people)
|
private int upsertPeople(List<Person> people)
|
||||||
{
|
{
|
||||||
var hash = people
|
var qtyNew = 0;
|
||||||
// new people only
|
foreach (var person in people)
|
||||||
.Where(p => !Cache.ContainsKey(p.Name))
|
|
||||||
// remove duplicates by Name. first in wins
|
|
||||||
.ToDictionarySafe(p => p.Name);
|
|
||||||
|
|
||||||
foreach (var kvp in hash)
|
|
||||||
{
|
{
|
||||||
var person = kvp.Value;
|
if (!Cache.TryGetValue(person.Name, out var contributor))
|
||||||
addContributor(person.Name, person.Asin);
|
{
|
||||||
|
contributor = createContributor(person.Name, person.Asin);
|
||||||
|
qtyNew++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContributor(person, contributor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash.Count;
|
return qtyNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only use after loading contributors => local
|
// only use after loading contributors => local
|
||||||
@@ -86,16 +86,22 @@ namespace DtoImporterService
|
|||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
foreach (var pub in hash)
|
foreach (var pub in hash)
|
||||||
addContributor(pub);
|
createContributor(pub);
|
||||||
|
|
||||||
return hash.Count;
|
return hash.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Contributor addContributor(string name, string id = null)
|
private void updateContributor(Person person, Contributor contributor)
|
||||||
|
{
|
||||||
|
if (person.Asin != contributor.AudibleContributorId)
|
||||||
|
contributor.SetAudibleContributorId(person.Asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Contributor createContributor(string name, string id = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var newContrib = new Contributor(name);
|
var newContrib = new Contributor(name, id);
|
||||||
|
|
||||||
var entityEntry = DbContext.Contributors.Add(newContrib);
|
var entityEntry = DbContext.Contributors.Add(newContrib);
|
||||||
var entity = entityEntry.Entity;
|
var entity = entityEntry.Entity;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ namespace DtoImporterService
|
|||||||
existing.SetAccount(item.AccountId);
|
existing.SetAccount(item.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.AbsentFromLastScan = isPlusTitleUnavailable(item);
|
existing.AbsentFromLastScan = isUnavailable(item);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -71,7 +71,7 @@ namespace DtoImporterService
|
|||||||
item.DtoItem.DateAdded,
|
item.DtoItem.DateAdded,
|
||||||
item.AccountId)
|
item.AccountId)
|
||||||
{
|
{
|
||||||
AbsentFromLastScan = isPlusTitleUnavailable(item)
|
AbsentFromLastScan = isUnavailable(item)
|
||||||
};
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -113,7 +113,13 @@ namespace DtoImporterService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
|
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
|
||||||
=> isPlusTitleUnavailable(item1) && !isPlusTitleUnavailable(item2) ? item2 : item1;
|
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
|
||||||
|
|
||||||
|
private static bool isUnavailable(ImportItem item)
|
||||||
|
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
|
||||||
|
|
||||||
|
private static bool isFutureRelease(ImportItem item)
|
||||||
|
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
private static bool isPlusTitleUnavailable(ImportItem item)
|
private static bool isPlusTitleUnavailable(ImportItem item)
|
||||||
=> item.DtoItem.ContentType is null
|
=> item.DtoItem.ContentType is null
|
||||||
|
|||||||
@@ -15,53 +15,28 @@ namespace FileLiberator
|
|||||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||||
public abstract Task CancelAsync();
|
public abstract Task CancelAsync();
|
||||||
|
|
||||||
protected LameConfig GetLameOptions(Configuration config)
|
|
||||||
{
|
|
||||||
LameConfig lameConfig = new()
|
|
||||||
{
|
|
||||||
Mode = MPEGMode.Mono,
|
|
||||||
Quality = config.LameEncoderQuality,
|
|
||||||
OutputSampleRate = (int)config.MaxSampleRate
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.LameTargetBitrate)
|
|
||||||
{
|
|
||||||
if (config.LameConstantBitrate)
|
|
||||||
lameConfig.BitRate = config.LameBitrate;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lameConfig.ABRRateKbps = config.LameBitrate;
|
|
||||||
lameConfig.VBR = VBRMode.ABR;
|
|
||||||
lameConfig.WriteVBRTag = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lameConfig.VBR = VBRMode.Default;
|
|
||||||
lameConfig.VBRQuality = config.LameVBRQuality;
|
|
||||||
lameConfig.WriteVBRTag = true;
|
|
||||||
}
|
|
||||||
return lameConfig;
|
|
||||||
}
|
|
||||||
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
||||||
protected void OnTitleDiscovered(object _, string title)
|
protected void OnTitleDiscovered(object _, string title)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = 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(string authors) => OnAuthorsDiscovered(null, authors);
|
||||||
protected void OnAuthorsDiscovered(object _, string authors)
|
protected void OnAuthorsDiscovered(object _, string authors)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = 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(string narrators) => OnNarratorsDiscovered(null, narrators);
|
||||||
protected void OnNarratorsDiscovered(object _, string narrators)
|
protected void OnNarratorsDiscovered(object _, string narrators)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = 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()
|
protected byte[] OnRequestCoverArt()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using AaxDecrypter;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
@@ -33,24 +35,17 @@ namespace FileLiberator
|
|||||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DownloadDecryptBook:
|
|
||||||
/// 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);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PDF: audio file does not exist
|
/// PDF: audio file does not exist
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false)
|
||||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension, returnFirstExisting: returnFirstExisting);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PDF: audio file already exists
|
/// PDF: audio file already exists
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties partProperties = null, bool returnFirstExisting = false)
|
||||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
=> partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting)
|
||||||
|
: Templates.ChapterFile.GetFilename(libraryBook.ToDto(), partProperties, dirFullPath, extension, returnFirstExisting: returnFirstExisting);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
using AAXClean;
|
||||||
|
using DataLayer;
|
||||||
|
using FileManager;
|
||||||
|
using Mpeg4Lib.Boxes;
|
||||||
|
using Mpeg4Lib.Util;
|
||||||
|
using NAudio.Lame.ID3;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AaxDecrypter;
|
||||||
|
|
||||||
|
/// <summary> Read audio codec, bitrate, sample rate, and channel count from MP4 and MP3 audio files. </summary>
|
||||||
|
internal static class AudioFormatDecoder
|
||||||
|
{
|
||||||
|
public static AudioFormat FromMpeg4(string filename)
|
||||||
|
{
|
||||||
|
using var fileStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
return FromMpeg4(new Mp4File(fileStream));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AudioFormat FromMpeg4(Mp4File mp4File)
|
||||||
|
{
|
||||||
|
Codec codec;
|
||||||
|
if (mp4File.AudioSampleEntry.Dac4 is not null)
|
||||||
|
{
|
||||||
|
codec = Codec.AC_4;
|
||||||
|
}
|
||||||
|
else if (mp4File.AudioSampleEntry.Dec3 is not null)
|
||||||
|
{
|
||||||
|
codec = Codec.EC_3;
|
||||||
|
}
|
||||||
|
else if (mp4File.AudioSampleEntry.Esds is EsdsBox esds)
|
||||||
|
{
|
||||||
|
var objectType = esds.ES_Descriptor.DecoderConfig.AudioSpecificConfig.AudioObjectType;
|
||||||
|
codec
|
||||||
|
= objectType == 2 ? Codec.AAC_LC
|
||||||
|
: objectType == 42 ? Codec.xHE_AAC
|
||||||
|
: Codec.Unknown;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return AudioFormat.Default;
|
||||||
|
|
||||||
|
var bitrate = (int)Math.Round(mp4File.AverageBitrate / 1024d);
|
||||||
|
|
||||||
|
return new AudioFormat(codec, bitrate, mp4File.TimeScale, mp4File.AudioChannels);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AudioFormat FromMpeg3(LongPath mp3Filename)
|
||||||
|
{
|
||||||
|
using var mp3File = File.Open(mp3Filename, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
if (Id3Header.Create(mp3File) is Id3Header id3header)
|
||||||
|
id3header.SeekForwardToPosition(mp3File, mp3File.Position + id3header.Size);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Debug("File appears not to have ID3 tags.");
|
||||||
|
mp3File.Position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SeekToFirstKeyFrame(mp3File))
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Warning("Invalid frame sync read from file at end of ID3 tag.");
|
||||||
|
return AudioFormat.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
var mpegSize = mp3File.Length - mp3File.Position;
|
||||||
|
if (mpegSize < 64)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {@File}", mp3Filename);
|
||||||
|
return AudioFormat.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region read first mp3 frame header
|
||||||
|
//https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#VBRIHeader
|
||||||
|
var reader = new BitReader(mp3File.ReadBlock(4));
|
||||||
|
reader.Position = 11; //Skip frame header magic bits
|
||||||
|
var versionId = (Version)reader.Read(2);
|
||||||
|
var layerDesc = (Layer)reader.Read(2);
|
||||||
|
|
||||||
|
if (layerDesc is not Layer.Layer_3)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString());
|
||||||
|
return AudioFormat.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionId is Version.Reserved)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Warning("Mp3 data data cannot be read from a file with version = 'Reserved'");
|
||||||
|
return AudioFormat.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
var protectionBit = reader.ReadBool();
|
||||||
|
var bitrateIndex = reader.Read(4);
|
||||||
|
var freqIndex = reader.Read(2);
|
||||||
|
_ = reader.ReadBool(); //Padding bit
|
||||||
|
_ = reader.ReadBool(); //Private bit
|
||||||
|
var channelMode = reader.Read(2);
|
||||||
|
_ = reader.Read(2); //Mode extension
|
||||||
|
_ = reader.ReadBool(); //Copyright
|
||||||
|
_ = reader.ReadBool(); //Original
|
||||||
|
_ = reader.Read(2); //Emphasis
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
//Read the sample rate,and channels from the first frame's header.
|
||||||
|
var sampleRate = Mp3SampleRateIndex[versionId][freqIndex];
|
||||||
|
var channelCount = channelMode == 3 ? 1 : 2;
|
||||||
|
|
||||||
|
//Try to read variable bitrate info from the first frame.
|
||||||
|
//Revert to fixed bitrate from frame header if not found.
|
||||||
|
var bitrate
|
||||||
|
= TryReadXingBitrate(out var br) ? br
|
||||||
|
: TryReadVbriBitrate(out br) ? br
|
||||||
|
: Mp3BitrateIndex[versionId][bitrateIndex];
|
||||||
|
|
||||||
|
return new AudioFormat(Codec.Mp3, bitrate, sampleRate, channelCount);
|
||||||
|
|
||||||
|
#region Variable bitrate header readers
|
||||||
|
bool TryReadXingBitrate(out int bitrate)
|
||||||
|
{
|
||||||
|
const int XingHeader = 0x58696e67;
|
||||||
|
const int InfoHeader = 0x496e666f;
|
||||||
|
|
||||||
|
var sideInfoSize = GetSideInfo(channelCount == 2, versionId) + (protectionBit ? 0 : 2);
|
||||||
|
mp3File.Position += sideInfoSize;
|
||||||
|
|
||||||
|
if (mp3File.ReadUInt32BE() is XingHeader or InfoHeader)
|
||||||
|
{
|
||||||
|
//Xing or Info header (common)
|
||||||
|
var flags = mp3File.ReadUInt32BE();
|
||||||
|
bool hasFramesField = (flags & 1) == 1;
|
||||||
|
bool hasBytesField = (flags & 2) == 2;
|
||||||
|
|
||||||
|
if (hasFramesField)
|
||||||
|
{
|
||||||
|
var numFrames = mp3File.ReadUInt32BE();
|
||||||
|
if (hasBytesField)
|
||||||
|
{
|
||||||
|
mpegSize = mp3File.ReadUInt32BE();
|
||||||
|
}
|
||||||
|
|
||||||
|
var samplesPerFrame = GetSamplesPerFrame(sampleRate);
|
||||||
|
var duration = samplesPerFrame * numFrames / sampleRate;
|
||||||
|
bitrate = (short)(mpegSize / duration / 1024 * 8);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
mp3File.Position -= sideInfoSize + 4;
|
||||||
|
|
||||||
|
bitrate = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TryReadVbriBitrate(out int bitrate)
|
||||||
|
{
|
||||||
|
const int VBRIHeader = 0x56425249;
|
||||||
|
|
||||||
|
mp3File.Position += 32;
|
||||||
|
|
||||||
|
if (mp3File.ReadUInt32BE() is VBRIHeader)
|
||||||
|
{
|
||||||
|
//VBRI header (rare)
|
||||||
|
_ = mp3File.ReadBlock(6);
|
||||||
|
mpegSize = mp3File.ReadUInt32BE();
|
||||||
|
var numFrames = mp3File.ReadUInt32BE();
|
||||||
|
|
||||||
|
var samplesPerFrame = GetSamplesPerFrame(sampleRate);
|
||||||
|
var duration = samplesPerFrame * numFrames / sampleRate;
|
||||||
|
bitrate = (short)(mpegSize / duration / 1024 * 8);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
bitrate = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
|
||||||
|
#region MP3 frame decoding helpers
|
||||||
|
private static bool SeekToFirstKeyFrame(Stream file)
|
||||||
|
{
|
||||||
|
//Frame headers begin with first 11 bits set.
|
||||||
|
const int MaxSeekBytes = 4096;
|
||||||
|
var maxPosition = Math.Min(file.Length, file.Position + MaxSeekBytes) - 2;
|
||||||
|
|
||||||
|
while (file.Position < maxPosition)
|
||||||
|
{
|
||||||
|
if (file.ReadByte() == 0xff)
|
||||||
|
{
|
||||||
|
if ((file.ReadByte() & 0xe0) == 0xe0)
|
||||||
|
{
|
||||||
|
file.Position -= 2;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
file.Position--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Version
|
||||||
|
{
|
||||||
|
Version_2_5,
|
||||||
|
Reserved,
|
||||||
|
Version_2,
|
||||||
|
Version_1
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Layer
|
||||||
|
{
|
||||||
|
Reserved,
|
||||||
|
Layer_3,
|
||||||
|
Layer_2,
|
||||||
|
Layer_1
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double GetSamplesPerFrame(int sampleRate) => sampleRate >= 32000 ? 1152 : 576;
|
||||||
|
|
||||||
|
private static byte GetSideInfo(bool stereo, Version version) => (stereo, version) switch
|
||||||
|
{
|
||||||
|
(true, Version.Version_1) => 32,
|
||||||
|
(true, Version.Version_2 or Version.Version_2_5) => 17,
|
||||||
|
(false, Version.Version_1) => 17,
|
||||||
|
(false, Version.Version_2 or Version.Version_2_5) => 9,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<Version, ushort[]> Mp3SampleRateIndex = new()
|
||||||
|
{
|
||||||
|
{ Version.Version_2_5, [11025, 12000, 8000] },
|
||||||
|
{ Version.Version_2, [22050, 24000, 16000] },
|
||||||
|
{ Version.Version_1, [44100, 48000, 32000] },
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<Version, short[]> Mp3BitrateIndex = new()
|
||||||
|
{
|
||||||
|
{ Version.Version_2_5, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]},
|
||||||
|
{ Version.Version_2, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]},
|
||||||
|
{ Version.Version_1, [-1,32,40,48,56,64,80,96,112,128,160,192,224,256,320,-1]}
|
||||||
|
};
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AAXClean;
|
using AAXClean;
|
||||||
using AAXClean.Codecs;
|
using AAXClean.Codecs;
|
||||||
@@ -19,7 +20,13 @@ namespace FileLiberator
|
|||||||
private readonly AaxDecrypter.AverageSpeed averageSpeed = new();
|
private readonly AaxDecrypter.AverageSpeed averageSpeed = new();
|
||||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||||
|
|
||||||
public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask;
|
private CancellationTokenSource CancellationTokenSource { get; set; }
|
||||||
|
public override async Task CancelAsync()
|
||||||
|
{
|
||||||
|
await CancellationTokenSource.CancelAsync();
|
||||||
|
if (Mp4Operation is not null)
|
||||||
|
await Mp4Operation.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public static bool ValidateMp3(LibraryBook libraryBook)
|
public static bool ValidateMp3(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
@@ -32,17 +39,33 @@ namespace FileLiberator
|
|||||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
OnBegin(libraryBook);
|
OnBegin(libraryBook);
|
||||||
|
var cancellationToken = (CancellationTokenSource = new()).Token;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
|
var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId)
|
||||||
|
.Where(m4bPath => File.Exists(m4bPath))
|
||||||
|
.Select(m4bPath => new { m4bPath, proposedMp3Path = Mp3FileName(m4bPath), m4bSize = new FileInfo(m4bPath).Length })
|
||||||
|
.Where(p => !File.Exists(p.proposedMp3Path))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
foreach (var m4bPath in m4bPaths)
|
long totalInputSize = m4bPaths.Sum(p => p.m4bSize);
|
||||||
|
long sizeOfCompletedFiles = 0L;
|
||||||
|
foreach (var entry in m4bPaths)
|
||||||
{
|
{
|
||||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
if (File.Exists(entry.proposedMp3Path) || !File.Exists(entry.m4bPath))
|
||||||
|
{
|
||||||
|
sizeOfCompletedFiles += entry.m4bSize;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
using var m4bFileStream = File.Open(entry.m4bPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||||
|
var m4bBook = new Mp4File(m4bFileStream);
|
||||||
|
|
||||||
|
//AAXClean.Codecs only supports decoding AAC and E-AC-3 audio.
|
||||||
|
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||||
@@ -50,7 +73,7 @@ namespace FileLiberator
|
|||||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||||
|
|
||||||
var config = Configuration.Instance;
|
var config = Configuration.Instance;
|
||||||
var lameConfig = GetLameOptions(config);
|
var lameConfig = DownloadOptions.GetLameOptions(config);
|
||||||
var chapters = m4bBook.GetChaptersFromMetadata();
|
var chapters = m4bBook.GetChaptersFromMetadata();
|
||||||
//Finishing configuring lame encoder.
|
//Finishing configuring lame encoder.
|
||||||
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||||
@@ -65,74 +88,85 @@ namespace FileLiberator
|
|||||||
lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString();
|
lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
long currentFileNumBytesProcessed = 0;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
|
var tempPath = Path.GetTempFileName();
|
||||||
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
using (var mp3File = File.Open(tempPath, FileMode.OpenOrCreate, FileAccess.ReadWrite))
|
||||||
await Mp4Operation;
|
|
||||||
|
|
||||||
if (Mp4Operation.IsCanceled)
|
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(mp3File.Name);
|
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
|
||||||
return new StatusHandler { "Cancelled" };
|
Mp4Operation.ConversionProgressUpdate += m4bBook_ConversionProgressUpdate;
|
||||||
|
await Mp4Operation;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
if (cancellationToken.IsCancellationRequested)
|
||||||
var realMp3Path
|
FileUtility.SaferDelete(tempPath);
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var realMp3Path
|
||||||
= FileUtility.SaferMoveToValidPath(
|
= FileUtility.SaferMoveToValidPath(
|
||||||
mp3File.Name,
|
tempPath,
|
||||||
proposedMp3Path,
|
entry.proposedMp3Path,
|
||||||
Configuration.Instance.ReplacementCharacters,
|
Configuration.Instance.ReplacementCharacters,
|
||||||
extension: "mp3",
|
extension: "mp3",
|
||||||
Configuration.Instance.OverwriteExisting);
|
Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
SetFileTime(libraryBook, realMp3Path);
|
SetFileTime(libraryBook, realMp3Path);
|
||||||
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
|
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
|
||||||
|
OnFileCreated(libraryBook, realMp3Path);
|
||||||
OnFileCreated(libraryBook, realMp3Path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Error(ex, "AAXClean error");
|
|
||||||
return new StatusHandler { "Conversion failed" };
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
if (Mp4Operation is not null)
|
if (Mp4Operation is not null)
|
||||||
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
|
Mp4Operation.ConversionProgressUpdate -= m4bBook_ConversionProgressUpdate;
|
||||||
|
|
||||||
m4bBook.InputStream.Close();
|
sizeOfCompletedFiles += entry.m4bSize;
|
||||||
mp3File.Close();
|
}
|
||||||
|
void m4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||||
|
{
|
||||||
|
currentFileNumBytesProcessed = (long)(e.FractionCompleted * entry.m4bSize);
|
||||||
|
var bytesCompleted = sizeOfCompletedFiles + currentFileNumBytesProcessed;
|
||||||
|
ConversionProgressUpdate(totalInputSize, bytesCompleted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return new StatusHandler();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "AAXClean error");
|
||||||
|
return new StatusHandler { "Conversion failed" };
|
||||||
|
}
|
||||||
|
return new StatusHandler { "Cancelled" };
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
OnCompleted(libraryBook);
|
||||||
|
CancellationTokenSource.Dispose();
|
||||||
|
CancellationTokenSource = null;
|
||||||
}
|
}
|
||||||
return new StatusHandler();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
private void ConversionProgressUpdate(long totalInputSize, long bytesCompleted)
|
||||||
{
|
{
|
||||||
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
averageSpeed.AddPosition(bytesCompleted);
|
||||||
|
|
||||||
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
|
var remainingBytes = (totalInputSize - bytesCompleted);
|
||||||
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
|
var estTimeRemaining = remainingBytes / averageSpeed.Average;
|
||||||
|
|
||||||
if (double.IsNormal(estTimeRemaining))
|
if (double.IsNormal(estTimeRemaining))
|
||||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||||
|
|
||||||
double progressPercent = 100 * e.FractionCompleted;
|
double progressPercent = 100 * bytesCompleted / totalInputSize;
|
||||||
|
|
||||||
OnStreamingProgressChanged(
|
OnStreamingProgressChanged(
|
||||||
new DownloadProgress
|
new DownloadProgress
|
||||||
{
|
{
|
||||||
ProgressPercentage = progressPercent,
|
ProgressPercentage = progressPercent,
|
||||||
BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds,
|
BytesReceived = bytesCompleted,
|
||||||
TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds
|
TotalBytesToReceive = totalInputSize
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,468 +1,512 @@
|
|||||||
using System;
|
using AaxDecrypter;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using AaxDecrypter;
|
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
using AudibleApi.Common;
|
using AudibleApi.Common;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
|
using Dinah.Core.Net.Http;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadDecryptBook : AudioDecodable
|
public class DownloadDecryptBook : AudioDecodable
|
||||||
{
|
{
|
||||||
public override string Name => "Download & Decrypt";
|
public override string Name => "Download & Decrypt";
|
||||||
private AudiobookDownloadBase abDownloader;
|
private CancellationTokenSource? cancellationTokenSource;
|
||||||
|
private AudiobookDownloadBase? abDownloader;
|
||||||
|
|
||||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||||
|
public override async Task CancelAsync()
|
||||||
|
{
|
||||||
|
if (abDownloader is not null) await abDownloader.CancelAsync();
|
||||||
|
if (cancellationTokenSource is not null) await cancellationTokenSource.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
OnBegin(libraryBook);
|
||||||
|
cancellationTokenSource = new CancellationTokenSource();
|
||||||
|
var cancellationToken = cancellationTokenSource.Token;
|
||||||
|
|
||||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
try
|
||||||
{
|
{
|
||||||
var entries = new List<FilePathCache.CacheEntry>();
|
if (libraryBook.Book.Audio_Exists())
|
||||||
// these only work so minimally b/c CacheEntry is a record.
|
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||||
// in case of parallel decrypts, only capture the ones for this book id.
|
|
||||||
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
|
||||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
|
||||||
{
|
|
||||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
|
||||||
entries.Add(e);
|
|
||||||
}
|
|
||||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
|
||||||
{
|
|
||||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
|
||||||
entries.Remove(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
OnBegin(libraryBook);
|
DownloadValidation(libraryBook);
|
||||||
|
|
||||||
try
|
var api = await libraryBook.GetApiAsync();
|
||||||
{
|
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
|
||||||
if (libraryBook.Book.Audio_Exists())
|
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
|
||||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
|
||||||
|
|
||||||
bool success = false;
|
if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile)
|
||||||
try
|
{
|
||||||
{
|
// decrypt failed. Delete all output entries but leave the cache files.
|
||||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath));
|
||||||
FilePathCache.Removed += FilePathCache_Removed;
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
return new StatusHandler { "Decrypt failed" };
|
||||||
|
}
|
||||||
|
|
||||||
success = await downloadAudiobookAsync(libraryBook);
|
if (Configuration.Instance.RetainAaxFile)
|
||||||
}
|
{
|
||||||
finally
|
//Add the cached aaxc and key files to the entries list to be moved to the Books directory.
|
||||||
{
|
result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles));
|
||||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
}
|
||||||
FilePathCache.Removed -= FilePathCache_Removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// decrypt failed
|
//Set the last downloaded information on the book so that it can be used in the naming templates,
|
||||||
if (!success || getFirstAudioFile(entries) == default)
|
//but don't persist it until everything completes successfully (in the finally block)
|
||||||
{
|
var audioFormat = GetFileFormatInfo(downloadOptions, audioFile);
|
||||||
await Task.WhenAll(
|
var audioVersion = downloadOptions.ContentMetadata.ContentReference.Version;
|
||||||
entries
|
libraryBook.Book.UserDefinedItem.SetLastDownloaded(Configuration.LibationVersion, audioFormat, audioVersion);
|
||||||
.Where(f => f.FileType != FileType.AAXC)
|
|
||||||
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
|
||||||
|
|
||||||
return
|
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||||
abDownloader?.IsCanceled is true
|
|
||||||
? new StatusHandler { "Cancelled" }
|
|
||||||
: new StatusHandler { "Decrypt failed" };
|
|
||||||
}
|
|
||||||
|
|
||||||
var finalStorageDir = getDestinationDirectory(libraryBook);
|
//post-download tasks done in parallel.
|
||||||
|
var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken));
|
||||||
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
Task[] finalTasks =
|
||||||
Task[] finalTasks = new[]
|
[
|
||||||
{
|
moveFilesTask,
|
||||||
Task.Run(() => downloadCoverArt(libraryBook)),
|
Task.Run(() => DownloadCoverArt(finalStorageDir, downloadOptions, cancellationToken)),
|
||||||
moveFilesTask,
|
Task.Run(() => DownloadRecordsAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
|
||||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
Task.Run(() => DownloadMetadataAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
|
||||||
};
|
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
|
||||||
|
];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.WhenAll(finalTasks);
|
await Task.WhenAll(finalTasks);
|
||||||
}
|
}
|
||||||
catch
|
catch when (!moveFilesTask.IsFaulted)
|
||||||
{
|
{
|
||||||
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
|
//Swallow DownloadCoverArt, DownloadRecordsAsync, DownloadMetadataAsync, and SetCoverAsFolderIcon exceptions.
|
||||||
//Only fail if the downloaded audio files failed to move to Books directory
|
//Only fail if the downloaded audio files failed to move to Books directory
|
||||||
if (moveFilesTask.IsFaulted)
|
}
|
||||||
{
|
finally
|
||||||
throw;
|
{
|
||||||
}
|
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
||||||
}
|
{
|
||||||
finally
|
libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
|
||||||
{
|
|
||||||
if (moveFilesTask.IsCompletedSuccessfully)
|
|
||||||
{
|
|
||||||
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
|
|
||||||
|
|
||||||
SetDirectoryTime(libraryBook, finalStorageDir);
|
SetDirectoryTime(libraryBook, finalStorageDir);
|
||||||
|
foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath)))
|
||||||
|
{
|
||||||
|
//Delete cache files only after the download/decrypt operation completes successfully.
|
||||||
|
FileUtility.SaferDelete(cacheFile.FilePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new StatusHandler();
|
return new StatusHandler();
|
||||||
}
|
}
|
||||||
finally
|
catch when (cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
Serilog.Log.Logger.Information("Download/Decrypt was cancelled. {@Book}", libraryBook.LogFriendly());
|
||||||
}
|
return new StatusHandler { "Cancelled" };
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
OnCompleted(libraryBook);
|
||||||
|
cancellationTokenSource.Dispose();
|
||||||
|
cancellationTokenSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
private record AudiobookDecryptResult(bool Success, List<TempFile> ResultFiles, List<TempFile> CacheFiles);
|
||||||
{
|
|
||||||
var config = Configuration.Instance;
|
|
||||||
|
|
||||||
downloadValidation(libraryBook);
|
private async Task<AudiobookDecryptResult> DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var outpoutDir = AudibleFileStorage.DecryptInProgressDirectory;
|
||||||
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
|
var result = new AudiobookDecryptResult(false, [], []);
|
||||||
|
|
||||||
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
|
try
|
||||||
var api = await libraryBook.GetApiAsync();
|
{
|
||||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
|
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
||||||
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
abDownloader = new UnencryptedAudiobookDownloader(outpoutDir, cacheDir, dlOptions);
|
||||||
|
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
|
||||||
|
|
||||||
if (contentLic.DrmType != DrmType.Adrm)
|
|
||||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
AaxcDownloadConvertBase converter
|
|
||||||
= config.SplitFilesByChapter ?
|
|
||||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
|
||||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
|
||||||
|
|
||||||
if (config.AllowLibationFixup)
|
|
||||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
|
|
||||||
|
|
||||||
abDownloader = converter;
|
|
||||||
}
|
|
||||||
|
|
||||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
|
||||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
|
||||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
|
||||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
|
||||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
|
||||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
|
||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
|
||||||
var success = await abDownloader.RunAsync();
|
|
||||||
|
|
||||||
if (success && config.SaveMetadataToFile)
|
|
||||||
{
|
|
||||||
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
|
|
||||||
|
|
||||||
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
|
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
|
|
||||||
|
|
||||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
|
||||||
OnFileCreated(libraryBook, metadataFile);
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
|
||||||
{
|
|
||||||
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
|
||||||
|
|
||||||
var outputFormat
|
|
||||||
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
|
||||||
? OutputFormat.Mp3
|
|
||||||
: OutputFormat.M4b;
|
|
||||||
|
|
||||||
long chapterStartMs
|
|
||||||
= config.StripAudibleBrandAudio
|
|
||||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
//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)
|
|
||||||
{
|
|
||||||
AudibleKey = contentLic?.Voucher?.Key,
|
|
||||||
AudibleIV = contentLic?.Voucher?.Iv,
|
|
||||||
OutputFormat = outputFormat,
|
|
||||||
LameConfig = GetLameOptions(config),
|
|
||||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
|
||||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
|
||||||
var chapters
|
|
||||||
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
|
||||||
.OrderBy(c => c.StartOffsetMs)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (config.MergeOpeningAndEndCredits)
|
|
||||||
combineCredits(chapters);
|
|
||||||
|
|
||||||
for (int i = 0; i < chapters.Count; i++)
|
|
||||||
{
|
|
||||||
var chapter = chapters[i];
|
|
||||||
long chapLenMs = chapter.LengthMs;
|
|
||||||
|
|
||||||
if (i == 0)
|
|
||||||
chapLenMs -= chapterStartMs;
|
|
||||||
|
|
||||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
|
||||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
|
||||||
|
|
||||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
return dlOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
|
|
||||||
Flatten Audible's new hierarchical chapters, combining children into parents.
|
|
||||||
|
|
||||||
Audible may deliver chapters like this:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 00:12 Book 1
|
|
||||||
00:12 - 00:14 | Part 1
|
|
||||||
00:14 - 01:40 | | Chapter 1
|
|
||||||
01:40 - 03:20 | | Chapter 2
|
|
||||||
03:20 - 03:22 | Part 2
|
|
||||||
03:22 - 05:00 | | Chapter 3
|
|
||||||
05:00 - 06:40 | | Chapter 4
|
|
||||||
06:40 - 06:42 Book 2
|
|
||||||
06:42 - 06:44 | Part 3
|
|
||||||
06:44 - 08:20 | | Chapter 5
|
|
||||||
08:20 - 10:00 | | Chapter 6
|
|
||||||
10:00 - 10:02 | Part 4
|
|
||||||
10:02 - 11:40 | | Chapter 7
|
|
||||||
11:40 - 13:20 | | Chapter 8
|
|
||||||
13:20 - 13:30 End Credits
|
|
||||||
|
|
||||||
And flattenChapters will combine them into this:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
|
||||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
|
||||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
|
||||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
|
||||||
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
|
||||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
|
||||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
|
||||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
|
||||||
13:20 - 13:40 End Credits
|
|
||||||
|
|
||||||
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
|
||||||
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
|
||||||
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
|
||||||
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 00:25 Book 1
|
|
||||||
00:25 - 00:27 | Part 1
|
|
||||||
00:27 - 01:40 | | Chapter 1
|
|
||||||
01:40 - 03:20 | | Chapter 2
|
|
||||||
03:20 - 03:22 | Part 2
|
|
||||||
03:22 - 05:00 | | Chapter 3
|
|
||||||
05:00 - 06:40 | | Chapter 4
|
|
||||||
06:40 - 06:42 Book 2
|
|
||||||
06:42 - 07:02 | Part 3
|
|
||||||
07:02 - 08:20 | | Chapter 5
|
|
||||||
08:20 - 10:00 | | Chapter 6
|
|
||||||
10:00 - 10:02 | Part 4
|
|
||||||
10:02 - 11:40 | | Chapter 7
|
|
||||||
11:40 - 13:20 | | Chapter 8
|
|
||||||
13:20 - 13:30 End Credits
|
|
||||||
|
|
||||||
then flattenChapters will combine them into this:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 00:25 Book 1
|
|
||||||
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
|
||||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
|
||||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
|
||||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
|
||||||
06:40 - 07:02 Book 2: Part 3
|
|
||||||
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
|
||||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
|
||||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
|
||||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
|
||||||
13:20 - 13:40 End Credits
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string titleConcat = ": ")
|
|
||||||
{
|
|
||||||
List<Chapter> chaps = new();
|
|
||||||
|
|
||||||
foreach (var c in chapters)
|
|
||||||
{
|
|
||||||
if (c.Chapters is null)
|
|
||||||
chaps.Add(c);
|
|
||||||
else if (titleConcat is null)
|
|
||||||
{
|
|
||||||
chaps.Add(c);
|
|
||||||
chaps.AddRange(flattenChapters(c.Chapters));
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (c.LengthMs < 10000)
|
AaxcDownloadConvertBase converter
|
||||||
{
|
= dlOptions.Config.SplitFilesByChapter && dlOptions.ChapterInfo.Count > 1 ?
|
||||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) :
|
||||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions);
|
||||||
c.Chapters[0].LengthMs += c.LengthMs;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
chaps.Add(c);
|
|
||||||
|
|
||||||
var children = flattenChapters(c.Chapters);
|
if (dlOptions.Config.AllowLibationFixup)
|
||||||
|
converter.RetrievedMetadata += Converter_RetrievedMetadata;
|
||||||
|
|
||||||
foreach (var child in children)
|
abDownloader = converter;
|
||||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
}
|
||||||
|
|
||||||
chaps.AddRange(children);
|
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||||
}
|
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||||
}
|
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||||
return chaps;
|
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||||
}
|
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||||
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
|
abDownloader.TempFileCreated += AbDownloader_TempFileCreated;
|
||||||
|
|
||||||
public static void combineCredits(IList<Chapter> chapters)
|
// REAL WORK DONE HERE
|
||||||
{
|
bool success = await abDownloader.RunAsync();
|
||||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
return result with { Success = success };
|
||||||
{
|
}
|
||||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
catch (Exception ex)
|
||||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
{
|
||||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
chapters.RemoveAt(0);
|
Serilog.Log.Logger.Error(ex, "Error downloading audiobook {@Book}", dlOptions.LibraryBook.LogFriendly());
|
||||||
}
|
//don't throw any exceptions so the caller can delete any temp files.
|
||||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
return result;
|
||||||
{
|
}
|
||||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
finally
|
||||||
chapters.Remove(chapters[^1]);
|
{
|
||||||
}
|
OnStreamingProgressChanged(new() { ProgressPercentage = 100 });
|
||||||
}
|
|
||||||
|
|
||||||
private static void downloadValidation(LibraryBook libraryBook)
|
|
||||||
{
|
|
||||||
string errorString(string field)
|
|
||||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
|
||||||
|
|
||||||
string errorTitle()
|
|
||||||
{
|
|
||||||
var title
|
|
||||||
= (libraryBook.Book.TitleWithSubtitle.Length > 53)
|
|
||||||
? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..."
|
|
||||||
: libraryBook.Book.TitleWithSubtitle;
|
|
||||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
|
||||||
return errorBookTitle;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
|
||||||
throw new Exception(errorString("Account"));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
|
||||||
throw new Exception(errorString("Locale"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
|
||||||
{
|
|
||||||
if (Configuration.Instance.AllowLibationFixup)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
e = OnRequestCoverArt();
|
|
||||||
abDownloader.SetCoverArt(e);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e is not null)
|
|
||||||
OnCoverImageDiscovered(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <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)
|
|
||||||
{
|
|
||||||
// create final directory. move each file into it
|
|
||||||
var destinationDir = getDestinationDirectory(libraryBook);
|
|
||||||
|
|
||||||
for (var i = 0; i < entries.Count; i++)
|
|
||||||
{
|
|
||||||
var entry = entries[i];
|
|
||||||
|
|
||||||
var realDest
|
|
||||||
= FileUtility.SaferMoveToValidPath(
|
|
||||||
entry.Path,
|
|
||||||
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
|
|
||||||
Configuration.Instance.ReplacementCharacters,
|
|
||||||
overwrite: Configuration.Instance.OverwriteExisting);
|
|
||||||
|
|
||||||
SetFileTime(libraryBook, realDest);
|
|
||||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
|
||||||
if (cue != default)
|
|
||||||
{
|
|
||||||
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
|
||||||
SetFileTime(libraryBook, cue.Path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AudibleFileStorage.Audio.Refresh();
|
void AbDownloader_TempFileCreated(object? sender, TempFile e)
|
||||||
}
|
{
|
||||||
|
if (Path.GetDirectoryName(e.FilePath) == outpoutDir)
|
||||||
private static string getDestinationDirectory(LibraryBook libraryBook)
|
{
|
||||||
{
|
result.ResultFiles.Add(e);
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
}
|
||||||
if (!Directory.Exists(destinationDir))
|
else if (Path.GetDirectoryName(e.FilePath) == cacheDir)
|
||||||
Directory.CreateDirectory(destinationDir);
|
{
|
||||||
return destinationDir;
|
result.CacheFiles.Add(e);
|
||||||
|
// Notify that the aaxc file has been created so that
|
||||||
|
// the UI can know about partially-downloaded files
|
||||||
|
if (getFileType(e) is FileType.AAXC)
|
||||||
|
OnFileCreated(dlOptions.LibraryBook, e.FilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
#region Decryptor event handlers
|
||||||
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
private void Converter_RetrievedMetadata(object? sender, AAXClean.AppleTags tags)
|
||||||
|
{
|
||||||
|
if (sender is not AaxcDownloadConvertBase converter ||
|
||||||
|
converter.AaxFile is not AAXClean.Mp4File aaxFile ||
|
||||||
|
converter.DownloadOptions is not DownloadOptions options ||
|
||||||
|
options.ChapterInfo.Chapters is not List<AAXClean.Chapter> chapters)
|
||||||
|
return;
|
||||||
|
|
||||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
#region Prevent erroneous truncation due to incorrect chapter info
|
||||||
{
|
|
||||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
|
||||||
|
|
||||||
var coverPath = "[null]";
|
//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.
|
||||||
|
|
||||||
try
|
var fileDuration = aaxFile.Duration;
|
||||||
{
|
if (options.Config.StripAudibleBrandAudio)
|
||||||
var destinationDir = getDestinationDirectory(libraryBook);
|
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
|
||||||
|
|
||||||
if (File.Exists(coverPath))
|
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
||||||
FileUtility.SaferDelete(coverPath);
|
//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 lastChapter = chapters[^1];
|
||||||
|
|
||||||
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
chapters.Remove(lastChapter);
|
||||||
if (picBytes.Length > 0)
|
options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta);
|
||||||
{
|
|
||||||
File.WriteAllBytes(coverPath, picBytes);
|
#endregion
|
||||||
SetFileTime(libraryBook, coverPath);
|
|
||||||
}
|
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
||||||
}
|
tags.Album ??= tags.Title;
|
||||||
catch (Exception ex)
|
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
||||||
{
|
tags.AlbumArtists ??= tags.Artist;
|
||||||
//Failure to download cover art should not be considered a failure to download the book
|
tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
|
||||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||||
}
|
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||||
}
|
tags.LongDescription ??= tags.Comment;
|
||||||
}
|
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
||||||
|
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
|
||||||
|
tags.Asin = options.LibraryBook.Book.AudibleProductId;
|
||||||
|
tags.Acr = options.ContentMetadata.ContentReference.Acr;
|
||||||
|
tags.Version = options.ContentMetadata.ContentReference.Version;
|
||||||
|
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
|
||||||
|
{
|
||||||
|
tags.Year ??= pubDate.Year.ToString();
|
||||||
|
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)
|
||||||
|
{
|
||||||
|
if (Configuration.Instance.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
e = OnRequestCoverArt();
|
||||||
|
downloader.SetCoverArt(e);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e is not null)
|
||||||
|
OnCoverImageDiscovered(e);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Validation
|
||||||
|
|
||||||
|
private static void DownloadValidation(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
string errorString(string field)
|
||||||
|
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||||
|
|
||||||
|
string errorTitle()
|
||||||
|
{
|
||||||
|
var title
|
||||||
|
= (libraryBook.Book.TitleWithSubtitle.Length > 53)
|
||||||
|
? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..."
|
||||||
|
: libraryBook.Book.TitleWithSubtitle;
|
||||||
|
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||||
|
return errorBookTitle;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||||
|
throw new InvalidOperationException(errorString("Account"));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||||
|
throw new InvalidOperationException(errorString("Locale"));
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Post-success routines
|
||||||
|
/// <summary>Read the audio format from the audio file's metadata.</summary>
|
||||||
|
public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return firstAudioFile.Extension.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".m4b" or ".m4a" or ".mp4" => GetMp4AudioFormat(),
|
||||||
|
".mp3" => AudioFormatDecoder.FromMpeg3(firstAudioFile.FilePath),
|
||||||
|
_ => AudioFormat.Default
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//Failure to determine output audio format should not be considered a failure to download the book
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error determining output audio format for {@Book}. File = '{@audioFile}'", options.LibraryBook, firstAudioFile);
|
||||||
|
return AudioFormat.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioFormat GetMp4AudioFormat()
|
||||||
|
=> abDownloader is AaxcDownloadConvertBase converter && converter.AaxFile is AAXClean.Mp4File mp4File
|
||||||
|
? AudioFormatDecoder.FromMpeg4(mp4File)
|
||||||
|
: AudioFormatDecoder.FromMpeg4(firstAudioFile.FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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 void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List<TempFile> entries, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
AverageSpeed averageSpeed = new();
|
||||||
|
|
||||||
|
var totalSizeToMove = entries.Sum(f => new FileInfo(f.FilePath).Length);
|
||||||
|
long totalBytesMoved = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = entries[i];
|
||||||
|
|
||||||
|
var destFileName
|
||||||
|
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||||
|
libraryBook,
|
||||||
|
destinationDir,
|
||||||
|
entry.Extension,
|
||||||
|
entry.PartProperties,
|
||||||
|
Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
|
var realDest
|
||||||
|
= FileUtility.SaferMoveToValidPath(
|
||||||
|
entry.FilePath,
|
||||||
|
destFileName,
|
||||||
|
Configuration.Instance.ReplacementCharacters,
|
||||||
|
entry.Extension,
|
||||||
|
Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
|
#region File Move Progress
|
||||||
|
totalBytesMoved += new FileInfo(realDest).Length;
|
||||||
|
averageSpeed.AddPosition(totalBytesMoved);
|
||||||
|
var estSecsRemaining = (totalSizeToMove - totalBytesMoved) / averageSpeed.Average;
|
||||||
|
|
||||||
|
if (double.IsNormal(estSecsRemaining))
|
||||||
|
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
|
||||||
|
|
||||||
|
OnStreamingProgressChanged(new DownloadProgress
|
||||||
|
{
|
||||||
|
ProgressPercentage = 100d * totalBytesMoved / totalSizeToMove,
|
||||||
|
BytesReceived = totalBytesMoved,
|
||||||
|
TotalBytesToReceive = totalSizeToMove
|
||||||
|
});
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
// propagate corrected path for cue file (after this for-loop)
|
||||||
|
entries[i] = entry with { FilePath = realDest };
|
||||||
|
|
||||||
|
SetFileTime(libraryBook, realDest);
|
||||||
|
OnFileCreated(libraryBook, realDest);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.FirstOrDefault(f => getFileType(f) is FileType.Cue) is TempFile cue
|
||||||
|
&& getFirstAudioFile(entries)?.FilePath is LongPath audioFilePath)
|
||||||
|
{
|
||||||
|
Cue.UpdateFileName(cue.FilePath, audioFilePath);
|
||||||
|
SetFileTime(libraryBook, cue.FilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
AudibleFileStorage.Audio.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Config.DownloadCoverArt) return;
|
||||||
|
|
||||||
|
var coverPath = "[null]";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
coverPath
|
||||||
|
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||||
|
options.LibraryBook,
|
||||||
|
destinationDir,
|
||||||
|
extension: ".jpg",
|
||||||
|
returnFirstExisting: Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
|
if (File.Exists(coverPath))
|
||||||
|
FileUtility.SaferDelete(coverPath);
|
||||||
|
|
||||||
|
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(options.LibraryBook, coverPath);
|
||||||
|
OnFileCreated(options.LibraryBook, coverPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//Failure to download cover art should not be considered a failure to download the book
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {@metadataFile}.", options.LibraryBook, coverPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DownloadRecordsAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Config.DownloadClipsBookmarks) return;
|
||||||
|
|
||||||
|
var recordsPath = "[null]";
|
||||||
|
var format = options.Config.ClipsBookmarksFileFormat;
|
||||||
|
var formatExtension = FileUtility.GetStandardizedExtension(format.ToString().ToLowerInvariant());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
recordsPath
|
||||||
|
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||||
|
options.LibraryBook,
|
||||||
|
destinationDir,
|
||||||
|
extension: formatExtension,
|
||||||
|
returnFirstExisting: Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
|
if (File.Exists(recordsPath))
|
||||||
|
FileUtility.SaferDelete(recordsPath);
|
||||||
|
|
||||||
|
var records = await api.GetRecordsAsync(options.AudibleProductId);
|
||||||
|
|
||||||
|
switch (format)
|
||||||
|
{
|
||||||
|
case Configuration.ClipBookmarkFormat.CSV:
|
||||||
|
RecordExporter.ToCsv(recordsPath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Xlsx:
|
||||||
|
RecordExporter.ToXlsx(recordsPath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Json:
|
||||||
|
RecordExporter.ToJson(recordsPath, options.LibraryBook, records);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"Unsupported record export format: {format}");
|
||||||
|
}
|
||||||
|
|
||||||
|
SetFileTime(options.LibraryBook, recordsPath);
|
||||||
|
OnFileCreated(options.LibraryBook, recordsPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//Failure to download records should not be considered a failure to download the book
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {@recordsPath}.", options.LibraryBook, recordsPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!options.Config.SaveMetadataToFile) return;
|
||||||
|
|
||||||
|
string metadataPath = "[null]";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
metadataPath
|
||||||
|
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||||
|
options.LibraryBook,
|
||||||
|
destinationDir,
|
||||||
|
extension: ".metadata.json",
|
||||||
|
returnFirstExisting: Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
|
if (File.Exists(metadataPath))
|
||||||
|
FileUtility.SaferDelete(metadataPath);
|
||||||
|
|
||||||
|
var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||||
|
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo));
|
||||||
|
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference));
|
||||||
|
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
File.WriteAllText(metadataPath, item.SourceJson.ToString());
|
||||||
|
SetFileTime(options.LibraryBook, metadataPath);
|
||||||
|
OnFileCreated(options.LibraryBook, metadataPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//Failure to download metadata should not be considered a failure to download the book
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error downloading metdatat of {@Book} to {@metadataFile}.", options.LibraryBook, metadataPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Macros
|
||||||
|
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||||
|
if (!Directory.Exists(destinationDir))
|
||||||
|
Directory.CreateDirectory(destinationDir);
|
||||||
|
return destinationDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FileType getFileType(TempFile file)
|
||||||
|
=> FileTypes.GetFileTypeFromPath(file.FilePath);
|
||||||
|
private static TempFile? getFirstAudioFile(IEnumerable<TempFile> entries)
|
||||||
|
=> entries.FirstOrDefault(f => File.Exists(f.FilePath) && getFileType(f) is FileType.Audio);
|
||||||
|
private static IEnumerable<TempFile> getAaxcFiles(IEnumerable<TempFile> entries)
|
||||||
|
=> entries.Where(f => File.Exists(f.FilePath) && (getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase)));
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using AaxDecrypter;
|
||||||
|
using AudibleApi;
|
||||||
|
using AudibleApi.Common;
|
||||||
|
using AudibleUtilities.Widevine;
|
||||||
|
using DataLayer;
|
||||||
|
using LibationFileManager;
|
||||||
|
using NAudio.Lame;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace FileLiberator;
|
||||||
|
|
||||||
|
public partial class DownloadOptions
|
||||||
|
{
|
||||||
|
private const string Ec3Codec = "ec+3";
|
||||||
|
private const string Ac4Codec = "ac-4";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiate an audiobook download from the audible api.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
|
||||||
|
{
|
||||||
|
var license = await ChooseContent(api, libraryBook, config, token);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
//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 class LicenseInfo
|
||||||
|
{
|
||||||
|
public DrmType DrmType { get; }
|
||||||
|
public ContentMetadata ContentMetadata { get; set; }
|
||||||
|
public KeyData[]? DecryptionKeys { get; }
|
||||||
|
public LicenseInfo(ContentLicense license, IEnumerable<KeyData>? keys = null)
|
||||||
|
{
|
||||||
|
DrmType = license.DrmType;
|
||||||
|
ContentMetadata = license.ContentMetadata;
|
||||||
|
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyData[]? ToKeys(VoucherDtoV10? voucher)
|
||||||
|
=> voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||||
|
{
|
||||||
|
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||||
|
|
||||||
|
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||||
|
return new LicenseInfo(license);
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//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;
|
||||||
|
|
||||||
|
var contentLic
|
||||||
|
= await api.GetDownloadLicenseAsync(
|
||||||
|
libraryBook.Book.AudibleProductId,
|
||||||
|
dlQuality,
|
||||||
|
ChapterTitlesType.Tree,
|
||||||
|
DrmType.Widevine,
|
||||||
|
config.RequestSpatial,
|
||||||
|
codecChoice);
|
||||||
|
|
||||||
|
if (contentLic.DrmType is not DrmType.Widevine)
|
||||||
|
return new LicenseInfo(contentLic);
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token);
|
||||||
|
var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
|
||||||
|
|
||||||
|
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
|
||||||
|
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
|
||||||
|
|
||||||
|
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
|
||||||
|
|
||||||
|
using var session = cdm.OpenSession();
|
||||||
|
var challenge = session.GetLicenseChallenge(dash);
|
||||||
|
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
|
||||||
|
var keys = session.ParseLicense(licenseMessage);
|
||||||
|
return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
||||||
|
//We failed to get a widevine content license. Depending on the
|
||||||
|
//failure reason, users can potentially still download this audiobook
|
||||||
|
//by disabling the "Use Widevine DRM" feature.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||||
|
{
|
||||||
|
long chapterStartMs
|
||||||
|
= config.StripAudibleBrandAudio
|
||||||
|
? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
|
||||||
|
{
|
||||||
|
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||||
|
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||||
|
};
|
||||||
|
|
||||||
|
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||||
|
var chapters
|
||||||
|
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||||
|
.OrderBy(c => c.StartOffsetMs)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (config.MergeOpeningAndEndCredits)
|
||||||
|
combineCredits(chapters);
|
||||||
|
|
||||||
|
for (int i = 0; i < chapters.Count; i++)
|
||||||
|
{
|
||||||
|
var chapter = chapters[i];
|
||||||
|
long chapLenMs = chapter.LengthMs;
|
||||||
|
|
||||||
|
if (i == 0)
|
||||||
|
chapLenMs -= chapterStartMs;
|
||||||
|
|
||||||
|
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||||
|
chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||||
|
|
||||||
|
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dlOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LameConfig GetLameOptions(Configuration config)
|
||||||
|
{
|
||||||
|
LameConfig lameConfig = new()
|
||||||
|
{
|
||||||
|
Mode = MPEGMode.Mono,
|
||||||
|
Quality = config.LameEncoderQuality,
|
||||||
|
OutputSampleRate = (int)config.MaxSampleRate
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.LameTargetBitrate)
|
||||||
|
{
|
||||||
|
if (config.LameConstantBitrate)
|
||||||
|
lameConfig.BitRate = config.LameBitrate;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lameConfig.ABRRateKbps = config.LameBitrate;
|
||||||
|
lameConfig.VBR = VBRMode.ABR;
|
||||||
|
lameConfig.WriteVBRTag = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lameConfig.VBR = VBRMode.Default;
|
||||||
|
lameConfig.VBRQuality = config.LameVBRQuality;
|
||||||
|
lameConfig.WriteVBRTag = true;
|
||||||
|
}
|
||||||
|
return lameConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||||
|
|
||||||
|
Audible may deliver chapters like this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:12 Book 1
|
||||||
|
00:12 - 00:14 | Part 1
|
||||||
|
00:14 - 01:40 | | Chapter 1
|
||||||
|
01:40 - 03:20 | | Chapter 2
|
||||||
|
03:20 - 03:22 | Part 2
|
||||||
|
03:22 - 05:00 | | Chapter 3
|
||||||
|
05:00 - 06:40 | | Chapter 4
|
||||||
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 06:44 | Part 3
|
||||||
|
06:44 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
|
And flattenChapters will combine them into this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
|
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
|
13:20 - 13:40 End Credits
|
||||||
|
|
||||||
|
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
||||||
|
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
||||||
|
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
||||||
|
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:25 Book 1
|
||||||
|
00:25 - 00:27 | Part 1
|
||||||
|
00:27 - 01:40 | | Chapter 1
|
||||||
|
01:40 - 03:20 | | Chapter 2
|
||||||
|
03:20 - 03:22 | Part 2
|
||||||
|
03:22 - 05:00 | | Chapter 3
|
||||||
|
05:00 - 06:40 | | Chapter 4
|
||||||
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 07:02 | Part 3
|
||||||
|
07:02 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
|
then flattenChapters will combine them into this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:25 Book 1
|
||||||
|
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
|
06:40 - 07:02 Book 2: Part 3
|
||||||
|
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
|
13:20 - 13:40 End Credits
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string? titleConcat = ": ")
|
||||||
|
{
|
||||||
|
List<Chapter> chaps = new();
|
||||||
|
|
||||||
|
foreach (var c in chapters)
|
||||||
|
{
|
||||||
|
if (c.Chapters is null)
|
||||||
|
chaps.Add(c);
|
||||||
|
else if (titleConcat is null)
|
||||||
|
{
|
||||||
|
chaps.Add(c);
|
||||||
|
chaps.AddRange(flattenChapters(c.Chapters, titleConcat));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (c.LengthMs < 10000)
|
||||||
|
{
|
||||||
|
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||||
|
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||||
|
c.Chapters[0].LengthMs += c.LengthMs;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
chaps.Add(c);
|
||||||
|
|
||||||
|
var children = flattenChapters(c.Chapters, titleConcat);
|
||||||
|
|
||||||
|
foreach (var child in children)
|
||||||
|
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||||
|
|
||||||
|
chaps.AddRange(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void combineCredits(IList<Chapter> chapters)
|
||||||
|
{
|
||||||
|
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||||
|
{
|
||||||
|
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||||
|
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||||
|
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||||
|
chapters.RemoveAt(0);
|
||||||
|
}
|
||||||
|
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||||
|
{
|
||||||
|
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||||
|
chapters.Remove(chapters[^1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,94 +3,84 @@ using AAXClean;
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using ApplicationServices;
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadOptions : IDownloadOptions, IDisposable
|
public partial class DownloadOptions : IDownloadOptions, IDisposable
|
||||||
{
|
{
|
||||||
public event EventHandler<long> DownloadSpeedChanged;
|
public event EventHandler<long>? DownloadSpeedChanged;
|
||||||
public LibraryBook LibraryBook { get; }
|
public LibraryBook LibraryBook { get; }
|
||||||
public LibraryBookDto LibraryBookDto { get; }
|
public LibraryBookDto LibraryBookDto { get; }
|
||||||
public string DownloadUrl { get; }
|
public string DownloadUrl { get; }
|
||||||
public string AudibleKey { get; init; }
|
public KeyData[]? DecryptionKeys { get; }
|
||||||
public string AudibleIV { get; init; }
|
public required TimeSpan RuntimeLength { get; init; }
|
||||||
public TimeSpan RuntimeLength { get; init; }
|
public OutputFormat OutputFormat { get; }
|
||||||
public OutputFormat OutputFormat { get; init; }
|
public required ChapterInfo ChapterInfo { get; init; }
|
||||||
public ChapterInfo ChapterInfo { get; init; }
|
|
||||||
public string Title => LibraryBook.Book.Title;
|
public string Title => LibraryBook.Book.Title;
|
||||||
public string Subtitle => LibraryBook.Book.Subtitle;
|
public string Subtitle => LibraryBook.Book.Subtitle;
|
||||||
public string Publisher => LibraryBook.Book.Publisher;
|
public string Publisher => LibraryBook.Book.Publisher;
|
||||||
public string Language => LibraryBook.Book.Language;
|
public string Language => LibraryBook.Book.Language;
|
||||||
public string AudibleProductId => LibraryBookDto.AudibleProductId;
|
public string? AudibleProductId => LibraryBookDto.AudibleProductId;
|
||||||
public string SeriesName => LibraryBookDto.SeriesName;
|
public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
|
||||||
public float? SeriesNumber => LibraryBookDto.SeriesNumber;
|
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 string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||||
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
|
||||||
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
|
public bool CreateCueSheet => Config.CreateCueSheet;
|
||||||
public bool CreateCueSheet => config.CreateCueSheet;
|
public long DownloadSpeedBps => Config.DownloadSpeedLimit;
|
||||||
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks;
|
public bool FixupFile => Config.AllowLibationFixup;
|
||||||
public long DownloadSpeedBps => config.DownloadSpeedLimit;
|
public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
|
||||||
public bool RetainEncryptedFile => config.RetainAaxFile;
|
public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
|
||||||
public bool FixupFile => config.AllowLibationFixup;
|
public bool MoveMoovToBeginning => Config.MoveMoovToBeginning;
|
||||||
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
|
public AAXClean.FileType? InputType { get; }
|
||||||
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
|
public AudibleApi.Common.DrmType DrmType { get; }
|
||||||
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetMultipartTitle(MultiConvertFileProperties props)
|
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||||
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
|
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
|
||||||
|
|
||||||
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
|
public Configuration Config { get; }
|
||||||
|
private readonly IDisposable cancellation;
|
||||||
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (DownloadClipsBookmarks)
|
cancellation?.Dispose();
|
||||||
{
|
GC.SuppressFinalize(this);
|
||||||
var format = config.ClipsBookmarksFileFormat;
|
|
||||||
|
|
||||||
var formatExtension = format.ToString().ToLowerInvariant();
|
|
||||||
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
|
||||||
|
|
||||||
var api = await LibraryBook.GetApiAsync();
|
|
||||||
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
|
|
||||||
|
|
||||||
switch(format)
|
|
||||||
{
|
|
||||||
case Configuration.ClipBookmarkFormat.CSV:
|
|
||||||
RecordExporter.ToCsv(filePath, records);
|
|
||||||
break;
|
|
||||||
case Configuration.ClipBookmarkFormat.Xlsx:
|
|
||||||
RecordExporter.ToXlsx(filePath, records);
|
|
||||||
break;
|
|
||||||
case Configuration.ClipBookmarkFormat.Json:
|
|
||||||
RecordExporter.ToJson(filePath, LibraryBook, records);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
return string.Empty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Configuration config;
|
private DownloadOptions(Configuration config, LibraryBook libraryBook, LicenseInfo licInfo)
|
||||||
private readonly IDisposable cancellation;
|
|
||||||
public void Dispose() => cancellation?.Dispose();
|
|
||||||
|
|
||||||
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
|
|
||||||
{
|
{
|
||||||
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
Config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
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 = LibraryBook.ToDto();
|
||||||
|
|
||||||
cancellation =
|
cancellation =
|
||||||
|
|||||||
@@ -19,5 +19,10 @@
|
|||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="DownloadOptions.*.cs">
|
||||||
|
<DependentUpon>DownloadOptions.cs</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -5,8 +5,9 @@ using System.Threading.Tasks;
|
|||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using LibationFileManager;
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public static class UtilityExtensions
|
public static class UtilityExtensions
|
||||||
@@ -19,9 +20,15 @@ namespace FileLiberator
|
|||||||
account: libraryBook.Account.ToMask()
|
account: libraryBook.Account.ToMask()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
|
||||||
|
|
||||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
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;
|
return apiExtended.Api;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,20 +54,37 @@ namespace FileLiberator
|
|||||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||||
DatePublished = libraryBook.Book.DatePublished,
|
DatePublished = libraryBook.Book.DatePublished,
|
||||||
|
|
||||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
Authors = libraryBook.Book.Authors.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
|
||||||
|
Narrators = libraryBook.Book.Narrators.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
|
||||||
|
|
||||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
Series = getSeries(libraryBook.Book.SeriesLink),
|
||||||
|
|
||||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
|
||||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
|
||||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||||
|
|
||||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
Language = libraryBook.Book.Language,
|
||||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString,
|
||||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate,
|
||||||
Language = libraryBook.Book.Language
|
SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate,
|
||||||
|
Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount,
|
||||||
|
LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString(3),
|
||||||
|
FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<SeriesDto>? getSeries(IEnumerable<SeriesBook> seriesBooks)
|
||||||
|
{
|
||||||
|
if (!seriesBooks.Any())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
//I don't remember why or if there was a good reason not to have series numbers for
|
||||||
|
//podcast parents, but preserving the behavior for backwards compatibility.
|
||||||
|
return seriesBooks
|
||||||
|
.Select(sb
|
||||||
|
=> new SeriesDto(
|
||||||
|
sb.Series.Name,
|
||||||
|
sb.Book.IsEpisodeParent() ? null : sb.Index,
|
||||||
|
sb.Series.AudibleSeriesId)
|
||||||
|
).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||||
<PackageReference Include="Polly" Version="8.5.2" />
|
<PackageReference Include="Polly" Version="8.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
|||||||
@@ -6,7 +6,30 @@
|
|||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme/>
|
<FluentTheme>
|
||||||
</Application.Styles>
|
<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>
|
</Application>
|
||||||
@@ -4,27 +4,16 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="HangoverAvalonia.Controls.CheckedListBox">
|
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
|
<ScrollViewer
|
||||||
Name="scroller"
|
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
VerticalScrollBarVisibility="Auto">
|
VerticalScrollBarVisibility="Auto">
|
||||||
<ItemsRepeater IsVisible="True"
|
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
|
||||||
VerticalCacheLength="1.2"
|
<ItemsControl.ItemTemplate>
|
||||||
HorizontalCacheLength="1"
|
<DataTemplate>
|
||||||
ItemsSource="{Binding CheckboxItems}"
|
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||||
ItemTemplate="{StaticResource elementFactory}" />
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -2,103 +2,18 @@ using Avalonia;
|
|||||||
using Avalonia.Collections;
|
using Avalonia.Collections;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using HangoverAvalonia.ViewModels;
|
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;
|
InitializeComponent();
|
||||||
|
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,13 +71,12 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
||||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
<PackageReference Include="Avalonia" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
|
||||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.5" />
|
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.5" />
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -1,41 +1,8 @@
|
|||||||
using ApplicationServices;
|
namespace HangoverAvalonia.ViewModels;
|
||||||
using DataLayer;
|
|
||||||
using ReactiveUI;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace HangoverAvalonia.ViewModels
|
public partial class MainVM
|
||||||
{
|
{
|
||||||
public partial class MainVM
|
public TrashBinViewModel TrashBinViewModel { get; } = new();
|
||||||
{
|
|
||||||
private List<LibraryBook> _deletedBooks;
|
|
||||||
public List<LibraryBook> DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); }
|
|
||||||
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
|
|
||||||
|
|
||||||
private int _totalBooksCount = 0;
|
private void Load_deletedVM() { }
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,40 +1,12 @@
|
|||||||
using ApplicationServices;
|
namespace HangoverAvalonia.Views;
|
||||||
using DataLayer;
|
|
||||||
using HangoverAvalonia.Controls;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
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.TrashBinViewModel.Reload();
|
||||||
_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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,11 @@
|
|||||||
|
|
||||||
<TabControl Name="tabControl1" Grid.Row="0">
|
<TabControl Name="tabControl1" Grid.Row="0">
|
||||||
<TabControl.Styles>
|
<TabControl.Styles>
|
||||||
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
|
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
|
||||||
<Setter Property="Height" Value="23"/>
|
<Setter Property="Height" Value="33"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem">
|
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
|
||||||
<Setter Property="MinHeight" Value="40"/>
|
<Setter Property="Height" Value="33"/>
|
||||||
<Setter Property="Height" Value="40"/>
|
|
||||||
<Setter Property="Padding" Value="8,2,8,5"/>
|
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem#Header TextBlock">
|
<Style Selector="TabItem#Header TextBlock">
|
||||||
<Setter Property="MinHeight" Value="5"/>
|
<Setter Property="MinHeight" Value="5"/>
|
||||||
@@ -51,6 +49,7 @@
|
|||||||
|
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="0,5,0,5"
|
Margin="0,5,0,5"
|
||||||
|
AcceptsReturn="True"
|
||||||
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -73,33 +72,58 @@
|
|||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
|
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
|
DataContext="{Binding TrashBinViewModel}"
|
||||||
RowDefinitions="Auto,*,Auto">
|
RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Margin="5"
|
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
|
<controls:CheckedListBox
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Margin="5,0,5,0"
|
Margin="5,0,5,0"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
BorderBrush="Gray"
|
BorderBrush="Gray"
|
||||||
Name="deletedCbl"
|
IsEnabled="{Binding ControlsEnabled}"
|
||||||
Items="{Binding DeletedBooks}" />
|
Items="{Binding DeletedBooks}" />
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
Margin="5"
|
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" />
|
<CheckBox
|
||||||
<Button Grid.Column="1" Margin="0,0,20,0" Content="Uncheck All" Click="Deleted_UncheckAll_Click" />
|
IsEnabled="{Binding ControlsEnabled}"
|
||||||
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding CheckedCountText}" />
|
IsThreeState="True"
|
||||||
<Button Grid.Column="3" HorizontalAlignment="Right" Content="Save" Click="Deleted_Save_Click" />
|
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
from Libation" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ namespace HangoverAvalonia.Views
|
|||||||
|
|
||||||
public void OnLoad()
|
public void OnLoad()
|
||||||
{
|
{
|
||||||
deletedCbl.ItemCheck += Deleted_CheckedListBox_ItemCheck;
|
|
||||||
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
|
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); };
|
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); };
|
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ using Dinah.Core;
|
|||||||
using LibationAvalonia.Themes;
|
using LibationAvalonia.Themes;
|
||||||
using Avalonia.Data.Core.Plugins;
|
using Avalonia.Data.Core.Plugins;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using LibationUiBase.Forms;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
namespace LibationAvalonia
|
namespace LibationAvalonia
|
||||||
@@ -42,6 +44,9 @@ namespace LibationAvalonia
|
|||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
|
||||||
|
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||||
|
|
||||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||||
DisableAvaloniaDataAnnotationValidation();
|
DisableAvaloniaDataAnnotationValidation();
|
||||||
|
|||||||
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -4,6 +4,7 @@ using Avalonia.Markup.Xaml.MarkupExtensions;
|
|||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using LibationUiBase.Forms;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|||||||
@@ -5,26 +5,15 @@
|
|||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="LibationAvalonia.Controls.CheckedListBox">
|
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
|
<ScrollViewer
|
||||||
Name="scroller"
|
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
VerticalScrollBarVisibility="Auto">
|
VerticalScrollBarVisibility="Auto">
|
||||||
<ItemsRepeater IsVisible="True"
|
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
|
||||||
VerticalCacheLength="1.2"
|
<ItemsControl.ItemTemplate>
|
||||||
HorizontalCacheLength="1"
|
<DataTemplate>
|
||||||
ItemsSource="{Binding CheckboxItems}"
|
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||||
ItemTemplate="{StaticResource elementFactory}" />
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -12,24 +12,10 @@ namespace LibationAvalonia.Controls
|
|||||||
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
|
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
|
||||||
|
|
||||||
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
|
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
|
||||||
private CheckedListBoxViewModel _viewModel = new();
|
|
||||||
|
|
||||||
public CheckedListBox()
|
public CheckedListBox()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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); }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace LibationAvalonia.Controls
|
|||||||
{
|
{
|
||||||
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
|
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
|
||||||
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
|
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
|
||||||
ele.IsThreeState = dataItem is ISeriesEntry;
|
ele.IsThreeState = dataItem is SeriesEntry;
|
||||||
return ele;
|
return ele;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,11 @@ namespace LibationAvalonia.Controls
|
|||||||
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
|
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is DataGridCell cell &&
|
if (sender is DataGridCell cell &&
|
||||||
cell.DataContext is IGridEntry clickedEntry &&
|
cell.DataContext is GridEntry clickedEntry &&
|
||||||
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
|
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
|
||||||
OwningGridProperty.GetValue(column) is DataGrid grid)
|
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);
|
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
|
||||||
if (clickedIndex == -1)
|
if (clickedIndex == -1)
|
||||||
{
|
{
|
||||||
@@ -101,7 +101,7 @@ namespace LibationAvalonia.Controls
|
|||||||
private static string RemoveLineBreaks(string text)
|
private static string RemoveLineBreaks(string text)
|
||||||
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
|
=> 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();
|
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
|
||||||
return string.Join("\t", contents);
|
return string.Join("\t", contents);
|
||||||
@@ -109,7 +109,7 @@ namespace LibationAvalonia.Controls
|
|||||||
|
|
||||||
public required DataGrid Grid { get; init; }
|
public required DataGrid Grid { get; init; }
|
||||||
public required DataGridColumn Column { 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 required ContextMenu ContextMenu { get; init; }
|
||||||
public AvaloniaList<Control> ContextMenuItems
|
public AvaloniaList<Control> ContextMenuItems
|
||||||
=> ContextMenu.ItemsSource as AvaloniaList<Control>;
|
=> ContextMenu.ItemsSource as AvaloniaList<Control>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
@@ -90,7 +91,7 @@ namespace LibationAvalonia.Controls
|
|||||||
|
|
||||||
var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options);
|
var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options);
|
||||||
|
|
||||||
directoryState.CustomDir = selectedFolders.SingleOrDefault()?.Path?.LocalPath ?? directoryState.CustomDir;
|
directoryState.CustomDir = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? directoryState.CustomDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
|
private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
|
|||||||
@@ -47,6 +47,53 @@
|
|||||||
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
|
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
||||||
|
<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"
|
||||||
|
Text="{CompiledBinding SpatialAudioCodecText}" />
|
||||||
|
|
||||||
|
<controls:WheelComboBox
|
||||||
|
Margin="5,0,0,0"
|
||||||
|
Grid.Column="1"
|
||||||
|
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
|
||||||
|
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
|
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
|
||||||
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />
|
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />
|
||||||
</CheckBox>
|
</CheckBox>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
using AudibleUtilities;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using LibationAvalonia.Dialogs;
|
using LibationAvalonia.Dialogs;
|
||||||
using LibationAvalonia.ViewModels.Settings;
|
using LibationAvalonia.ViewModels.Settings;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using LibationFileManager.Templates;
|
||||||
|
using LibationUiBase.Forms;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LibationAvalonia.Controls.Settings
|
namespace LibationAvalonia.Controls.Settings
|
||||||
@@ -19,6 +23,44 @@ namespace LibationAvalonia.Controls.Settings
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void UseWidevine_IsCheckedChanged(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (_viewModel is null) return;
|
if (_viewModel is null) return;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using Avalonia.Controls;
|
|||||||
using LibationAvalonia.Dialogs;
|
using LibationAvalonia.Dialogs;
|
||||||
using LibationAvalonia.ViewModels.Settings;
|
using LibationAvalonia.ViewModels.Settings;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using LibationFileManager.Templates;
|
||||||
|
using LibationUiBase.Forms;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LibationAvalonia.Controls.Settings
|
namespace LibationAvalonia.Controls.Settings
|
||||||
|
|||||||
@@ -58,10 +58,5 @@ namespace LibationAvalonia.Controls.Settings
|
|||||||
}
|
}
|
||||||
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
|
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OpenLogFolderButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Media.Imaging;
|
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
using LibationAvalonia.ViewModels;
|
using LibationAvalonia.ViewModels;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using NPOI.Util.Collections;
|
using LibationUiBase.ProcessQueue;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
@@ -38,11 +36,11 @@ public partial class ThemePreviewControl : UserControl
|
|||||||
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
|
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
QueuedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Queued };
|
QueuedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Queued };
|
||||||
WorkingBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Working };
|
WorkingBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Working };
|
||||||
CompletedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Completed };
|
CompletedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Completed };
|
||||||
CancelledBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Cancelled };
|
CancelledBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Cancelled };
|
||||||
FailedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Failed };
|
FailedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Failed };
|
||||||
|
|
||||||
//Set the current processable so that the empty queue doesn't try to advance.
|
//Set the current processable so that the empty queue doesn't try to advance.
|
||||||
QueuedBook.AddDownloadPdf();
|
QueuedBook.AddDownloadPdf();
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="520"
|
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="540"
|
||||||
MinWidth="450" MinHeight="520"
|
MinWidth="450" MinHeight="540"
|
||||||
Width="450" Height="520"
|
Width="450" Height="540"
|
||||||
x:Class="LibationAvalonia.Dialogs.AboutDialog"
|
x:Class="LibationAvalonia.Dialogs.AboutDialog"
|
||||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||||
Title="About Libation">
|
Title="About Libation">
|
||||||
@@ -41,39 +41,38 @@
|
|||||||
|
|
||||||
<controls:GroupBox Grid.Row="3" Label="Acknowledgements" Grid.ColumnSpan="2">
|
<controls:GroupBox Grid.Row="3" Label="Acknowledgements" Grid.ColumnSpan="2">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
<StackPanel.Styles>
|
||||||
|
<Style Selector="controls|LinkLabel">
|
||||||
|
<Setter Property="Margin" Value="5,0" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
</Style>
|
||||||
|
</StackPanel.Styles>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto">
|
<ItemsControl ItemsSource="{Binding PrimaryContributors}">
|
||||||
<controls:LinkLabel FontWeight="Bold" Text="rmcrackan" Tapped="Link_GithubUser" />
|
<ItemsControl.ItemTemplate>
|
||||||
<TextBlock Grid.Column="1" Margin="10,0" Text="Creator" />
|
<DataTemplate>
|
||||||
<controls:LinkLabel Grid.Row="1" FontWeight="Bold" Text="Mbucari" Tapped="Link_GithubUser" />
|
<StackPanel Orientation="Horizontal">
|
||||||
<TextBlock Grid.Row="1" Grid.Column="1" Margin="10,0" Text="Developer" />
|
<controls:LinkLabel FontWeight="Bold" Text="{Binding Name}" Tapped="ContributorLink_Tapped" />
|
||||||
</Grid>
|
<TextBlock Grid.Column="1" Margin="10,0" Text="{Binding Type}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
<TextBlock Margin="0,10" FontSize="12" Text="Additional Contributions by:" TextDecorations="Underline"/>
|
<TextBlock Margin="0,10" FontSize="12" Text="Additional Contributions by:" TextDecorations="Underline"/>
|
||||||
|
|
||||||
<WrapPanel>
|
<ItemsControl ItemsSource="{Binding AdditionalContributors}">
|
||||||
<WrapPanel.Styles>
|
<ItemsControl.ItemsPanel>
|
||||||
<Style Selector="controls|LinkLabel">
|
<ItemsPanelTemplate>
|
||||||
<Setter Property="Margin" Value="5,0" />
|
<WrapPanel />
|
||||||
<Setter Property="FontSize" Value="13" />
|
</ItemsPanelTemplate>
|
||||||
</Style>
|
</ItemsControl.ItemsPanel>
|
||||||
</WrapPanel.Styles>
|
<ItemsControl.ItemTemplate>
|
||||||
<controls:LinkLabel Text="pixil98" Tapped="Link_GithubUser" />
|
<DataTemplate>
|
||||||
<controls:LinkLabel Text="hutattedonmyarm" Tapped="Link_GithubUser" />
|
<controls:LinkLabel Text="{Binding Name}" Tapped="ContributorLink_Tapped" />
|
||||||
<controls:LinkLabel Text="seanke" Tapped="Link_GithubUser" />
|
</DataTemplate>
|
||||||
<controls:LinkLabel Text="wtanksleyjr" Tapped="Link_GithubUser" />
|
</ItemsControl.ItemTemplate>
|
||||||
<controls:LinkLabel Text="Dr.Blank" Tapped="Link_GithubUser" />
|
</ItemsControl>
|
||||||
<controls:LinkLabel Text="CharlieRussel" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="cbordeman" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="jwillikers" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="Shuvashish76" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="RokeJulianLockhart" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="maaximal" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="muchtall" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="ScubyG" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="patienttruth" Tapped="Link_GithubUser" />
|
|
||||||
<controls:LinkLabel Text="stickystyle" Tapped="Link_GithubUser" />
|
|
||||||
</WrapPanel>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</controls:GroupBox>
|
</controls:GroupBox>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ using LibationAvalonia.Controls;
|
|||||||
using LibationAvalonia.ViewModels;
|
using LibationAvalonia.ViewModels;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using LibationUiBase;
|
using LibationUiBase;
|
||||||
|
using LibationUiBase.Forms;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace LibationAvalonia.Dialogs
|
namespace LibationAvalonia.Dialogs
|
||||||
@@ -48,11 +50,11 @@ namespace LibationAvalonia.Dialogs
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Link_GithubUser(object sender, Avalonia.Input.TappedEventArgs e)
|
private void ContributorLink_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is LinkLabel lbl)
|
if (sender is LinkLabel lbl && lbl.DataContext is LibationContributor contributor)
|
||||||
{
|
{
|
||||||
Dinah.Core.Go.To.Url($"ht" + $"tps://github.com/{lbl.Text.Replace('.','-')}");
|
Dinah.Core.Go.To.Url(contributor.Link.AbsoluteUri);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +74,9 @@ namespace LibationAvalonia.Dialogs
|
|||||||
private bool canCheckForUpgrade = true;
|
private bool canCheckForUpgrade = true;
|
||||||
private string upgradeButtonText = "Check for Upgrade";
|
private string upgradeButtonText = "Check for Upgrade";
|
||||||
|
|
||||||
|
public IEnumerable<LibationContributor> PrimaryContributors => LibationContributor.PrimaryContributors;
|
||||||
|
public IEnumerable<LibationContributor> AdditionalContributors => LibationContributor.AdditionalContributors;
|
||||||
|
|
||||||
public AboutVM()
|
public AboutVM()
|
||||||
{
|
{
|
||||||
Version = $"Libation {AppScaffolding.LibationScaffolding.Variety} v{AppScaffolding.LibationScaffolding.BuildVersion}";
|
Version = $"Libation {AppScaffolding.LibationScaffolding.Variety} v{AppScaffolding.LibationScaffolding.BuildVersion}";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using AudibleUtilities;
|
|||||||
using Avalonia.Collections;
|
using Avalonia.Collections;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using LibationUiBase.Forms;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|||||||