Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c76a7414 | ||
|
|
17a0c21453 | ||
|
|
fc9c9dfe48 | ||
|
|
d5f0e39981 | ||
|
|
0f6493f4af | ||
|
|
454b490a06 | ||
|
|
ffea2648aa | ||
|
|
1ac967500c | ||
|
|
ed5afe5d0f | ||
|
|
ab075d0bef | ||
|
|
7fb1adb41b | ||
|
|
9735a8391c | ||
|
|
dbdfdbc536 | ||
|
|
3b86fc405f | ||
|
|
4ea7f04921 | ||
|
|
5b59b442ab | ||
|
|
b5d9c0a27a | ||
|
|
f5cbf89e13 | ||
|
|
00dc9e020d | ||
|
|
bfa0e4d338 | ||
|
|
5ceda408da | ||
|
|
716b1923a4 | ||
|
|
1148d8125d | ||
|
|
690fd10e42 | ||
|
|
736fbbf82f | ||
|
|
eda100b7ac | ||
|
|
ceb007500d | ||
|
|
05fad01624 | ||
|
|
e1d789ccdc | ||
|
|
d0f00f3f1e | ||
|
|
6ab82dba7b | ||
|
|
0045202334 | ||
|
|
4c80813651 | ||
|
|
6b637b35ab | ||
|
|
9b55ffa715 | ||
|
|
65da7890f1 | ||
|
|
72f92ec6c0 | ||
|
|
4efc084375 | ||
|
|
f955daa5ed | ||
|
|
144ab2162a | ||
|
|
6d0c4a9b3c | ||
|
|
8a682533c1 | ||
|
|
cecabc911e | ||
|
|
e35f5209dc | ||
|
|
4ffe70af0e | ||
|
|
233ba3184f | ||
|
|
ac4c168725 | ||
|
|
db588629c0 | ||
|
|
29be091a4b | ||
|
|
82a48db57b | ||
|
|
9f0f32a462 | ||
|
|
f64239b5ee | ||
|
|
bc8a35aedd | ||
|
|
2fca6b8b91 | ||
|
|
bc2eddd2dd | ||
|
|
ae012548bd | ||
|
|
76a59873ea | ||
|
|
3129fdba7b | ||
|
|
1771813849 | ||
|
|
7024bbf823 | ||
|
|
663f70b8bf | ||
|
|
7741e3caff | ||
|
|
c82eefa768 | ||
|
|
0e4231906a | ||
|
|
9bca84dca4 | ||
|
|
ca30fd41c6 | ||
|
|
be96f99461 | ||
|
|
f017fe419f | ||
|
|
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 |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,10 +6,14 @@ labels: bug
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking an attached log file may get limited or delayed help.
|
||||||
|
|
||||||
|
___
|
||||||
|
|
||||||
|
## Describe the bug
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
**To Reproduce**
|
## To Reproduce
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
@ -17,14 +21,23 @@ Steps to reproduce the behavior:
|
|||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
**Expected behavior**
|
## Expected behavior
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
**Screenshots**
|
## Screenshots
|
||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Platform**
|
## Platform
|
||||||
[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. If your user folder contains the file "LibationCrash.log", attach that also.
|
||||||
|
|
||||||
|
**Default Log File Locations**
|
||||||
|
|Platform|Folder|
|
||||||
|
|-|-|
|
||||||
|
|Windows|`%userprofile%\Libation`|
|
||||||
|
|macOS|`~/Library/Application Support/Libation`|
|
||||||
|
|Linux|`~/.local/share/Libation`|
|
||||||
|
|
||||||
|
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
|
||||||
|
|||||||
4
.github/workflows/build-linux.yml
vendored
@ -41,9 +41,9 @@ jobs:
|
|||||||
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
|
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
|
||||||
runs-on: ${{ inputs.runs_on }}
|
runs-on: ${{ inputs.runs_on }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v5
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
env:
|
env:
|
||||||
|
|||||||
29
.github/workflows/build-windows.yml
vendored
@ -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]
|
||||||
@ -35,9 +42,9 @@ jobs:
|
|||||||
release_name: classic
|
release_name: classic
|
||||||
prefix: Classic-
|
prefix: Classic-
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v4
|
uses: actions/setup-dotnet@v5
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
env:
|
env:
|
||||||
@ -63,38 +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 = @(
|
||||||
"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"
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/build.yml
vendored
@ -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:
|
||||||
|
|||||||
2
.github/workflows/docker.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
pattern: "*(Classic-)Libation.*"
|
pattern: "*(Classic-)Libation.*"
|
||||||
|
|||||||
@ -17,6 +17,6 @@ jobs:
|
|||||||
container:
|
container:
|
||||||
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Check the MetaInfo file
|
- name: Check the MetaInfo file
|
||||||
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||||
|
|||||||
2
.github/workflows/validate-desktop-file.yaml
vendored
@ -15,7 +15,7 @@ jobs:
|
|||||||
validate-desktop-file:
|
validate-desktop-file:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- run: sudo apt --yes install desktop-file-utils
|
- run: sudo apt --yes install desktop-file-utils
|
||||||
- name: Check the desktop file
|
- name: Check the desktop file
|
||||||
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
13
.vscode/launch.json
vendored
@ -6,7 +6,7 @@
|
|||||||
"configurations": [
|
"configurations": [
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": ".NET Core Launch (console)",
|
"name": ".NET Core Launch (console) Windows",
|
||||||
"type": "coreclr",
|
"type": "coreclr",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "build",
|
"preLaunchTask": "build",
|
||||||
@ -15,6 +15,17 @@
|
|||||||
"cwd": "${workspaceFolder}",
|
"cwd": "${workspaceFolder}",
|
||||||
"stopAtEntry": false,
|
"stopAtEntry": false,
|
||||||
"console": "internalConsole"
|
"console": "internalConsole"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": ".NET Core Launch (console) Linux",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build_linux",
|
||||||
|
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"console": "internalConsole"
|
||||||
}
|
}
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|||||||
17
.vscode/tasks.json
vendored
@ -37,6 +37,23 @@
|
|||||||
//"reveal": "silent"
|
//"reveal": "silent"
|
||||||
},
|
},
|
||||||
"problemMatcher": "$msCompile"
|
"problemMatcher": "$msCompile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build_linux",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj",
|
||||||
|
"-p:TargetFramework=net9.0",
|
||||||
|
"-p:TargetFrameworks=net9.0",
|
||||||
|
"-p:RuntimeIdentifier=linux-x64"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
//"reveal": "silent"
|
||||||
|
},
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
- [Custom File Naming](NamingTemplates.md)
|
- [Custom File Naming](NamingTemplates.md)
|
||||||
- [Command Line Interface](#command-line-interface)
|
- [Command Line Interface](#command-line-interface)
|
||||||
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
|
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
|
||||||
|
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
104
Documentation/AudioFileFormats.md
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
# Audio Formats Produced by Libation
|
||||||
|
|
||||||
|
Libation will download audio in a number of different audio formats, depending on the settings you choose within Libation and the per-title availability of audio formats from Audible. The Libation settings which affect the format downloaded by Libation are shown in the Settings menu screenshot below.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Audiobook file extensions are either `.m4b` or `.mp3`. Libation uses the `.m4b` file extension for all non-MP3 files, regardless of the audio codec contained therein. Some media players don't recognize the `.m4b` file extension and may require the extension be changed to `.m4a` or `.mp4`.
|
||||||
|
- Most (but not all) podcasts are delivered by Audible as native MP3 files. None of the following audio formats and settings discussions pertain to those podcasts because MP3s have no DRM, and those episodes are copied directly to their output folders.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Settings Summary
|
||||||
|
### Audio quality to request from Audible
|
||||||
|
Audiobooks can be requested from Audible as "Normal" quality or "High" quality, matching the settings in the Audible mobile apps. This setting affects the audio bitrate and, sometimes, the number of audio channels. This setting has no effect on the _audio codec_.
|
||||||
|
|
||||||
|
### Use Widevine DRM
|
||||||
|
When this setting is disabled, all audiobooks will be downloaded using Audible's in-house DRM (AAX(C)) in the [AAC-LC](#aac-lc) format.
|
||||||
|
When this setting is enabled, Libation will request audio files protected by Google's Widevine Digital Rights Managements scheme, and two additional settings will be unlocked: [Request xHE-AAC Codec](#request-xhe-aac-codec) and [Request Spatial Audio](#request-spatial-audio) (explained further below).
|
||||||
|
|
||||||
|
If you don't enable either of those additional options, then enabling 'Use Widevine DRM' will have no pratcical effect in nearly all circumstances. Audiobooks will be downloaded in the same [AAC-LC](#aac-lc) format with the same bitrate and the same number of audio channels. On rare occasions, enabling 'Use Widevine DRM' without the other two options will result in audio files with a different bitrate.
|
||||||
|
|
||||||
|
### Request xHE-AAC Codec
|
||||||
|
Enable this setting to request audiobooks in the [xHE-AAC](#xhe-aac) format. This codec is generally better quality than the [AAC-LC](#aac-lc) codec at the same bitrate, but it isn't as commonly supported by media players, so you may have some difficulty playing these audiobooks. The highest bitrate version of some audiobooks is only available as [xHE-AAC](#xhe-aac).
|
||||||
|
|
||||||
|
### Request Spatial Audio
|
||||||
|
Enable this setting to request audiobooks in a "spatial" ([Dolby Atmos](#dolby-atmos)) audio format. If an audiobook is not available in a spatial format, it will instead be downloaded in the [xHE-AAC codec](#xhe-aac).
|
||||||
|
|
||||||
|
### Spatial audio codec
|
||||||
|
Choose whether spatial audiobooks are downloaded in the [E-AC-3](#e-ac-3) or [AC-4](#ac-4) format.
|
||||||
|
|
||||||
|
### Download my books in the original audio format (Lossless)
|
||||||
|
If selected, Audiobooks will be downloaded and saved in the format delivered by audible (which depends on the settings explained above). Libation will not change the audio.
|
||||||
|
|
||||||
|
### Download my books as .MP3 files (transcode if necessary).
|
||||||
|
If selected, Libation will decode [AAC-LC](#aac-lc), [xHE-AAC](#xhe-aac), and [E-AC-3](#e-ac-3) audiobooks and re-encode them as MP3s using the MP3 encoder settings ([read about LAME MP3 encoder settings](https://lame.sourceforge.io/lame_ui_example.php)). Note that Libation cannot convert [AC-4](#ac-4) audio to MP3.
|
||||||
|
|
||||||
|
# Audio Formats
|
||||||
|
|
||||||
|
## Traditional Mono and Stereo Formats
|
||||||
|
|
||||||
|
### AAC-LC
|
||||||
|
#### _Full Name_
|
||||||
|
Advanced Audio Coding - Low Complexity
|
||||||
|
#### _Description_
|
||||||
|
This is the base profile for AAC audio and has existed since AAC's initial release in 1997. It enjoys wide support on nearly every conceivable platform capable of playing digital audio, as ubiquitous as MP3.
|
||||||
|
If Widevine support is not enabled, or if the book is not available in the more high-definition formats, Libation will download audiobooks in this format.
|
||||||
|
|
||||||
|
### MP3
|
||||||
|
#### _Full Name_
|
||||||
|
MPEG-1 Audio Layer III or MPEG-2 Audio Layer III
|
||||||
|
#### _Description_
|
||||||
|
An older (released in 1991) but still nearly universally supported audio codec. Its audio quality is generally worse than AAC-LC at similar bitrates. Audible delivers some podcasts in MP3 format, but no audiobooks are natively availble as MP3. Libation supports converting Audiobooks delivered in other audio formats to MP3. Note that the MP3 format supports a maximum of two audio channels, so multichannel E-AC-3 audio will be downsampled to stereo or mono (depending on the Libation's settings). [AC-4](#ac-4) cannot be converted to MP3.
|
||||||
|
|
||||||
|
### xHE-AAC
|
||||||
|
#### _Full Name_
|
||||||
|
Extended High-Efficiency Advanced Audio Coding
|
||||||
|
#### _Description_
|
||||||
|
This is a proprietary codec created by the [Fraunhofer Institute for Integrated Circuits IIS](https://www.iis.fraunhofer.de/en/ff/amm/broadcast-streaming/xheaac.html). It combines features of the HE-AAC v2 and the baseline USAC (Unified Speech and Audio Coding) profiles with the parts of the MPEG-D DRC Loudness Control Profile or Dynamic Range Control Profile. Therefore, USAC and xHE-AAC are not synonymous and should not be used interchangeably. A player capable of decoding USAC will not necessarily be able to decode xHE-AAC.
|
||||||
|
|
||||||
|
xHE-AAC boasts significantly higher quality audio at low bitrates. Though it has existed since at least 2016, playback support is still quite limited. FFmpeg has recently added partial decoder support for the USAC profiles, but it is insufficient to decode the xHE-AAC audio files acquired from Audible (due to FFmpeg's lack of support for MPEG Surround for Mono to Stereo Upmixing; ISO 23003-3:2012 §7.11)
|
||||||
|
|
||||||
|
Note that the xHE-AAC files authored by Audible have some USAC conformance errors including:
|
||||||
|
- Number of samples per frame not matching the UsacConfig coreCoderFrameLength value.
|
||||||
|
- Disagreement between stts and UsacFrame usacIndependencyFlag value.
|
||||||
|
- Stts indicating a frame is an immediate play-out frame, but USAC AudioPreRoll is absent.
|
||||||
|
|
||||||
|
## Dolby Atmos
|
||||||
|
Atmos is a surround sound technology that expands on existing surround sound systems by adding height channels as well as free-moving sound objects. Audible delivers Dolby Atmos in two formats: E-AC-3 and AC-4.
|
||||||
|
|
||||||
|
Your device's ability to play audio from these formats does not necessarily mean that the audio you are hearing is Atmos (spatial). For instance, downloading the AC-4 codec for Windows ([links in the [Supported media Players](#supported-media-players) section) will enable you to play AC-4 audiobooks, but you'll still need to download [Dolby Access](https://apps.microsoft.com/detail/9n0866fs04w8?hl=en-US&gl=US) and pay $15 to enable _Dolby Atmos For Headphones_. Please refer to [this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524) for additional context.
|
||||||
|
|
||||||
|
### E-AC-3
|
||||||
|
#### _Full Name_
|
||||||
|
Dolby Digital Plus (a.k.a Enhanced AC-3, DDP, DD+, and EC-3)
|
||||||
|
#### _Description_
|
||||||
|
A proprietary digital audio compression scheme developed by Dolby Digital for the transport and storage of multichannel audio. This format can be extended to add support for Atmos, making the codec _Dolby Digital Plus Atmos_. _Dolby Digital Plus Atmos_ is backwards compatible with Dolby Digital Plus, so any media player capable of playing Dolby Digital Plus can play _Dolby Digital Plus Atmos_. Audible spatial audiobooks downloaded in the E-AC-3 format are _Dolby Digital Plus Atmos_. If they are played by a media player that supports Atmos, they will play as Atmos audio. If they are played by a media player that does not support Atmos, they will be played as traditional 5.1 surround audio.
|
||||||
|
|
||||||
|
### AC-4
|
||||||
|
#### _Full Name_
|
||||||
|
Dolby AC-4
|
||||||
|
#### _Description_
|
||||||
|
A proprietary audio compression technology developed by Dolby Digital for the transport and storage of audio channels and/or audio objects. Audible spatial audiobooks downloaded in the AC-4 format are 2-channel AC-4 Immersive Stereo (AC4-IMS) audio, intended for playback in headphones or earbuds (though apparently [not supported on Apple devices](https://github.com/rmcrackan/Libation/issues/996#issuecomment-3169574514)).
|
||||||
|
|
||||||
|
# Supported Media Players
|
||||||
|
Below is an incomplete matrix of codec support across various media players and platforms.
|
||||||
|
| Player | [AAC-LC](#aac-lc) | [xHE-AAC](#xhe-aac) | [E-AC-3](#e-ac-3) | [AC-4](#ac-4) |
|
||||||
|
| :--- | :---: | :---: | :---: | :---: |
|
||||||
|
|Windows Native Support|Yes|Yes<sup>1</sup>|Yes<sup>2,3</sup>|Yes<sup>4</sup>|
|
||||||
|
|macOS Native Support|Yes|Yes|Yes<sup>3</sup>| |
|
||||||
|
|Android Native Support<sup>5</sup>|Yes|Yes| | |
|
||||||
|
|FFmpeg (all platforms)|Yes|Yes<sup>6</sup>|Yes<sup>3</sup>||
|
||||||
|
|[VLC](https://www.videolan.org/vlc/) (Windows)|Yes| |Yes<sup>3</sup> | |
|
||||||
|
|[foobar2000](https://www.foobar2000.org/components) (Windows and Mac)|Yes|Yes<sup>7</sup> | | |
|
||||||
|
|[PotPlayer](https://potplayer.daum.net/) (Windows)|Yes|Yes|Yes<sup>3</sup>| |
|
||||||
|
|[Samsung Media Player](https://play.google.com/store/apps/details?id=com.sec.android.app.music)<sup>8</sup> (Samsung devices) |Yes|Yes|Yes|Yes|
|
||||||
|
|
||||||
|
1. Windows 11 22H2 and later
|
||||||
|
2. On Windows [prior to Windows 11, version 24H2](https://support.microsoft.com/en-us/windows/codecs-in-media-player-d5c2cdcd-83a2-4805-abb0-c6888138e456). You can still get the codec by running the following command from a Windows PowerShell console: `winget install --id 9nvjqjbdkn97`
|
||||||
|
3. As mentioned in the [Dolby Atmos](#dolby-atmos) section, just because a media player can play a file does not mean it's rendering Atmos. _Dolby Digital Plus Atmos_ is backwards compatible with _Dolby Digital Plus_, so media players which only support _Dolby Digital Plus_ will play E-AC-3 audio files as regular 5.1 surround without rendering the Atmos spatial qualities. Additional software or hardware support may be required for Dolby Atmos playback.
|
||||||
|
4. You can download the AC-4 codec for Windows from 3rd party sites like [Major Geeks](https://www.majorgeeks.com/files/details/dolby_ac_3ac_4_installer.html) and [Free-Codecs](https://www.free-codecs.com/dolby-ac-4-decoder_download.htm). Once you install the codec bundle from one of those sources, the Windows store app will keep it updated. Read more about the process [in this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524).
|
||||||
|
5. All Android devices will support AAC-LC and xHE-AAC. Some manufactures (such as Samsung) will include Dolby codecs for playing E-AC-3 and AC-4 audio.
|
||||||
|
6. requires FFmpeg to be [built with fdk-aac](https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_aac). You will almost certainly not find pre-build binaries in the wild due to licensing restrictions.
|
||||||
|
7. Requires the [fdk-aac plugin](https://www.foobar2000.org/components/view/foo_pd_aac) (Windows only)
|
||||||
|
8. Requires audio file extensions to be `.m4a` or `.mp4`. Libation sets the file extensions to `.m4b`, so you must manually change it to `.m4a` by renaming the audio file.
|
||||||
|
|
||||||
@ -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.
|
||||||
@ -31,9 +35,19 @@ Self-hosting online:
|
|||||||
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
|
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
|
||||||
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
|
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
|
||||||
|
|
||||||
|
## Q: I'm having trouble playing my non-spatial audiobook, how can I fix this?
|
||||||
|
|
||||||
|
**A:** If you enabled the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings, then the audiobook is being downloaded in the [xHE-AAC codec](AudioFileFormats.md#xhe-aac) which isn't widely supported. You have two options:
|
||||||
|
1. Use a media player which supports the xHE-AAC codec. [See an incomplete list of media players which support xHE-AAC](AudioFileFormats.md#supported-media-players).
|
||||||
|
2. Disable the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings and re-download the audiobook. This will cause Libation to download audiobooks in the [AAC-LC codec](AudioFileFormats.md#aac-lc), which enjoys near-universal media player support.
|
||||||
|
|
||||||
|
## Q: I'm having trouble playing my book with 4D, spatial audio, or Dolby Atmos, how can I fix this?
|
||||||
|
|
||||||
|
**A:** Spatial audiobooks are delivered in two formats: [E-AC-3](AudioFileFormats.md#e-ac-3) and [AC-4](AudioFileFormats.md#ac-4). [See an incomplete list of media players which support those codecs](AudioFileFormats.md#supported-media-players).
|
||||||
|
|
||||||
## Q: I'm having trouble loggin into my Brazil account.
|
## Q: I'm having trouble loggin into my Brazil account.
|
||||||
|
|
||||||
For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
|
**A:** For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
|
||||||
|
|
||||||
## Q: How do I use Libation with a South Africa account?
|
## Q: How do I use Libation with a South Africa account?
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
|
|||||||
64
Documentation/LinuxDevelopmentSetupUsingNix.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Development Environment Setup using Nix or Nix Flakes on Linux x86_64
|
||||||
|
[Nix flakes](https://nixos.wiki/wiki/Flakes) can be used to provide version controlled reproducible and cross-platform development environments. The key files are:
|
||||||
|
- `flake.nix`: Defines the flake inputs and outputs, including development shells.
|
||||||
|
- `shell.nix`: This file defines the dependencies and additionally adds support for the Impure `nix-shell` method. This is used by the flake to create the dev environment.
|
||||||
|
- `flake.lock`: Locks the versions of inputs for reproducibility.
|
||||||
|
---
|
||||||
|
## Prerequisites
|
||||||
|
- [Nix](https://nixos.org/download.html) the package manager or NixOs installed on Linux (x86_64-linux)
|
||||||
|
- Optional: flakes support enabled.
|
||||||
|
---
|
||||||
|
## Using the Development Shell
|
||||||
|
You have two primary ways to enter the development shell with Nix:
|
||||||
|
### 1. Using `nix develop` (flake-native command)
|
||||||
|
This is the recommended way if you have Nix with flakes support. Flake guarantee the versions of the dependencies and can be controlled through `flake.nix` and `flake.lock`.
|
||||||
|
```
|
||||||
|
nix develop
|
||||||
|
```
|
||||||
|
This will open a shell with all dependencies and environment configured as per the `flake.nix` for (`x86_64-linux`) systems only at this time.
|
||||||
|
|
||||||
|
---
|
||||||
|
### 2. Using `nix-shell` (that's why shell.nix is a separate file)
|
||||||
|
If you want to use traditional `nix-shell` tooling which uses the nixpkgs version of your system:
|
||||||
|
```
|
||||||
|
nix-shell
|
||||||
|
```
|
||||||
|
This will drop you into the shell environment defined in `shell.nix`. Note that this is not flake-native method and does not use the locked nixpkgs in `flake.lock` so exact versions of the dependancies is not guaranteed.
|
||||||
|
|
||||||
|
---
|
||||||
|
## What’s inside the dev shell?
|
||||||
|
- The environment variables and packages configured in `shell.nix` will be available.
|
||||||
|
- The package set (`pkgs`) used aligns with the versions locked in `flake.lock` to ensure reproducibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
## Example Workflow using flakes
|
||||||
|
```
|
||||||
|
# Navigate to the project root folder which contains the flake.nix, flake.lock and shell.nix files.
|
||||||
|
cd /home/user/dev/Libation
|
||||||
|
# Enter the flake development shell (Linux x86_64)
|
||||||
|
nix develop
|
||||||
|
# run VSCode or VSCodium from the current shell environment
|
||||||
|
code .
|
||||||
|
# Run or Debug using VSCode and VSCodium using the linux Launch configuration.
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|
You can also Build and run your application inside the shell.
|
||||||
|
```
|
||||||
|
dotnet build ./Source/LibationAvalonia/LibationAvalonia.csproj -p:TargetFrameworks=net9.0 -p:TargetFramework=net9.0 -p:RuntimeIdentifier=linux-x64
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Leaving the current shell environemnt will drop all added dependancies and you will not be able to run or debug the program unless your system has those dependancies defined globally.
|
||||||
|
- To exit the shell environment voluntarily use `exit` inside the shell.
|
||||||
|
- Ensure you have no conflicting `nix.conf` or `global.json` that might affect SDK versions or runtime identifiers.
|
||||||
|
- Keep your `flake.lock` file committed to ensure builds are reproducible for all collaborators.
|
||||||
|
|
||||||
|
---
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Nix Flakes - NixOS Wiki](https://nixos.wiki/wiki/Flakes)
|
||||||
|
- [Nix.dev - Introduction to Nix flakes](https://nix.dev/manual/nix/2.28/command-ref/new-cli/nix3-flake-init)
|
||||||
|
- [Nix-shell Manual](https://nixos.org/manual/nix/stable/command-ref/nix-shell.html)
|
||||||
@ -46,9 +46,12 @@ These tags will be replaced in the template with the audiobook's values.
|
|||||||
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
||||||
|\<first series\>|First series|[Series](#series-formatters)|
|
|\<first series\>|First series|[Series](#series-formatters)|
|
||||||
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||||
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|
|\<channels\>|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)|
|
||||||
|
|\<codec\>|Audio codec of the last downloaded audiobook|[Text](#text-formatters)|
|
||||||
|
|\<file version\>|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)|
|
||||||
|
|\<libation version\>|Libation version used during last download of the audiobook|[Text](#text-formatters)|
|
||||||
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|
||||||
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||||
|\<locale\>|Region/country|[Text](#text-formatters)|
|
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||||
@ -78,17 +81,39 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
|
|||||||
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||||
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||||
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|
||||||
|
|\<has PROPERTY-\>...\<-has\>|Only include if the PROPERTY has a value (i.e. not null or empty)|Conditional|
|
||||||
|
|
||||||
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||||
|
|
||||||
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
||||||
|
|
||||||
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
|
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
|
||||||
|
|
||||||
|
|Inverted Tag|Description|Type|
|
||||||
|
|-|-|-|
|
||||||
|
|\<!if series-\>...\<-if series\>|Only include if *not* part of a book series or podcast|Conditional|
|
||||||
|
|\<!if podcast-\>...\<-if podcast\>|Only include if *not* part of a podcast|Conditional|
|
||||||
|
|\<!if bookseries-\>...\<-if bookseries\>|Only include if *not* part of a book series|Conditional|
|
||||||
|
|\<!if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is *not* a podcast series parent|Conditional|
|
||||||
|
|\<!has PROPERTY-\>...\<-has\>|Only include if the PROPERTY *does not* have a value (i.e. is null or empty)|Conditional|
|
||||||
|
|
||||||
|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||||
|
|
||||||
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
||||||
|
|
||||||
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
|
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast>\<title>`
|
||||||
|
|
||||||
|
This example will add a number if the `<series#\>` tag has a value:
|
||||||
|
|
||||||
|
`<has series#><series#><-has>`
|
||||||
|
|
||||||
|
This example will put non-series books in a "Standalones" folder:
|
||||||
|
|
||||||
|
`<!if series->Standalones/<-if series>`
|
||||||
|
|
||||||
|
And this example will customize the title based on whether the book has a subtitle:
|
||||||
|
|
||||||
|
`<audible title><has audible subtitle->-<audible subtitle><-has>`
|
||||||
|
|
||||||
# Tag Formatters
|
# Tag Formatters
|
||||||
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
||||||
@ -102,13 +127,13 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|
|||||||
## Series Formatters
|
## Series Formatters
|
||||||
|Formatter|Description|Example Usage|Example Result|
|
|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|
|
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N}, {ID}, {#:00.0}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>Sherlock Holmes, B08376S3R2, 01.0-06.0|
|
||||||
|
|
||||||
## Series List Formatters
|
## Series List Formatters
|
||||||
|Formatter|Description|Example Usage|Example Result|
|
|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|
|
|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|
|
|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>`<series[format({ID}-{N}, {#:00.0})]>`|Sherlock Holmes, 1-6; Book Collection, 1<hr>B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0|
|
||||||
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
|
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
|
||||||
|
|
||||||
## Name Formatters
|
## Name Formatters
|
||||||
|
|||||||
BIN
Documentation/images/AudioFormatSettings.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Documentation/images/StartingDebuggingInVSCode.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
@ -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)
|
||||||
@ -33,6 +34,7 @@
|
|||||||
- [Custom File Naming](Documentation/NamingTemplates.md)
|
- [Custom File Naming](Documentation/NamingTemplates.md)
|
||||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||||
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
|
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
|
||||||
|
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](Documentation/AudioFileFormats.md)
|
||||||
- [Docker](Documentation/Docker.md)
|
- [Docker](Documentation/Docker.md)
|
||||||
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="2.0.1" />
|
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.2" />
|
||||||
</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 Mp4File 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,32 +27,58 @@ 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()
|
private Mp4File Open()
|
||||||
{
|
{
|
||||||
if (DownloadOptions.InputType is FileType.Dash)
|
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 dash = new DashFile(InputFileStream);
|
||||||
dash.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
||||||
|
|
||||||
|
if (kidIndex == -1)
|
||||||
|
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
||||||
|
|
||||||
|
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;
|
return dash;
|
||||||
}
|
}
|
||||||
else if (DownloadOptions.InputType is FileType.Aax)
|
else if (DownloadOptions.InputType is FileType.Aax)
|
||||||
{
|
{
|
||||||
var aax = new AaxFile(InputFileStream);
|
var aax = new AaxFile(InputFileStream);
|
||||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey);
|
var key = keys[0].KeyPart1;
|
||||||
|
aax.SetDecryptionKey(keys[0].KeyPart1);
|
||||||
|
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
|
||||||
return aax;
|
return aax;
|
||||||
}
|
}
|
||||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||||
{
|
{
|
||||||
var aax = new AaxFile(InputFileStream);
|
var aax = new AaxFile(InputFileStream);
|
||||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
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;
|
return aax;
|
||||||
}
|
}
|
||||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
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()
|
||||||
@ -97,15 +126,15 @@ namespace AaxDecrypter
|
|||||||
if (DownloadOptions.SeriesName is string series)
|
if (DownloadOptions.SeriesName is string series)
|
||||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
|
||||||
|
|
||||||
if (DownloadOptions.SeriesNumber is float part)
|
if (DownloadOptions.SeriesNumber is string part)
|
||||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
OnInitialized();
|
||||||
|
|
||||||
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,17 +98,17 @@ 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),
|
||||||
@ -116,33 +117,32 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
|
|
||||||
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback 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.FromCompleted(AaxFile);
|
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; }
|
||||||
public 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,53 +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 &&
|
|
||||||
DownloadOptions.InputType is AAXClean.FileType fileType)
|
|
||||||
{
|
|
||||||
//Write aax decryption key
|
|
||||||
string keyPath = Path.ChangeExtension(tempFilePath, ".key");
|
|
||||||
FileUtility.SaferDelete(keyPath);
|
|
||||||
string aaxPath;
|
|
||||||
|
|
||||||
if (fileType is AAXClean.FileType.Aax)
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
|
||||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
|
||||||
}
|
|
||||||
else if (fileType is AAXClean.FileType.Aaxc)
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
|
||||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
|
|
||||||
}
|
|
||||||
else if (fileType is AAXClean.FileType.Dash)
|
|
||||||
{
|
|
||||||
await File.WriteAllTextAsync(keyPath, $"KeyId={DownloadOptions.AudibleKey}{Environment.NewLine}Key={DownloadOptions.AudibleIV}");
|
|
||||||
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
throw new InvalidOperationException($"Unknown file type: {fileType}");
|
|
||||||
|
|
||||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
|
||||||
|
|
||||||
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))
|
||||||
@ -238,8 +198,14 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
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()
|
||||||
|
|||||||
@ -1,40 +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; }
|
string? 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; }
|
public FileType? InputType { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,9 +61,6 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
#region Constants
|
#region Constants
|
||||||
|
|
||||||
//Size of each range request. Android app uses 64MB chunks.
|
|
||||||
private const int RANGE_REQUEST_SZ = 64 * 1024 * 1024;
|
|
||||||
|
|
||||||
//Download memory buffer size
|
//Download memory buffer size
|
||||||
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
||||||
|
|
||||||
@ -103,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);
|
||||||
@ -113,14 +116,16 @@ 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)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (DateTime.UtcNow > NextUpdateTime)
|
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
|
||||||
{
|
{
|
||||||
Updated?.Invoke(this, EventArgs.Empty);
|
Updated?.Invoke(this, EventArgs.Empty);
|
||||||
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
|
//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);
|
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -161,7 +166,7 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
//Initiate connection with the first request block and
|
//Initiate connection with the first request block and
|
||||||
//get the total content length before returning.
|
//get the total content length before returning.
|
||||||
using var client = new HttpClient();
|
var client = new HttpClient();
|
||||||
var response = await RequestNextByteRangeAsync(client);
|
var response = await RequestNextByteRangeAsync(client);
|
||||||
|
|
||||||
if (ContentLength != 0 && ContentLength != response.FileSize)
|
if (ContentLength != 0 && ContentLength != response.FileSize)
|
||||||
@ -170,38 +175,59 @@ namespace AaxDecrypter
|
|||||||
ContentLength = response.FileSize;
|
ContentLength = response.FileSize;
|
||||||
|
|
||||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||||
//Hand off the open request to the downloader to download and write data to file.
|
//Hand off the client and the open request to the downloader to download and write data to file.
|
||||||
DownloadTask = Task.Run(() => DownloadLoopInternal(response), _cancellationSource.Token);
|
DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DownloadLoopInternal(BlockResponse initialResponse)
|
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||||
{
|
{
|
||||||
await DownloadToFile(initialResponse);
|
|
||||||
initialResponse.Dispose();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var client = new HttpClient();
|
long startPosition = WritePosition;
|
||||||
|
|
||||||
while (WritePosition < ContentLength && !IsCancelled)
|
while (WritePosition < ContentLength && !IsCancelled)
|
||||||
{
|
{
|
||||||
using var response = await RequestNextByteRangeAsync(client);
|
try
|
||||||
await DownloadToFile(response);
|
{
|
||||||
|
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
|
finally
|
||||||
{
|
{
|
||||||
_writeFile.Close();
|
_writeFile.Dispose();
|
||||||
|
blockResponse.Dispose();
|
||||||
|
client.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
||||||
{
|
{
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||||
|
|
||||||
|
//Just in case it snuck in the saved json (Issue #1232)
|
||||||
|
RequestHeaders.Remove("Range");
|
||||||
|
|
||||||
foreach (var header in RequestHeaders)
|
foreach (var header in RequestHeaders)
|
||||||
request.Headers.Add(header.Key, header.Value);
|
request.Headers.Add(header.Key, header.Value);
|
||||||
|
|
||||||
request.Headers.Add("Range", $"bytes={WritePosition}-{WritePosition + RANGE_REQUEST_SZ - 1}");
|
request.Headers.Add("Range", $"bytes={WritePosition}-");
|
||||||
|
|
||||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||||
|
|
||||||
@ -226,7 +252,7 @@ namespace AaxDecrypter
|
|||||||
private async Task DownloadToFile(BlockResponse block)
|
private async Task DownloadToFile(BlockResponse block)
|
||||||
{
|
{
|
||||||
var endPosition = WritePosition + block.BlockSize;
|
var endPosition = WritePosition + block.BlockSize;
|
||||||
var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||||
|
|
||||||
var downloadPosition = WritePosition;
|
var downloadPosition = WritePosition;
|
||||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||||
@ -259,11 +285,11 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,9 +312,8 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
networkStream.Close();
|
|
||||||
_downloadedPiece.Set();
|
_downloadedPiece.Set();
|
||||||
OnUpdate();
|
OnUpdate(waitForWrite: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -385,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;
|
||||||
|
|||||||
17
Source/AaxDecrypter/TempFile.cs
Normal file
@ -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.3.0.1</Version>
|
<Version>12.5.3.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 = typeof(FileSinkHook).AssemblyQualifiedName;
|
||||||
|
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}"
|
||||||
@ -147,7 +158,8 @@ namespace AppScaffolding
|
|||||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||||
// {Properties:j} needed for expanded exception logging
|
// {Properties:j} needed for expanded exception logging
|
||||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
|
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
|
||||||
|
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -274,7 +286,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 +302,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 +333,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 +367,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)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.3" />
|
<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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,8 +5,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="9.4.0.1" />
|
<PackageReference Include="AudibleApi" Version="9.4.5.1" />
|
||||||
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
|
<PackageReference Include="Google.Protobuf" Version="3.32.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -55,7 +55,7 @@ public class WidevineKey
|
|||||||
Type = (KeyType)type;
|
Type = (KeyType)type;
|
||||||
Key = key;
|
Key = key;
|
||||||
}
|
}
|
||||||
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray()).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Cdm
|
public partial class Cdm
|
||||||
@ -192,7 +192,7 @@ public partial class Cdm
|
|||||||
id = id.Append(new byte[16 - id.Length]);
|
id = id.Append(new byte[16 - id.Length]);
|
||||||
}
|
}
|
||||||
|
|
||||||
keys[i] = new WidevineKey(new Guid(id), keyContainer.Type, keyBytes);
|
keys[i] = new WidevineKey(new Guid(id,bigEndian: true), keyContainer.Type, keyBytes);
|
||||||
}
|
}
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Numerics;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
@ -56,18 +57,99 @@ internal class Device
|
|||||||
|
|
||||||
public byte[] SignMessage(byte[] message)
|
public byte[] SignMessage(byte[] message)
|
||||||
{
|
{
|
||||||
using var sha1 = SHA1.Create();
|
var digestion = SHA1.HashData(message);
|
||||||
var digestion = sha1.ComputeHash(message);
|
return PssSha1Signer.SignHash(CdmKey, digestion);
|
||||||
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool VerifyMessage(byte[] message, byte[] signature)
|
public bool VerifyMessage(byte[] message, byte[] signature)
|
||||||
{
|
{
|
||||||
using var sha1 = SHA1.Create();
|
var digestion = SHA1.HashData(message);
|
||||||
var digestion = sha1.ComputeHash(message);
|
|
||||||
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] DecryptSessionKey(byte[] sessionKey)
|
public byte[] DecryptSessionKey(byte[] sessionKey)
|
||||||
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
|
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Completely managed implementation of RSASSA-PSS using SHA-1.
|
||||||
|
/// https://github.com/bcgit/bc-csharp/blob/master/crypto/src/crypto/signers/PssSigner.cs
|
||||||
|
///
|
||||||
|
/// Absolutely nobody anywhere should use this RSASSA-PSS implementation in anything where they care about security at all. We completely skipped the random salt part of it because libation doesn't need security; it only needs to satisfy Audible server's challenge-response requirements.
|
||||||
|
/// </summary>
|
||||||
|
private static class PssSha1Signer
|
||||||
|
{
|
||||||
|
private const int Sha1DigestSize = 20;
|
||||||
|
private const int Trailer = 0xBC;
|
||||||
|
|
||||||
|
public static byte[] SignHash(RSA rsa, ReadOnlySpan<byte> hash)
|
||||||
|
{
|
||||||
|
ArgumentOutOfRangeException.ThrowIfNotEqual(hash.Length, Sha1DigestSize);
|
||||||
|
|
||||||
|
var parameters = rsa.ExportParameters(true);
|
||||||
|
var Modulus = new BigInteger(parameters.Modulus, isUnsigned: true, isBigEndian: true);
|
||||||
|
var Exponent = new BigInteger(parameters.D, isUnsigned: true, isBigEndian: true);
|
||||||
|
var emBits = rsa.KeySize - 1;
|
||||||
|
var block = new byte[(emBits + 7) / 8];
|
||||||
|
var firstByteMask = (byte)(0xFFU >> ((block.Length * 8) - emBits));
|
||||||
|
|
||||||
|
Span<byte> mDash = new byte[8 + 2 * Sha1DigestSize];
|
||||||
|
|
||||||
|
hash.CopyTo(mDash.Slice(8));
|
||||||
|
var h = SHA1.HashData(mDash);
|
||||||
|
|
||||||
|
block[^(2 * (Sha1DigestSize + 1))] = 1;
|
||||||
|
byte[] dbMask = MaskGeneratorFunction1(h, 0, h.Length, block.Length - Sha1DigestSize - 1);
|
||||||
|
for (int i = 0; i != dbMask.Length; i++)
|
||||||
|
block[i] ^= dbMask[i];
|
||||||
|
|
||||||
|
h.CopyTo(block, block.Length - Sha1DigestSize - 1);
|
||||||
|
|
||||||
|
block[0] &= firstByteMask;
|
||||||
|
block[^1] = Trailer;
|
||||||
|
|
||||||
|
var input = new BigInteger(block, isUnsigned: true, isBigEndian: true);
|
||||||
|
var result = BigInteger.ModPow(input, Exponent, Modulus);
|
||||||
|
return result.ToByteArray(isUnsigned: true, isBigEndian: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] MaskGeneratorFunction1(byte[] Z, int zOff, int zLen, int length)
|
||||||
|
{
|
||||||
|
byte[] mask = new byte[length];
|
||||||
|
byte[] hashBuf = new byte[Sha1DigestSize];
|
||||||
|
byte[] C = new byte[4];
|
||||||
|
int counter = 0;
|
||||||
|
|
||||||
|
using var sha = SHA1.Create();
|
||||||
|
|
||||||
|
for (; counter < (length / Sha1DigestSize); counter++)
|
||||||
|
{
|
||||||
|
ItoOSP(counter, C);
|
||||||
|
|
||||||
|
sha.TransformBlock(Z, zOff, zLen, null, 0);
|
||||||
|
sha.TransformFinalBlock(C, 0, C.Length);
|
||||||
|
|
||||||
|
sha.Hash!.CopyTo(mask, counter * Sha1DigestSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((counter * Sha1DigestSize) < length)
|
||||||
|
{
|
||||||
|
ItoOSP(counter, C);
|
||||||
|
|
||||||
|
sha.TransformBlock(Z, zOff, zLen, null, 0);
|
||||||
|
sha.TransformFinalBlock(C, 0, C.Length);
|
||||||
|
|
||||||
|
Array.Copy(sha.Hash!, 0, mask, counter * Sha1DigestSize, mask.Length - (counter * Sha1DigestSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ItoOSP(int i, byte[] sp)
|
||||||
|
{
|
||||||
|
sp[0] = (byte)((uint)i >> 24);
|
||||||
|
sp[1] = (byte)((uint)i >> 16);
|
||||||
|
sp[2] = (byte)((uint)i >> 8);
|
||||||
|
sp[3] = (byte)((uint)i >> 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
70
Source/DataLayer/AudioFormat.cs
Normal file
@ -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);
|
||||||
|
|||||||
@ -10,14 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.3.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.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
|
||||||
<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.4" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.8">
|
||||||
<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 ?? IsSpatial;
|
||||||
DatePublished = datePublished ?? DatePublished;
|
DatePublished = datePublished ?? DatePublished;
|
||||||
Language = language?.FirstCharToUpper() ?? Language;
|
Language = language?.FirstCharToUpper() ?? Language;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
474
Source/DataLayer/Migrations/20250725074123_AddAudioFormatData.Designer.cs
generated
Normal file
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
// only library importing should use tracking. All else should be NoTracking.
|
// only library importing should use tracking. All else should be NoTracking.
|
||||||
@ -24,13 +25,13 @@ namespace DataLayer
|
|||||||
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
|
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||||
=> context
|
=> context
|
||||||
.LibraryBooks
|
.LibraryBooks
|
||||||
.AsNoTrackingWithIdentityResolution()
|
.AsNoTrackingWithIdentityResolution()
|
||||||
.GetLibraryBook(productId);
|
.GetLibraryBook(productId);
|
||||||
|
|
||||||
public static LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
|
public static LibraryBook? GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
|
||||||
=> library
|
=> library
|
||||||
.GetLibrary()
|
.GetLibrary()
|
||||||
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
|
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
|
||||||
@ -103,13 +104,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),
|
||||||
|
|||||||
@ -19,21 +19,24 @@ namespace FileLiberator
|
|||||||
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,6 +1,7 @@
|
|||||||
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;
|
using LibationFileManager.Templates;
|
||||||
@ -34,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
242
Source/FileLiberator/AudioFormatDecoder.cs
Normal file
@ -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,29 @@ 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.
|
//AAXClean.Codecs only supports decoding AAC and E-AC-3 audio.
|
||||||
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
|
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
|
||||||
@ -69,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,312 +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
|
||||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
{
|
||||||
{
|
OnCompleted(libraryBook);
|
||||||
var config = Configuration.Instance;
|
cancellationTokenSource.Dispose();
|
||||||
|
cancellationTokenSource = null;
|
||||||
downloadValidation(libraryBook);
|
}
|
||||||
|
|
||||||
var api = await libraryBook.GetApiAsync();
|
|
||||||
|
|
||||||
using var dlOptions = await DownloadOptions.InitiateDownloadAsync(api, libraryBook, config);
|
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
|
||||||
|
|
||||||
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
|
||||||
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 += Converter_RetrievedMetadata;
|
|
||||||
|
|
||||||
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 = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
|
|
||||||
|
|
||||||
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
|
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
|
|
||||||
|
|
||||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
|
||||||
OnFileCreated(libraryBook, metadataFile);
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags)
|
private record AudiobookDecryptResult(bool Success, List<TempFile> ResultFiles, List<TempFile> CacheFiles);
|
||||||
|
|
||||||
|
private async Task<AudiobookDecryptResult> DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
|
var outpoutDir = AudibleFileStorage.DecryptInProgressDirectory;
|
||||||
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
|
var result = new AudiobookDecryptResult(false, [], []);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
||||||
|
abDownloader = new UnencryptedAudiobookDownloader(outpoutDir, cacheDir, dlOptions);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AaxcDownloadConvertBase converter
|
||||||
|
= dlOptions.Config.SplitFilesByChapter && dlOptions.ChapterInfo.Count > 1 ?
|
||||||
|
new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) :
|
||||||
|
new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions);
|
||||||
|
|
||||||
|
if (dlOptions.Config.AllowLibationFixup)
|
||||||
|
converter.RetrievedMetadata += Converter_RetrievedMetadata;
|
||||||
|
|
||||||
|
abDownloader = converter;
|
||||||
|
}
|
||||||
|
|
||||||
|
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||||
|
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||||
|
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||||
|
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||||
|
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||||
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
|
abDownloader.TempFileCreated += AbDownloader_TempFileCreated;
|
||||||
|
|
||||||
|
// REAL WORK DONE HERE
|
||||||
|
bool success = await abDownloader.RunAsync();
|
||||||
|
return result with { Success = success };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (!cancellationToken.IsCancellationRequested)
|
||||||
|
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.
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
OnStreamingProgressChanged(new() { ProgressPercentage = 100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
void AbDownloader_TempFileCreated(object? sender, TempFile e)
|
||||||
|
{
|
||||||
|
if (Path.GetDirectoryName(e.FilePath) == outpoutDir)
|
||||||
|
{
|
||||||
|
result.ResultFiles.Add(e);
|
||||||
|
}
|
||||||
|
else if (Path.GetDirectoryName(e.FilePath) == cacheDir)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Decryptor event handlers
|
||||||
|
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;
|
return;
|
||||||
|
|
||||||
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
#region Prevent erroneous truncation due to incorrect chapter info
|
||||||
tags.Album ??= tags.Title;
|
|
||||||
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
//Sometimes the chapter info is not accurate. Since AAXClean trims audio
|
||||||
tags.AlbumArtists ??= tags.Artist;
|
//files to the chapters start and end, if the last chapter's end time is
|
||||||
|
//before the end of the audio file, the file will be truncated to match
|
||||||
|
//the chapter. This is never desirable, so pad the last chapter to match
|
||||||
|
//the original audio length.
|
||||||
|
|
||||||
|
var fileDuration = aaxFile.Duration;
|
||||||
|
if (options.Config.StripAudibleBrandAudio)
|
||||||
|
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||||
|
|
||||||
|
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
||||||
|
//Remove the last chapter and re-add it with the durationDelta that will
|
||||||
|
//make the chapter's end coincide with the end of the audio file.
|
||||||
|
var lastChapter = chapters[^1];
|
||||||
|
|
||||||
|
chapters.Remove(lastChapter);
|
||||||
|
options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
||||||
|
tags.Album ??= tags.Title;
|
||||||
|
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
||||||
|
tags.AlbumArtists ??= tags.Artist;
|
||||||
tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
|
tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
|
||||||
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||||
tags.Comment ??= options.LibraryBook.Book.Description;
|
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||||
tags.LongDescription ??= tags.Comment;
|
tags.LongDescription ??= tags.Comment;
|
||||||
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
||||||
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
|
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
|
||||||
tags.Asin = options.LibraryBook.Book.AudibleProductId;
|
tags.Asin = options.LibraryBook.Book.AudibleProductId;
|
||||||
tags.Acr = options.ContentMetadata.ContentReference.Acr;
|
tags.Acr = options.ContentMetadata.ContentReference.Acr;
|
||||||
tags.Version = options.ContentMetadata.ContentReference.Version;
|
tags.Version = options.ContentMetadata.ContentReference.Version;
|
||||||
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
|
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
|
||||||
{
|
{
|
||||||
tags.Year ??= pubDate.Year.ToString();
|
tags.Year ??= pubDate.Year.ToString();
|
||||||
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)
|
||||||
/// <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)
|
if (Configuration.Instance.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader)
|
||||||
{
|
{
|
||||||
// create final directory. move each file into it
|
try
|
||||||
var destinationDir = getDestinationDirectory(libraryBook);
|
{
|
||||||
|
e = OnRequestCoverArt();
|
||||||
for (var i = 0; i < entries.Count; i++)
|
downloader.SetCoverArt(e);
|
||||||
{
|
}
|
||||||
var entry = entries[i];
|
catch (Exception ex)
|
||||||
|
{
|
||||||
var realDest
|
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
|
||||||
= 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();
|
if (e is not null)
|
||||||
}
|
OnCoverImageDiscovered(e);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
private static string getDestinationDirectory(LibraryBook libraryBook)
|
#region Validation
|
||||||
{
|
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
private static void DownloadValidation(LibraryBook libraryBook)
|
||||||
if (!Directory.Exists(destinationDir))
|
{
|
||||||
Directory.CreateDirectory(destinationDir);
|
string errorString(string field)
|
||||||
return destinationDir;
|
=> $"{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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
/// <summary>Move new files to 'Books' directory</summary>
|
||||||
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
/// <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();
|
||||||
|
|
||||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
var totalSizeToMove = entries.Sum(f => new FileInfo(f.FilePath).Length);
|
||||||
{
|
long totalBytesMoved = 0;
|
||||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
|
||||||
|
|
||||||
var coverPath = "[null]";
|
for (var i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = entries[i];
|
||||||
|
|
||||||
try
|
var destFileName
|
||||||
{
|
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||||
var destinationDir = getDestinationDirectory(libraryBook);
|
libraryBook,
|
||||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
destinationDir,
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
entry.Extension,
|
||||||
|
entry.PartProperties,
|
||||||
|
Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
if (File.Exists(coverPath))
|
var realDest
|
||||||
FileUtility.SaferDelete(coverPath);
|
= FileUtility.SaferMoveToValidPath(
|
||||||
|
entry.FilePath,
|
||||||
|
destFileName,
|
||||||
|
Configuration.Instance.ReplacementCharacters,
|
||||||
|
entry.Extension,
|
||||||
|
Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
#region File Move Progress
|
||||||
if (picBytes.Length > 0)
|
totalBytesMoved += new FileInfo(realDest).Length;
|
||||||
{
|
averageSpeed.AddPosition(totalBytesMoved);
|
||||||
File.WriteAllBytes(coverPath, picBytes);
|
var estSecsRemaining = (totalSizeToMove - totalBytesMoved) / averageSpeed.Average;
|
||||||
SetFileTime(libraryBook, coverPath);
|
|
||||||
}
|
if (double.IsNormal(estSecsRemaining))
|
||||||
}
|
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
|
||||||
catch (Exception ex)
|
|
||||||
{
|
OnStreamingProgressChanged(new DownloadProgress
|
||||||
//Failure to download cover art should not be considered a failure to download the book
|
{
|
||||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
@ -17,168 +18,115 @@ namespace FileLiberator;
|
|||||||
|
|
||||||
public partial class DownloadOptions
|
public partial class DownloadOptions
|
||||||
{
|
{
|
||||||
private const string Ec3Codec = "ec+3";
|
|
||||||
private const string Ac4Codec = "ac-4";
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initiate an audiobook download from the audible api.
|
/// Initiate an audiobook download from the audible api.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, LibraryBook libraryBook, Configuration config)
|
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
|
||||||
{
|
{
|
||||||
var license = await ChooseContent(api, libraryBook, config);
|
var license = await ChooseContent(api, libraryBook, config, token);
|
||||||
var options = BuildDownloadOptions(libraryBook, config, license);
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
return options;
|
//Some audiobooks will have incorrect chapters in the metadata returned from the license request,
|
||||||
|
//but the metadata returned by the content metadata endpoint will be correct. Call the content
|
||||||
|
//metadata endpoint and use its chapters. Only replace the license request chapters if the total
|
||||||
|
//lengths match (defensive against different audio formats having slightly different lengths).
|
||||||
|
var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId);
|
||||||
|
if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs)
|
||||||
|
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return BuildDownloadOptions(libraryBook, config, license);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<ContentLicense> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
|
private class LicenseInfo
|
||||||
{
|
{
|
||||||
var cdm = await Cdm.GetCdmAsync();
|
public DrmType DrmType { get; }
|
||||||
|
public ContentMetadata ContentMetadata { get; set; }
|
||||||
|
public KeyData[]? DecryptionKeys { get; }
|
||||||
|
public LicenseInfo(ContentLicense license, IEnumerable<KeyData>? keys = null)
|
||||||
|
{
|
||||||
|
DrmType = license.DrmType;
|
||||||
|
ContentMetadata = license.ContentMetadata;
|
||||||
|
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyData[]? ToKeys(VoucherDtoV10? voucher)
|
||||||
|
=> voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||||
|
{
|
||||||
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||||
|
|
||||||
ContentLicense? contentLic = null;
|
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
|
||||||
ContentLicense? fallback = null;
|
|
||||||
|
|
||||||
if (cdm is null)
|
|
||||||
{
|
{
|
||||||
//Doesn't matter what the user chose. We can't get a CDM so we must fall back to AAX(C)
|
token.ThrowIfCancellationRequested();
|
||||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||||
}
|
return new LicenseInfo(license);
|
||||||
else
|
|
||||||
{
|
|
||||||
var spatial = config.FileDownloadQuality is Configuration.DownloadQuality.Spatial;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var codecChoice = config.SpatialAudioCodec switch
|
|
||||||
{
|
|
||||||
Configuration.SpatialCodec.EC_3 => Ec3Codec,
|
|
||||||
Configuration.SpatialCodec.AC_4 => Ac4Codec,
|
|
||||||
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
|
|
||||||
};
|
|
||||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality, ChapterTitlesType.Tree, DrmType.Widevine, spatial, codecChoice);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLic is null)
|
|
||||||
{
|
|
||||||
//We failed to get a widevine license, so fall back to AAX(C)
|
|
||||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
|
||||||
}
|
|
||||||
else if (!contentLic.ContentMetadata.ContentReference.IsSpatial && contentLic.DrmType != DrmType.Adrm)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
We got a widevine license and we have a Cdm, but we still need to decide if we WANT the file
|
|
||||||
being delivered with widevine. This file is not "spatial", so it may be no better than the
|
|
||||||
audio in the Adrm files. All else being equal, we prefer Adrm files because they have more
|
|
||||||
build-in metadata and always AAC-LC, which is a codec playable by pretty much every device
|
|
||||||
in existence.
|
|
||||||
|
|
||||||
Unfortunately, there appears to be no way to determine which codec/quality combination we'll
|
|
||||||
get until we make the request and see what content gets delivered. For some books,
|
|
||||||
Widevine/High delivers 44.1 kHz / 128 kbps audio and Adrm/High delivers 22.05 kHz / 64 kbps.
|
|
||||||
In those cases, the Widevine content size is much larger. Other books will deliver the same
|
|
||||||
sample rate / bitrate for both Widevine and Adrm, the only difference being codec. Widevine
|
|
||||||
is usually xHE-AAC, but is sometimes AAC-LC. Adrm is always AAC-LC.
|
|
||||||
|
|
||||||
To decide which file we want, use this simple rule: if files are different codecs and
|
|
||||||
Widevine is significantly larger, use Widevine. Otherwise use ADRM.
|
|
||||||
|
|
||||||
*/
|
|
||||||
fallback = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
|
||||||
|
|
||||||
var wvCr = contentLic.ContentMetadata.ContentReference;
|
|
||||||
var adrmCr = fallback.ContentMetadata.ContentReference;
|
|
||||||
|
|
||||||
if (wvCr.Codec == adrmCr.Codec ||
|
|
||||||
adrmCr.ContentSizeInBytes > wvCr.ContentSizeInBytes ||
|
|
||||||
RelativePercentDifference(adrmCr.ContentSizeInBytes, wvCr.ContentSizeInBytes) < 0.05)
|
|
||||||
{
|
|
||||||
contentLic = fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentLic.DrmType == DrmType.Widevine && cdm is not null)
|
token.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
try
|
//try to request a widevine content license using the user's audio settings
|
||||||
{
|
var aacCodecChoice = config.Request_xHE_AAC ? Codecs.xHE_AAC : Codecs.AAC_LC;
|
||||||
using var client = new HttpClient();
|
//Always use the ec+3 codec if converting to mp3
|
||||||
var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
|
var spatialCodecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 && !config.DecryptToLossy ? Codecs.AC_4 : Codecs.EC_3;
|
||||||
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
|
|
||||||
|
|
||||||
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
|
var contentLic
|
||||||
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
|
= await api.GetDownloadLicenseAsync(
|
||||||
|
libraryBook.Book.AudibleProductId,
|
||||||
|
dlQuality,
|
||||||
|
ChapterTitlesType.Tree,
|
||||||
|
DrmType.Widevine,
|
||||||
|
config.RequestSpatial,
|
||||||
|
aacCodecChoice,
|
||||||
|
spatialCodecChoice);
|
||||||
|
|
||||||
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
|
if (contentLic.DrmType is not DrmType.Widevine)
|
||||||
|
return new LicenseInfo(contentLic);
|
||||||
|
|
||||||
using var session = cdm.OpenSession();
|
using var client = new HttpClient();
|
||||||
var challenge = session.GetLicenseChallenge(dash);
|
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token);
|
||||||
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
|
var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
|
||||||
var keys = session.ParseLicense(licenseMessage);
|
|
||||||
contentLic.Voucher = new VoucherDtoV10()
|
|
||||||
{
|
|
||||||
Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()),
|
|
||||||
Iv = Convert.ToHexStringLower(keys[0].Key)
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
|
||||||
catch
|
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
|
||||||
{
|
|
||||||
if (fallback != null)
|
|
||||||
return fallback;
|
|
||||||
|
|
||||||
//We won't have a fallback if the requested license is for a spatial audio file.
|
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
|
||||||
//Throw so that the user is aware that spatial audio exists and that they were not able to download it.
|
|
||||||
throw;
|
using var session = cdm.OpenSession();
|
||||||
}
|
var challenge = session.GetLicenseChallenge(dash);
|
||||||
|
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
|
||||||
|
var keys = session.ParseLicense(licenseMessage);
|
||||||
|
return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
||||||
|
//We failed to get a widevine content license. Depending on the
|
||||||
|
//failure reason, users can potentially still download this audiobook
|
||||||
|
//by disabling the "Use Widevine DRM" feature.
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
return contentLic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||||
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
|
||||||
{
|
{
|
||||||
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
|
|
||||||
var outputFormat
|
|
||||||
= contentLic.DrmType is not DrmType.Adrm and not DrmType.Widevine ||
|
|
||||||
(config.AllowLibationFixup && config.DecryptToLossy && contentLic.ContentMetadata.ContentReference.Codec != "ac-4")
|
|
||||||
? OutputFormat.Mp3
|
|
||||||
: OutputFormat.M4b;
|
|
||||||
|
|
||||||
long chapterStartMs
|
long chapterStartMs
|
||||||
= config.StripAudibleBrandAudio
|
= config.StripAudibleBrandAudio
|
||||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
AAXClean.FileType? inputType
|
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
|
||||||
= contentLic.DrmType is DrmType.Widevine ? AAXClean.FileType.Dash
|
|
||||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 8 && contentLic.Voucher?.Iv == null ? AAXClean.FileType.Aax
|
|
||||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 32 && contentLic.Voucher?.Iv.Length == 32 ? AAXClean.FileType.Aaxc
|
|
||||||
: null;
|
|
||||||
|
|
||||||
//Set the requested AudioFormat for use in file naming templates
|
|
||||||
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
|
|
||||||
|
|
||||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic.ContentMetadata.ContentUrl?.OfflineUrl)
|
|
||||||
{
|
{
|
||||||
AudibleKey = contentLic.Voucher?.Key,
|
|
||||||
AudibleIV = contentLic.Voucher?.Iv,
|
|
||||||
InputType = inputType,
|
|
||||||
OutputFormat = outputFormat,
|
|
||||||
DrmType = contentLic.DrmType,
|
|
||||||
ContentMetadata = contentLic.ContentMetadata,
|
|
||||||
LameConfig = outputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null,
|
|
||||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||||
};
|
};
|
||||||
|
|
||||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||||
var chapters
|
var chapters
|
||||||
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||||
.OrderBy(c => c.StartOffsetMs)
|
.OrderBy(c => c.StartOffsetMs)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@ -194,7 +142,7 @@ public partial class DownloadOptions
|
|||||||
chapLenMs -= chapterStartMs;
|
chapLenMs -= chapterStartMs;
|
||||||
|
|
||||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||||
|
|
||||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||||
}
|
}
|
||||||
@ -317,7 +265,7 @@ public partial class DownloadOptions
|
|||||||
else if (titleConcat is null)
|
else if (titleConcat is null)
|
||||||
{
|
{
|
||||||
chaps.Add(c);
|
chaps.Add(c);
|
||||||
chaps.AddRange(flattenChapters(c.Chapters));
|
chaps.AddRange(flattenChapters(c.Chapters, titleConcat));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@ -330,7 +278,7 @@ public partial class DownloadOptions
|
|||||||
else
|
else
|
||||||
chaps.Add(c);
|
chaps.Add(c);
|
||||||
|
|
||||||
var children = flattenChapters(c.Chapters);
|
var children = flattenChapters(c.Chapters, titleConcat);
|
||||||
|
|
||||||
foreach (var child in children)
|
foreach (var child in children)
|
||||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||||
@ -356,7 +304,4 @@ public partial class DownloadOptions
|
|||||||
chapters.Remove(chapters[^1]);
|
chapters.Remove(chapters[^1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static double RelativePercentDifference(long num1, long num2)
|
|
||||||
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,88 +3,47 @@ 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;
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public partial 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.FirstSeries?.Name;
|
public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
|
||||||
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
public string? SeriesNumber => LibraryBookDto.FirstSeries?.Order?.ToString();
|
||||||
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 AAXClean.FileType? InputType { get; init; }
|
|
||||||
public AudibleApi.Common.DrmType DrmType { get; init; }
|
|
||||||
public AudibleApi.Common.ContentMetadata ContentMetadata { get; init; }
|
|
||||||
|
|
||||||
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; }
|
||||||
{
|
|
||||||
if (DownloadClipsBookmarks)
|
|
||||||
{
|
|
||||||
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 readonly IDisposable cancellation;
|
private readonly IDisposable cancellation;
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
@ -92,13 +51,36 @@ namespace FileLiberator
|
|||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private DownloadOptions(Configuration config, LibraryBook libraryBook, [System.Diagnostics.CodeAnalysis.NotNull] string downloadUrl)
|
private DownloadOptions(Configuration config, LibraryBook libraryBook, LicenseInfo licInfo)
|
||||||
{
|
{
|
||||||
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
Config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
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 != AudibleApi.Codecs.AC_4)
|
||||||
|
? 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 =
|
||||||
|
|||||||
@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
|||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
using LibationFileManager.Templates;
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
#nullable enable
|
#nullable enable
|
||||||
@ -20,9 +21,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,10 +62,13 @@ namespace FileLiberator
|
|||||||
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?.ToVersionString(),
|
||||||
|
FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,7 +83,7 @@ namespace FileLiberator
|
|||||||
.Select(sb
|
.Select(sb
|
||||||
=> new SeriesDto(
|
=> new SeriesDto(
|
||||||
sb.Series.Name,
|
sb.Series.Name,
|
||||||
sb.Book.IsEpisodeParent() ? null : sb.Index,
|
sb.Book.IsEpisodeParent() ? null : sb.Order,
|
||||||
sb.Series.AudibleSeriesId)
|
sb.Series.AudibleSeriesId)
|
||||||
).ToList();
|
).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.3.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'">
|
||||||
|
|||||||
74
Source/FileManager/FileSystemTest.cs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace FileManager
|
||||||
|
{
|
||||||
|
public static class FileSystemTest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Additional characters which are illegal for filenames in Windows environments.
|
||||||
|
/// Double quotes and slashes are already illegal filename characters on all platforms,
|
||||||
|
/// so they are not included here.
|
||||||
|
/// </summary>
|
||||||
|
public static string AdditionalInvalidWindowsFilenameCharacters { get; } = "<>|:*?";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, <, >, |).
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanWriteWindowsInvalidChars(LongPath directoryName)
|
||||||
|
{
|
||||||
|
var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString());
|
||||||
|
return CanWriteFile(testFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test if the directory supports filenames with 255 unicode characters.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanWrite255UnicodeChars(LongPath directoryName)
|
||||||
|
{
|
||||||
|
const char unicodeChar = 'ü';
|
||||||
|
var testFileName = new string(unicodeChar, 255);
|
||||||
|
var testFile = Path.Combine(directoryName, testFileName);
|
||||||
|
return CanWriteFile(testFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test if a directory has write access by attempting to create an empty file in it.
|
||||||
|
/// <para/>Returns true even if the temporary file can not be deleted.
|
||||||
|
/// </summary>
|
||||||
|
public static bool CanWriteDirectory(LongPath directoryName)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directoryName))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Serilog.Log.Logger.Debug("Testing write permissions for directory: {@DirectoryName}", directoryName);
|
||||||
|
var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString());
|
||||||
|
return CanWriteFile(testFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CanWriteFile(LongPath filename)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Debug("Testing ability to write filename: {@filename}", filename);
|
||||||
|
File.WriteAllBytes(filename, []);
|
||||||
|
Serilog.Log.Logger.Debug("Deleting test file after successful write: {@filename}", filename);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
FileUtility.SaferDelete(filename);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//An error deleting the file doesn't constitute a write failure.
|
||||||
|
Serilog.Log.Logger.Debug(ex, "Error deleting test file: {@filename}", filename);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Debug(ex, "Error writing test file: {@filename}", filename);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,7 +56,7 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(name, nameof(name));
|
ArgumentValidator.EnsureNotNull(name, nameof(name));
|
||||||
|
|
||||||
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
|
name = ReplacementCharacters.Barebones(true).ReplaceFilenameChars(name);
|
||||||
return Task.Run(() => AddFileInternal(name, contents.Span, comment));
|
return Task.Run(() => AddFileInternal(name, contents.Span, comment));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,8 @@ internal interface IClosingPropertyTag : IPropertyTag
|
|||||||
bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
|
bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public delegate bool Conditional<T>(ITemplateTag templateTag, T value, string condition);
|
||||||
|
|
||||||
public class ConditionalTagCollection<TClass> : TagCollection
|
public class ConditionalTagCollection<TClass> : TagCollection
|
||||||
{
|
{
|
||||||
public ConditionalTagCollection(bool caseSensative = true) :base(typeof(TClass), caseSensative) { }
|
public ConditionalTagCollection(bool caseSensative = true) :base(typeof(TClass), caseSensative) { }
|
||||||
@ -32,21 +34,49 @@ public class ConditionalTagCollection<TClass> : TagCollection
|
|||||||
/// <param name="propertyGetter">A Func to get the condition's <see cref="bool"/> value from <see cref="TClass"/></param>
|
/// <param name="propertyGetter">A Func to get the condition's <see cref="bool"/> value from <see cref="TClass"/></param>
|
||||||
public void Add(ITemplateTag templateTag, Func<TClass, bool> propertyGetter)
|
public void Add(ITemplateTag templateTag, Func<TClass, bool> propertyGetter)
|
||||||
{
|
{
|
||||||
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
|
var target = propertyGetter.Target is null ? null : Expression.Constant(propertyGetter.Target);
|
||||||
|
var expr = Expression.Call(target, propertyGetter.Method, Parameter);
|
||||||
AddPropertyTag(new ConditionalTag(templateTag, Options, expr));
|
AddPropertyTag(new ConditionalTag(templateTag, Options, expr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a conditional tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="conditional">A <see cref="Conditional{TClass}"/> to get the condition's <see cref="bool"/> value</param>
|
||||||
|
public void Add(ITemplateTag templateTag, Conditional<TClass> conditional)
|
||||||
|
{
|
||||||
|
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, conditional));
|
||||||
|
}
|
||||||
|
|
||||||
private class ConditionalTag : TagBase, IClosingPropertyTag
|
private class ConditionalTag : TagBase, IClosingPropertyTag
|
||||||
{
|
{
|
||||||
public override Regex NameMatcher { get; }
|
public override Regex NameMatcher { get; }
|
||||||
public Regex NameCloseMatcher { get; }
|
public Regex NameCloseMatcher { get; }
|
||||||
|
|
||||||
|
private Func<string?, Expression> CreateConditionExpression { get; }
|
||||||
|
|
||||||
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
|
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
|
||||||
: base(templateTag, conditionExpression)
|
: base(templateTag, conditionExpression)
|
||||||
{
|
{
|
||||||
NameMatcher = new Regex($"^<(!)?{templateTag.TagName}->", options);
|
NameMatcher = new Regex(@$"^<(!)?{templateTag.TagName}->", options);
|
||||||
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||||
|
CreateConditionExpression = _ => conditionExpression;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, Conditional<TClass> conditional)
|
||||||
|
: base(templateTag, Expression.Constant(false))
|
||||||
|
{
|
||||||
|
NameMatcher = new Regex(@$"^<(!)?{templateTag.TagName}(?:\s+?(.*?)\s*?)?->", options);
|
||||||
|
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||||
|
|
||||||
|
var target = conditional.Target is null ? null : Expression.Constant(conditional.Target);
|
||||||
|
CreateConditionExpression = condition
|
||||||
|
=> Expression.Call(
|
||||||
|
conditional.Target is null ? null : Expression.Constant(conditional.Target),
|
||||||
|
conditional.Method,
|
||||||
|
Expression.Constant(templateTag),
|
||||||
|
parameter,
|
||||||
|
Expression.Constant(condition));
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
|
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
|
||||||
@ -64,6 +94,13 @@ public class ConditionalTagCollection<TClass> : TagCollection
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ValueExpression) : ValueExpression;
|
protected override Expression GetTagExpression(string exactName, string[] extraData)
|
||||||
|
{
|
||||||
|
if (extraData.Length is not (1 or 2) || extraData[0] is not ("!" or "") || extraData.Length == 2 && string.IsNullOrWhiteSpace(extraData[1]))
|
||||||
|
return Expression.Constant(false);
|
||||||
|
|
||||||
|
var getBool = extraData.Length == 2 ? CreateConditionExpression(extraData[1]) : CreateConditionExpression(null);
|
||||||
|
return extraData[0] == "!" ? Expression.Not(getBool) : getBool;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ public class NamingTemplate
|
|||||||
/// Invoke the <see cref="NamingTemplate"/>
|
/// Invoke the <see cref="NamingTemplate"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
|
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
|
||||||
public TemplatePart Evaluate(params object[] propertyClasses)
|
public TemplatePart Evaluate(params object?[] propertyClasses)
|
||||||
{
|
{
|
||||||
if (templateToString is null)
|
if (templateToString is null)
|
||||||
throw new InvalidOperationException();
|
throw new InvalidOperationException();
|
||||||
@ -38,8 +38,8 @@ public class NamingTemplate
|
|||||||
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
|
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
|
||||||
// First parameter is "this", so ignore it.
|
// First parameter is "this", so ignore it.
|
||||||
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
|
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
|
||||||
|
|
||||||
object[] args = delegateArgTypes.Join(propertyClasses, o => o.ParameterType, i => i.GetType(), (_, i) => i).ToArray();
|
object?[] args = delegateArgTypes.Join(propertyClasses, o => o.ParameterType, i => i?.GetType(), (_, i) => i).ToArray();
|
||||||
|
|
||||||
if (args.Length != delegateArgTypes.Count())
|
if (args.Length != delegateArgTypes.Count())
|
||||||
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
|
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
@ -109,6 +110,25 @@ public class PropertyTagCollection<TClass> : TagCollection
|
|||||||
catch { return null; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Try to get the default (unformatted) value of a property tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tagName">Name of the tag value to get</param>
|
||||||
|
/// <param name="object">The property class from which the tag's value is read</param>
|
||||||
|
/// <param name="value"><paramref name="tagName"/>'s string value if it is in this collection, otherwise null</param>
|
||||||
|
/// <returns>True if the <paramref name="tagName"/> is in this collection, otherwise false</returns>
|
||||||
|
public bool TryGetValue(string tagName, TClass @object, [NotNullWhen(true)] out string? value)
|
||||||
|
{
|
||||||
|
value = null;
|
||||||
|
|
||||||
|
if (!StartsWith($"<{tagName}>", out var exactName, out var propertyTag, out var valueExpression))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var func = Expression.Lambda<Func<TClass, string>>(valueExpression, Parameter).Compile();
|
||||||
|
value = func(@object);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private class PropertyTag<TPropertyValue> : TagBase
|
private class PropertyTag<TPropertyValue> : TagBase
|
||||||
{
|
{
|
||||||
public override Regex NameMatcher { get; }
|
public override Regex NameMatcher { get; }
|
||||||
@ -138,8 +158,13 @@ public class PropertyTagCollection<TClass> : TagCollection
|
|||||||
expVal);
|
expVal);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Expression GetTagExpression(string exactName, string formatString)
|
protected override Expression GetTagExpression(string exactName, string[] extraData)
|
||||||
{
|
{
|
||||||
|
if (extraData.Length is not (0 or 1))
|
||||||
|
return Expression.Constant(exactName);
|
||||||
|
|
||||||
|
string formatString = extraData.Length == 1 ? extraData[0] : "";
|
||||||
|
|
||||||
Expression toStringExpression
|
Expression toStringExpression
|
||||||
= !ReturnType.IsValueType
|
= !ReturnType.IsValueType
|
||||||
? Expression.Condition(
|
? Expression.Condition(
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
@ -42,8 +43,8 @@ internal abstract class TagBase : IPropertyTag
|
|||||||
|
|
||||||
/// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary>
|
/// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary>
|
||||||
/// <param name="exactName">The exact string that was matched to <see cref="ITemplateTag"/></param>
|
/// <param name="exactName">The exact string that was matched to <see cref="ITemplateTag"/></param>
|
||||||
/// <param name="formatter">The optional format string in the match inside the square brackets</param>
|
/// <param name="extraData">Optional extra data parsed from the tag, such as a format string in the match the square brackets, logical negation, and conditional options</param>
|
||||||
protected abstract Expression GetTagExpression(string exactName, string formatter);
|
protected abstract Expression GetTagExpression(string exactName, string[] extraData);
|
||||||
|
|
||||||
public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
|
public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
|
||||||
{
|
{
|
||||||
@ -51,7 +52,7 @@ internal abstract class TagBase : IPropertyTag
|
|||||||
if (match.Success)
|
if (match.Success)
|
||||||
{
|
{
|
||||||
exactName = match.Value;
|
exactName = match.Value;
|
||||||
propertyValue = GetTagExpression(exactName, match.Groups.Count == 2 ? match.Groups[1].Value.Trim() : "");
|
propertyValue = GetTagExpression(exactName, match.Groups.Values.Skip(1).Select(v => v.Value.Trim()).ToArray());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
|
|||||||
/// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagCollection"/>'s TClass type.</summary>
|
/// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagCollection"/>'s TClass type.</summary>
|
||||||
internal ParameterExpression Parameter { get; }
|
internal ParameterExpression Parameter { get; }
|
||||||
protected RegexOptions Options { get; } = RegexOptions.Compiled;
|
protected RegexOptions Options { get; } = RegexOptions.Compiled;
|
||||||
private List<IPropertyTag> PropertyTags { get; } = new();
|
internal List<IPropertyTag> PropertyTags { get; } = new();
|
||||||
|
|
||||||
protected TagCollection(Type classType, bool caseSensative = true)
|
protected TagCollection(Type classType, bool caseSensative = true)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -74,12 +74,14 @@ namespace FileManager
|
|||||||
}
|
}
|
||||||
public override int GetHashCode() => Replacements.GetHashCode();
|
public override int GetHashCode() => Replacements.GetHashCode();
|
||||||
|
|
||||||
public static readonly ReplacementCharacters Default
|
public static ReplacementCharacters Default(bool ntfs) => ntfs ? HiFi_NTFS : HiFi_Other;
|
||||||
= IsWindows
|
public static ReplacementCharacters LoFiDefault(bool ntfs) => ntfs ? LoFi_NTFS : LoFi_Other;
|
||||||
? new()
|
public static ReplacementCharacters Barebones(bool ntfs) => ntfs ? BareBones_NTFS : BareBones_Other;
|
||||||
{
|
|
||||||
Replacements = new Replacement[]
|
#region Defaults
|
||||||
{
|
private static readonly ReplacementCharacters HiFi_NTFS = new()
|
||||||
|
{
|
||||||
|
Replacements = [
|
||||||
Replacement.OtherInvalid("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.FilenameForwardSlash("∕"),
|
Replacement.FilenameForwardSlash("∕"),
|
||||||
Replacement.FilenameBackSlash(""),
|
Replacement.FilenameBackSlash(""),
|
||||||
@ -91,28 +93,23 @@ namespace FileManager
|
|||||||
Replacement.Colon("_"),
|
Replacement.Colon("_"),
|
||||||
Replacement.Asterisk("✱"),
|
Replacement.Asterisk("✱"),
|
||||||
Replacement.QuestionMark("?"),
|
Replacement.QuestionMark("?"),
|
||||||
Replacement.Pipe("⏐"),
|
Replacement.Pipe("⏐")]
|
||||||
}
|
};
|
||||||
}
|
|
||||||
: new()
|
private static readonly ReplacementCharacters HiFi_Other = new()
|
||||||
{
|
{
|
||||||
Replacements = new Replacement[]
|
Replacements = [
|
||||||
{
|
|
||||||
Replacement.OtherInvalid("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.FilenameForwardSlash("∕"),
|
Replacement.FilenameForwardSlash("∕"),
|
||||||
Replacement.FilenameBackSlash("\\"),
|
Replacement.FilenameBackSlash("\\"),
|
||||||
Replacement.OpenQuote("“"),
|
Replacement.OpenQuote("“"),
|
||||||
Replacement.CloseQuote("”"),
|
Replacement.CloseQuote("”"),
|
||||||
Replacement.OtherQuote("\"")
|
Replacement.OtherQuote("\"")]
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
public static readonly ReplacementCharacters LoFiDefault
|
private static readonly ReplacementCharacters LoFi_NTFS = new()
|
||||||
= IsWindows
|
{
|
||||||
? new()
|
Replacements = [
|
||||||
{
|
|
||||||
Replacements = new Replacement[]
|
|
||||||
{
|
|
||||||
Replacement.OtherInvalid("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.FilenameForwardSlash("_"),
|
Replacement.FilenameForwardSlash("_"),
|
||||||
Replacement.FilenameBackSlash("_"),
|
Replacement.FilenameBackSlash("_"),
|
||||||
@ -121,56 +118,54 @@ namespace FileManager
|
|||||||
Replacement.OtherQuote("'"),
|
Replacement.OtherQuote("'"),
|
||||||
Replacement.OpenAngleBracket("{"),
|
Replacement.OpenAngleBracket("{"),
|
||||||
Replacement.CloseAngleBracket("}"),
|
Replacement.CloseAngleBracket("}"),
|
||||||
Replacement.Colon("-"),
|
Replacement.Colon("-")]
|
||||||
}
|
};
|
||||||
}
|
|
||||||
: new ()
|
private static readonly ReplacementCharacters LoFi_Other = new()
|
||||||
{
|
{
|
||||||
Replacements = new Replacement[]
|
Replacements = [
|
||||||
{
|
|
||||||
Replacement.OtherInvalid("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.FilenameForwardSlash("_"),
|
Replacement.FilenameForwardSlash("_"),
|
||||||
Replacement.FilenameBackSlash("\\"),
|
Replacement.FilenameBackSlash("\\"),
|
||||||
Replacement.OpenQuote("\""),
|
Replacement.OpenQuote("\""),
|
||||||
Replacement.CloseQuote("\""),
|
Replacement.CloseQuote("\""),
|
||||||
Replacement.OtherQuote("\"")
|
Replacement.OtherQuote("\"")]
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
public static readonly ReplacementCharacters Barebones
|
private static readonly ReplacementCharacters BareBones_NTFS = new()
|
||||||
= IsWindows
|
{
|
||||||
? new ()
|
Replacements = [
|
||||||
{
|
|
||||||
Replacements = new Replacement[]
|
|
||||||
{
|
|
||||||
Replacement.OtherInvalid("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.FilenameForwardSlash("_"),
|
Replacement.FilenameForwardSlash("_"),
|
||||||
Replacement.FilenameBackSlash("_"),
|
Replacement.FilenameBackSlash("_"),
|
||||||
Replacement.OpenQuote("_"),
|
Replacement.OpenQuote("_"),
|
||||||
Replacement.CloseQuote("_"),
|
Replacement.CloseQuote("_"),
|
||||||
Replacement.OtherQuote("_")
|
Replacement.OtherQuote("_")]
|
||||||
}
|
};
|
||||||
}
|
|
||||||
: new ()
|
private static readonly ReplacementCharacters BareBones_Other = new()
|
||||||
{
|
{
|
||||||
Replacements = new Replacement[]
|
Replacements = [
|
||||||
{
|
|
||||||
Replacement.OtherInvalid("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.FilenameForwardSlash("_"),
|
Replacement.FilenameForwardSlash("_"),
|
||||||
Replacement.FilenameBackSlash("\\"),
|
Replacement.FilenameBackSlash("\\"),
|
||||||
Replacement.OpenQuote("\""),
|
Replacement.OpenQuote("\""),
|
||||||
Replacement.CloseQuote("\""),
|
Replacement.CloseQuote("\""),
|
||||||
Replacement.OtherQuote("\"")
|
Replacement.OtherQuote("\"")]
|
||||||
}
|
};
|
||||||
};
|
#endregion
|
||||||
|
/// <summary>
|
||||||
|
/// Characters to consider invalid in filenames in addition to those returned by <see cref="Path.GetInvalidFileNameChars()"/>
|
||||||
|
/// </summary>
|
||||||
|
public static char[] AdditionalInvalidFilenameCharacters { get; set; } = [];
|
||||||
|
|
||||||
private static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT;
|
internal static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT;
|
||||||
|
|
||||||
private static readonly char[] invalidPathChars = Path.GetInvalidFileNameChars().Except(new[] {
|
private static char[] invalidPathChars { get; } = Path.GetInvalidFileNameChars().Except(new[] {
|
||||||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
private static readonly char[] invalidSlashes = Path.GetInvalidFileNameChars().Intersect(new[] {
|
private static char[] invalidSlashes { get; } = Path.GetInvalidFileNameChars().Intersect(new[] {
|
||||||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
@ -229,8 +224,11 @@ namespace FileManager
|
|||||||
return DefaultReplacement;
|
return DefaultReplacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool CharIsPathInvalid(char c)
|
||||||
|
=> invalidPathChars.Contains(c) || AdditionalInvalidFilenameCharacters.Contains(c);
|
||||||
|
|
||||||
public static bool ContainsInvalidPathChar(string path)
|
public static bool ContainsInvalidPathChar(string path)
|
||||||
=> path.Any(c => invalidPathChars.Contains(c));
|
=> path.Any(CharIsPathInvalid);
|
||||||
public static bool ContainsInvalidFilenameChar(string path)
|
public static bool ContainsInvalidFilenameChar(string path)
|
||||||
=> ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c));
|
=> ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c));
|
||||||
|
|
||||||
@ -242,7 +240,7 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
var c = fileName[i];
|
var c = fileName[i];
|
||||||
|
|
||||||
if (invalidPathChars.Contains(c)
|
if (CharIsPathInvalid(c)
|
||||||
|| invalidSlashes.Contains(c)
|
|| invalidSlashes.Contains(c)
|
||||||
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
|
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
|
||||||
{
|
{
|
||||||
@ -267,14 +265,14 @@ namespace FileManager
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
invalidPathChars.Contains(c)
|
CharIsPathInvalid(c)
|
||||||
|| ( // Replace any other legal characters that they user wants.
|
|| ( // Replace any other legal characters that they user wants.
|
||||||
c != Path.DirectorySeparatorChar
|
c != Path.DirectorySeparatorChar
|
||||||
&& c != Path.AltDirectorySeparatorChar
|
&& c != Path.AltDirectorySeparatorChar
|
||||||
&& Replacements.Any(r => r.CharacterToReplace == c)
|
&& Replacements.Any(r => r.CharacterToReplace == c)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
&& !( // replace all colons except drive letter designator on Windows
|
&& !( // replace all colons except drive letter designator on Windows
|
||||||
c == ':'
|
c == ':'
|
||||||
&& i == 1
|
&& i == 1
|
||||||
&& Path.IsPathRooted(pathStr)
|
&& Path.IsPathRooted(pathStr)
|
||||||
@ -282,9 +280,9 @@ namespace FileManager
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
char preceding = i > 0 ? pathStr[i - 1] : default;
|
char preceding = i > 0 ? pathStr[i - 1] : default;
|
||||||
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
||||||
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
builder.Append(c);
|
builder.Append(c);
|
||||||
@ -301,23 +299,21 @@ namespace FileManager
|
|||||||
|
|
||||||
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
|
||||||
{
|
{
|
||||||
|
var defaults = ReplacementCharacters.Default(ReplacementCharacters.IsWindows).Replacements;
|
||||||
|
|
||||||
var jObj = JObject.Load(reader);
|
var jObj = JObject.Load(reader);
|
||||||
var replaceArr = jObj[nameof(Replacement)];
|
var replaceArr = jObj[nameof(Replacement)];
|
||||||
var dict
|
var dict = replaceArr?.ToObject<Replacement[]>()?.ToList() ?? defaults;
|
||||||
= replaceArr?.ToObject<Replacement[]>()?.ToList()
|
|
||||||
?? ReplacementCharacters.Default.Replacements;
|
|
||||||
|
|
||||||
|
|
||||||
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
|
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
|
||||||
//If not, reset to default.
|
//If not, reset to default.
|
||||||
|
|
||||||
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
|
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
|
||||||
{
|
{
|
||||||
if (dict.Count < Replacement.FIXED_COUNT
|
if (dict.Count < Replacement.FIXED_COUNT
|
||||||
|| dict[i].CharacterToReplace != ReplacementCharacters.Barebones.Replacements[i].CharacterToReplace
|
|| dict[i].CharacterToReplace != defaults[i].CharacterToReplace
|
||||||
|| dict[i].Description != ReplacementCharacters.Barebones.Replacements[i].Description)
|
|| dict[i].Description != defaults[i].Description)
|
||||||
{
|
{
|
||||||
dict = ReplacementCharacters.Default.Replacements;
|
dict = defaults;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.8" />
|
<PackageReference Include="Avalonia" Version="11.3.3" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.8" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.3" />
|
||||||
<!--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.8" />
|
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.3" />
|
||||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.8" />
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.3" />
|
||||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.3" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||||
|
|||||||
11
Source/HangoverAvalonia/ViewModels/CheckBoxViewModel.cs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.ViewModels;
|
||||||
|
|
||||||
|
public class CheckBoxViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private bool _isChecked;
|
||||||
|
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
|
||||||
|
private object _bookText;
|
||||||
|
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
|
||||||
|
}
|
||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
117
Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
using ApplicationServices;
|
||||||
|
using Avalonia.Collections;
|
||||||
|
using DataLayer;
|
||||||
|
using ReactiveUI;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.ViewModels;
|
||||||
|
|
||||||
|
public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
|
||||||
|
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
|
||||||
|
|
||||||
|
private bool _controlsEnabled = true;
|
||||||
|
public bool ControlsEnabled { get => _controlsEnabled; set => this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
|
||||||
|
|
||||||
|
private bool? everythingChecked = false;
|
||||||
|
public bool? EverythingChecked
|
||||||
|
{
|
||||||
|
get => everythingChecked;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
everythingChecked = value ?? false;
|
||||||
|
|
||||||
|
if (everythingChecked is true)
|
||||||
|
CheckAll();
|
||||||
|
else if (everythingChecked is false)
|
||||||
|
UncheckAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _totalBooksCount = 0;
|
||||||
|
private int _checkedBooksCount = -1;
|
||||||
|
public int CheckedBooksCount
|
||||||
|
{
|
||||||
|
get => _checkedBooksCount;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_checkedBooksCount = value;
|
||||||
|
this.RaisePropertyChanged(nameof(CheckedCountText));
|
||||||
|
|
||||||
|
everythingChecked
|
||||||
|
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
|
||||||
|
: _checkedBooksCount == _totalBooksCount ? true
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.RaisePropertyChanged(nameof(EverythingChecked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
|
||||||
|
|
||||||
|
public TrashBinViewModel()
|
||||||
|
{
|
||||||
|
DeletedBooks = new()
|
||||||
|
{
|
||||||
|
ResetBehavior = ResetBehavior.Remove
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CheckAll()
|
||||||
|
{
|
||||||
|
foreach (var item in DeletedBooks)
|
||||||
|
item.IsChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UncheckAll()
|
||||||
|
{
|
||||||
|
foreach (var item in DeletedBooks)
|
||||||
|
item.IsChecked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RestoreCheckedAsync()
|
||||||
|
{
|
||||||
|
ControlsEnabled = false;
|
||||||
|
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
Reload();
|
||||||
|
ControlsEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PermanentlyDeleteCheckedAsync()
|
||||||
|
{
|
||||||
|
ControlsEnabled = false;
|
||||||
|
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
Reload();
|
||||||
|
ControlsEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
||||||
|
|
||||||
|
DeletedBooks.Clear();
|
||||||
|
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
|
||||||
|
|
||||||
|
_totalBooksCount = DeletedBooks.Count;
|
||||||
|
CheckedBooksCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IDisposable tracker;
|
||||||
|
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
|
||||||
|
{
|
||||||
|
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
|
||||||
|
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => tracker?.Dispose();
|
||||||
|
}
|
||||||
@ -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); };
|
||||||
|
|||||||
@ -102,6 +102,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI"
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AssertionHelper", "_Tests\AssertionHelper\AssertionHelper.csproj", "{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@ -220,6 +222,10 @@ Global
|
|||||||
{E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU
|
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@ -258,6 +264,7 @@ Global
|
|||||||
{FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
{FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||||
{53758A35-1C7E-4702-9B96-433ABA457B37} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
{53758A35-1C7E-4702-9B96-433ABA457B37} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||||
{47E27674-595D-4F7A-8CFB-127E768E1D1E} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
{47E27674-595D-4F7A-8CFB-127E768E1D1E} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||||
|
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
|
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
|
||||||
|
|||||||
@ -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();
|
||||||
@ -106,7 +111,7 @@ namespace LibationAvalonia
|
|||||||
if (setupDialog.Config.LibationSettingsAreValid)
|
if (setupDialog.Config.LibationSettingsAreValid)
|
||||||
{
|
{
|
||||||
string? theme = setupDialog.SelectedTheme.Content as string;
|
string? theme = setupDialog.SelectedTheme.Content as string;
|
||||||
|
|
||||||
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
|
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
|
||||||
|
|
||||||
await RunMigrationsAsync(setupDialog.Config);
|
await RunMigrationsAsync(setupDialog.Config);
|
||||||
@ -115,7 +120,10 @@ namespace LibationAvalonia
|
|||||||
ShowMainWindow(desktop);
|
ShowMainWindow(desktop);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
await CancelInstallation();
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
await CancelInstallation(setupDialog);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (setupDialog.IsReturningUser)
|
else if (setupDialog.IsReturningUser)
|
||||||
{
|
{
|
||||||
@ -123,7 +131,8 @@ namespace LibationAvalonia
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await CancelInstallation();
|
e.Cancel = true;
|
||||||
|
await CancelInstallation(setupDialog);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,11 +143,11 @@ namespace LibationAvalonia
|
|||||||
var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file.";
|
var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file.";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await MessageBox.ShowAdminAlert(null, body, title, ex);
|
await MessageBox.ShowAdminAlert(setupDialog, body, title, ex);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
await MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
await MessageBox.Show(setupDialog, $"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -185,6 +194,7 @@ namespace LibationAvalonia
|
|||||||
{
|
{
|
||||||
// path did not result in valid settings
|
// path did not result in valid settings
|
||||||
var continueResult = await MessageBox.Show(
|
var continueResult = await MessageBox.Show(
|
||||||
|
libationFilesDialog,
|
||||||
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
|
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
|
||||||
"New install?",
|
"New install?",
|
||||||
MessageBoxButtons.YesNo,
|
MessageBoxButtons.YesNo,
|
||||||
@ -202,18 +212,18 @@ namespace LibationAvalonia
|
|||||||
ShowMainWindow(desktop);
|
ShowMainWindow(desktop);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
await CancelInstallation();
|
await CancelInstallation(libationFilesDialog);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
await CancelInstallation();
|
await CancelInstallation(libationFilesDialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
libationFilesDialog.Close();
|
libationFilesDialog.Close();
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task CancelInstallation()
|
static async Task CancelInstallation(Window window)
|
||||||
{
|
{
|
||||||
await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||||
Environment.Exit(0);
|
Environment.Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
Source/LibationAvalonia/Assets/MBIcons/Asterisk.ico
Normal file
|
After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
BIN
Source/LibationAvalonia/Assets/MBIcons/Asterisk_64.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
Source/LibationAvalonia/Assets/MBIcons/Error.ico
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
Source/LibationAvalonia/Assets/MBIcons/Error_64.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
Source/LibationAvalonia/Assets/MBIcons/Exclamation.ico
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
Source/LibationAvalonia/Assets/MBIcons/Exclamation_64.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
Source/LibationAvalonia/Assets/MBIcons/Question.ico
Normal file
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
BIN
Source/LibationAvalonia/Assets/MBIcons/Question_64.png
Normal file
|
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)
|
||||||
|
|||||||
@ -92,13 +92,11 @@ namespace LibationAvalonia.Controls
|
|||||||
base.UpdateDataValidation(property, state, error);
|
base.UpdateDataValidation(property, state, error);
|
||||||
if (property == CommandProperty)
|
if (property == CommandProperty)
|
||||||
{
|
{
|
||||||
if (state == BindingValueType.BindingError)
|
var canExecure = !state.HasFlag(BindingValueType.HasError);
|
||||||
|
if (canExecure != _commandCanExecute)
|
||||||
{
|
{
|
||||||
if (_commandCanExecute)
|
_commandCanExecute = canExecure;
|
||||||
{
|
UpdateIsEffectivelyEnabled();
|
||||||
_commandCanExecute = false;
|
|
||||||
UpdateIsEffectivelyEnabled();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||