mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-06 21:08:35 -05:00
Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -27,4 +27,4 @@ If applicable, add screenshots to help explain your problem.
|
||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||
|
||||
**Log Files**
|
||||
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'
|
||||
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'. If your user folder contains the file "LibationCrash.log", attach that also.
|
||||
|
||||
25
.github/workflows/build-windows.yml
vendored
25
.github/workflows/build-windows.yml
vendored
@@ -15,6 +15,10 @@ on:
|
||||
description: "Skip running unit tests"
|
||||
required: false
|
||||
default: true
|
||||
architecture:
|
||||
type: string
|
||||
description: "CPU architecture targeted by the build."
|
||||
required: true
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: "Release"
|
||||
@@ -22,8 +26,11 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "${{ matrix.os }}-${{ matrix.release_name }}"
|
||||
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
RUNTIME_ID: "win-${{ inputs.architecture }}"
|
||||
strategy:
|
||||
matrix:
|
||||
os: [Windows]
|
||||
@@ -63,38 +70,42 @@ jobs:
|
||||
run: |
|
||||
dotnet publish `
|
||||
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LibationCli/LibationCli.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:DefineConstants="${{ matrix.release_name }}" `
|
||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
|
||||
- name: Zip artifact
|
||||
id: zip
|
||||
working-directory: ./Source/bin/Publish
|
||||
run: |
|
||||
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
|
||||
$bin_dir = "${{ env.OUTPUT_NAME }}\"
|
||||
$delfiles = @(
|
||||
"WindowsConfigApp.exe",
|
||||
"WindowsConfigApp.runtimeconfig.json",
|
||||
"WindowsConfigApp.deps.json"
|
||||
)
|
||||
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
||||
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -18,10 +18,14 @@ on:
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [x64]
|
||||
uses: ./.github/workflows/build-windows.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
|
||||
linux:
|
||||
strategy:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm",
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
|
||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
|
||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
|
||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
|
||||
}
|
||||
|
||||
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": ".NET Core Launch (console)",
|
||||
"name": ".NET Core Launch (console) Windows",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
@@ -15,6 +15,17 @@
|
||||
"cwd": "${workspaceFolder}",
|
||||
"stopAtEntry": false,
|
||||
"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
17
.vscode/tasks.json
vendored
@@ -37,6 +37,23 @@
|
||||
//"reveal": "silent"
|
||||
},
|
||||
"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
|
||||
|
||||
info "loading settings"
|
||||
update_settings Settings.json Books /data
|
||||
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
|
||||
update_settings Settings.json InProgress /tmp
|
||||
|
||||
info "loading database"
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
## Q: Where can I get help for my specific problem?
|
||||
|
||||
**A:** [You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
|
||||
## Q: What's the difference between 'Classic' and 'Chardonnay'?
|
||||
|
||||
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
- [Download PDF attachments](#download-pdf-attachments)
|
||||
- [Details of downloaded files](#details-of-downloaded-files)
|
||||
- [Export your library](#export-your-library)
|
||||
- [I still need help](#i-still-need-help)
|
||||
|
||||
|
||||
|
||||
@@ -148,3 +149,7 @@ When you set up Libation, you'll specify a Books directory. Libation looks insid
|
||||

|
||||
|
||||
Export your library to Excel, CSV, or JSON
|
||||
|
||||
### I still need help
|
||||
|
||||
[You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
|
||||
@@ -8,23 +8,23 @@
|
||||
|
||||
[](https://repology.org/project/libation/versions)
|
||||
|
||||
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the Libation repository's releases page.
|
||||
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the [Libation repository's releases page](https://github.com/rmcrackan/Libation/releases).
|
||||
|
||||
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
|
||||
Run these commands in your terminal to download and install Libation. **Make sure you replace** `X.X.X` with the latest Libation version and `ARCH` with your CPU's architechture (either `amd64` or `arm64`).
|
||||
|
||||
### Debian
|
||||
```Console
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.deb
|
||||
sudo apt install ./libation.deb
|
||||
```
|
||||
### Redhat and CentOS
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
|
||||
sudo yum install ./libation.rpm
|
||||
```
|
||||
### Fedora
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
|
||||
sudo dnf5 install ./libation.rpm
|
||||
```
|
||||
---
|
||||
|
||||
64
Documentation/LinuxDevelopmentSetupUsingNix.md
Normal file
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)|
|
||||
|\<first series\>|First series|[Series](#series-formatters)|
|
||||
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|
||||
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|
||||
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|
||||
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[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 nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||
|
||||
BIN
Documentation/images/StartingDebuggingInVSCode.png
Normal file
BIN
Documentation/images/StartingDebuggingInVSCode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -22,6 +22,7 @@
|
||||
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
||||
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
||||
- [Export your library](Documentation/GettingStarted.md#export-your-library)
|
||||
- If you still need help, [you can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
|
||||
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
||||
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.2" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
using AAXClean;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||
{
|
||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||
public event EventHandler<AppleTags>? RetrievedMetadata;
|
||||
|
||||
public Mp4File AaxFile { get; private set; }
|
||||
protected Mp4Operation AaxConversion { get; set; }
|
||||
public Mp4File? AaxFile { get; private set; }
|
||||
protected Mp4Operation? AaxConversion { get; set; }
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions) { }
|
||||
|
||||
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
|
||||
public override void SetCoverArt(byte[] coverArt)
|
||||
@@ -25,18 +27,19 @@ namespace AaxDecrypter
|
||||
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
await base.CancelAsync();
|
||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||
FinalizeDownload();
|
||||
}
|
||||
|
||||
private Mp4File Open()
|
||||
{
|
||||
if (DownloadOptions.InputType is FileType.Dash)
|
||||
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 = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||
var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||
|
||||
var dash = new DashFile(InputFileStream);
|
||||
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
||||
@@ -44,26 +47,38 @@ namespace AaxDecrypter
|
||||
if (kidIndex == -1)
|
||||
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
||||
|
||||
DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex];
|
||||
var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1;
|
||||
var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2;
|
||||
|
||||
keys[0] = keys[kidIndex];
|
||||
var keyId = keys[kidIndex].KeyPart1;
|
||||
var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
|
||||
dash.SetDecryptionKey(keyId, key);
|
||||
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
|
||||
return dash;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aax)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1);
|
||||
var key = keys[0].KeyPart1;
|
||||
aax.SetDecryptionKey(keys[0].KeyPart1);
|
||||
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
|
||||
return aax;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2);
|
||||
var key = keys[0].KeyPart1;
|
||||
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
|
||||
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
|
||||
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
|
||||
return aax;
|
||||
}
|
||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||
|
||||
void WriteKeyFile(string contents)
|
||||
{
|
||||
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
|
||||
File.WriteAllText(keyFile, contents + Environment.NewLine);
|
||||
OnTempFileCreated(new(keyFile));
|
||||
}
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
@@ -111,15 +126,15 @@ namespace AaxDecrypter
|
||||
if (DownloadOptions.SeriesName is string series)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
|
||||
|
||||
if (DownloadOptions.SeriesNumber is float part)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
|
||||
if (DownloadOptions.SeriesNumber is string part)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
|
||||
}
|
||||
|
||||
OnInitialized();
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
OnInitialized();
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
@@ -5,20 +5,20 @@ using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||
private FileStream workingFileStream;
|
||||
private FileStream? workingFileStream;
|
||||
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions)
|
||||
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions)
|
||||
{
|
||||
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
if (AaxFile is null) return false;
|
||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||
|
||||
// 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
|
||||
{
|
||||
await (AaxConversion = decryptMultiAsync(splitChapters));
|
||||
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
|
||||
|
||||
if (AaxConversion.IsCompletedSuccessfully)
|
||||
await moveMoovToBeginning(workingFileStream?.Name);
|
||||
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
|
||||
|
||||
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;
|
||||
return
|
||||
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||
? AaxFile.ConvertToMultiMp4aAsync
|
||||
? aaxFile.ConvertToMultiMp4aAsync
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
||||
)
|
||||
: AaxFile.ConvertToMultiMp3Async
|
||||
: aaxFile.ConvertToMultiMp3Async
|
||||
(
|
||||
splitChapters,
|
||||
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)
|
||||
{
|
||||
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
|
||||
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
MultiConvertFileProperties props = new()
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
OutputFileName = newTempFile.FilePath,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback?.Chapter?.Title,
|
||||
Title = newSplitCallback.Chapter?.Title,
|
||||
};
|
||||
|
||||
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
|
||||
|
||||
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||
newSplitCallback.TrackNumber = currentChapter;
|
||||
newSplitCallback.TrackCount = splitChapters.Count;
|
||||
|
||||
OnFileCreated(workingFileStream.Name);
|
||||
OnTempFileCreated(newTempFile with { PartProperties = props });
|
||||
}
|
||||
|
||||
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||
FileUtility.SaferDelete(fileName);
|
||||
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
|
||||
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
}
|
||||
}
|
||||
|
||||
private Mp4Operation moveMoovToBeginning(string filename)
|
||||
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
|
||||
{
|
||||
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||
&& 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);
|
||||
}
|
||||
else return Mp4Operation.FromCompleted(AaxFile);
|
||||
else return Mp4Operation.FromCompleted(aaxFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
private readonly AverageSpeed averageSpeed = new();
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions)
|
||||
private TempFile? outputTempFile;
|
||||
|
||||
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outDirectory, cacheDirectory, dlOptions)
|
||||
{
|
||||
var step = 1;
|
||||
|
||||
@@ -21,7 +24,6 @@ namespace AaxDecrypter
|
||||
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -39,14 +41,16 @@ namespace AaxDecrypter
|
||||
|
||||
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);
|
||||
OnFileCreated(OutputFileName);
|
||||
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnTempFileCreated(outputTempFile);
|
||||
|
||||
try
|
||||
{
|
||||
await (AaxConversion = decryptAsync(outputFile));
|
||||
await (AaxConversion = decryptAsync(AaxFile, outputFile));
|
||||
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
@@ -58,14 +62,15 @@ namespace AaxDecrypter
|
||||
|
||||
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;
|
||||
await AaxConversion;
|
||||
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
|
||||
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
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
|
||||
? AaxFile.ConvertToMp3Async
|
||||
? aaxFile.ConvertToMp3Async
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.ChapterInfo
|
||||
)
|
||||
: DownloadOptions.FixupFile
|
||||
? AaxFile.ConvertToMp4aAsync
|
||||
? aaxFile.ConvertToMp4aAsync
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.ChapterInfo
|
||||
)
|
||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||
: aaxFile.ConvertToMp4aAsync(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,55 +6,50 @@ using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public enum OutputFormat { M4b, Mp3 }
|
||||
|
||||
public abstract class AudiobookDownloadBase
|
||||
{
|
||||
public event EventHandler<string> RetrievedTitle;
|
||||
public event EventHandler<string> RetrievedAuthors;
|
||||
public event EventHandler<string> RetrievedNarrators;
|
||||
public event EventHandler<byte[]> RetrievedCoverArt;
|
||||
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
|
||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||
public event EventHandler<string> FileCreated;
|
||||
public event EventHandler<string?>? RetrievedTitle;
|
||||
public event EventHandler<string?>? RetrievedAuthors;
|
||||
public event EventHandler<string?>? RetrievedNarrators;
|
||||
public event EventHandler<byte[]?>? RetrievedCoverArt;
|
||||
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
|
||||
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
|
||||
public event EventHandler<TempFile>? TempFileCreated;
|
||||
|
||||
public bool IsCanceled { get; protected set; }
|
||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||
protected string OutputFileName { get; }
|
||||
protected string OutputDirectory { get; }
|
||||
public IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||
private bool downloadFinished;
|
||||
|
||||
private readonly NetworkFileStreamPersister nfsPersister;
|
||||
private NetworkFileStreamPersister? m_nfsPersister;
|
||||
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
|
||||
private readonly DownloadProgress zeroProgress;
|
||||
private readonly string jsonDownloadState;
|
||||
private readonly string tempFilePath;
|
||||
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
{
|
||||
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
|
||||
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
{
|
||||
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(outDir))
|
||||
Directory.CreateDirectory(outDir);
|
||||
if (!Directory.Exists(OutputDirectory))
|
||||
Directory.CreateDirectory(OutputDirectory);
|
||||
|
||||
if (!Directory.Exists(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");
|
||||
|
||||
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
|
||||
{
|
||||
BytesReceived = 0,
|
||||
@@ -65,24 +60,30 @@ namespace AaxDecrypter
|
||||
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()
|
||||
{
|
||||
await InputFileStream.BeginDownloadingAsync();
|
||||
var progressTask = Task.Run(reportProgress);
|
||||
|
||||
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||
|
||||
//Stop the downloader so it doesn't keep running in the background.
|
||||
if (!success)
|
||||
nfsPersister.Dispose();
|
||||
NfsPersister.Dispose();
|
||||
|
||||
await progressTask;
|
||||
|
||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||
|
||||
nfsPersister.Dispose();
|
||||
NfsPersister.Dispose();
|
||||
return success;
|
||||
|
||||
async Task reportProgress()
|
||||
@@ -120,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();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt) { }
|
||||
|
||||
protected void OnRetrievedTitle(string title)
|
||||
protected void OnRetrievedTitle(string? title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
protected void OnRetrievedAuthors(string authors)
|
||||
protected void OnRetrievedAuthors(string? authors)
|
||||
=> RetrievedAuthors?.Invoke(this, authors);
|
||||
protected void OnRetrievedNarrators(string narrators)
|
||||
protected void OnRetrievedNarrators(string? narrators)
|
||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||
protected void OnRetrievedCoverArt(byte[]? coverArt)
|
||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||
protected void OnFileCreated(string path)
|
||||
=> FileCreated?.Invoke(this, path);
|
||||
public void OnTempFileCreated(TempFile path)
|
||||
=> TempFileCreated?.Invoke(this, path);
|
||||
|
||||
protected virtual void FinalizeDownload()
|
||||
{
|
||||
nfsPersister?.Dispose();
|
||||
NfsPersister.Dispose();
|
||||
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()
|
||||
{
|
||||
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
|
||||
try
|
||||
{
|
||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||
OnFileCreated(path);
|
||||
var tempFile = GetNewTempFilePath(".cue");
|
||||
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
|
||||
OnTempFileCreated(tempFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -176,58 +175,9 @@ namespace AaxDecrypter
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private async Task<bool> CleanupAsync()
|
||||
{
|
||||
if (IsCanceled) return false;
|
||||
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
|
||||
if (DownloadOptions.DecryptionKeys != null &&
|
||||
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={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Aaxc)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath,
|
||||
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
||||
$"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Dash)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath,
|
||||
$"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
||||
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"Unknown file type: {fileType}");
|
||||
|
||||
if (tempFilePath != aaxPath)
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
}
|
||||
else
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||
{
|
||||
NetworkFileStreamPersister nfsp = default;
|
||||
NetworkFileStreamPersister? nfsp = default;
|
||||
try
|
||||
{
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
@@ -248,8 +198,14 @@ namespace AaxDecrypter
|
||||
}
|
||||
finally
|
||||
{
|
||||
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
|
||||
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()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using AAXClean;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
@@ -19,9 +18,9 @@ namespace AaxDecrypter
|
||||
public KeyData(string keyPart1, string? keyPart2 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
|
||||
KeyPart1 = Convert.FromBase64String(keyPart1);
|
||||
KeyPart1 = Convert.FromHexString(keyPart1);
|
||||
if (keyPart2 != null)
|
||||
KeyPart2 = Convert.FromBase64String(keyPart2);
|
||||
KeyPart2 = Convert.FromHexString(keyPart2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +32,8 @@ namespace AaxDecrypter
|
||||
KeyData[]? DecryptionKeys { get; }
|
||||
TimeSpan RuntimeLength { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool TrimOutputToChapterLength { get; }
|
||||
bool RetainEncryptedFile { get; }
|
||||
bool StripUnabridged { get; }
|
||||
bool CreateCueSheet { get; }
|
||||
bool DownloadClipsBookmarks { get; }
|
||||
long DownloadSpeedBps { get; }
|
||||
ChapterInfo ChapterInfo { get; }
|
||||
bool FixupFile { get; }
|
||||
@@ -47,14 +43,12 @@ namespace AaxDecrypter
|
||||
string? Publisher { get; }
|
||||
string? Language { get; }
|
||||
string? SeriesName { get; }
|
||||
float? SeriesNumber { get; }
|
||||
string? SeriesNumber { get; }
|
||||
NAudio.Lame.LameConfig? LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
bool MoveMoovToBeginning { get; }
|
||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||
public FileType? InputType { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,12 @@ namespace AaxDecrypter
|
||||
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);
|
||||
|
||||
SetUriForSameFile(uri);
|
||||
|
||||
17
Source/AaxDecrypter/TempFile.cs
Normal file
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 System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
@@ -8,20 +7,12 @@ namespace AaxDecrypter
|
||||
{
|
||||
protected override long InputFilePosition => InputFileStream.WritePosition;
|
||||
|
||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outDirectory, cacheDirectory, dlLic)
|
||||
{
|
||||
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
||||
}
|
||||
|
||||
public override Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
FinalizeDownload();
|
||||
return Task.CompletedTask;
|
||||
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
|
||||
}
|
||||
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
@@ -33,8 +24,9 @@ namespace AaxDecrypter
|
||||
else
|
||||
{
|
||||
FinalizeDownload();
|
||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
|
||||
OnFileCreated(OutputFileName);
|
||||
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
|
||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
|
||||
OnTempFileCreated(tempFile);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>12.4.2.1</Version>
|
||||
<Version>12.5.0.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||
<!-- Do not remove unused Serilog.Sinks -->
|
||||
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
|
||||
<!-- Only File sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
|
||||
@@ -115,11 +115,22 @@ namespace AppScaffolding
|
||||
{
|
||||
if (config.GetObject("Serilog") is JObject serilog)
|
||||
{
|
||||
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink)
|
||||
bool fileChanged = false;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
|
||||
{
|
||||
fileSink["Name"] = "ZipFile";
|
||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||
zipFileSink["Name"] = "File";
|
||||
fileChanged = true;
|
||||
}
|
||||
var hooks = 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;
|
||||
}
|
||||
|
||||
@@ -129,17 +140,17 @@ namespace AppScaffolding
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
// ABOUT SINKS
|
||||
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "ZipFile" },
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
|
||||
{ "path", Path.Combine(config.LibationFiles, "Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
@@ -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}";
|
||||
// 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
|
||||
{ "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),
|
||||
});
|
||||
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
|
||||
@@ -290,33 +302,24 @@ namespace AppScaffolding
|
||||
public static UpgradeProperties GetLatestRelease()
|
||||
{
|
||||
// timed out
|
||||
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
(var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||
|
||||
if (latest is null || zip is null)
|
||||
return null;
|
||||
|
||||
var latestVersionString = latest.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
||||
return null;
|
||||
|
||||
// we're up to date
|
||||
if (latestRelease <= BuildVersion)
|
||||
if (version is null || latest is null || zip is null)
|
||||
return null;
|
||||
|
||||
// we have an update
|
||||
|
||||
var zipUrl = zip?.BrowserDownloadUrl;
|
||||
|
||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||
{
|
||||
latestRelease = latestRelease.ToString(),
|
||||
latestRelease = version.ToString(),
|
||||
latest.HtmlUrl,
|
||||
zipUrl
|
||||
});
|
||||
|
||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
|
||||
return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body);
|
||||
}
|
||||
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||
private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -330,15 +333,23 @@ namespace AppScaffolding
|
||||
{
|
||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||
}
|
||||
return (null, null);
|
||||
return (null, null, null);
|
||||
}
|
||||
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||
private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||
{
|
||||
const string ownerAccount = "rmcrackan";
|
||||
const string repoName = "Libation";
|
||||
|
||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||
|
||||
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
||||
|
||||
//Ensure that latest release is greater than the current version
|
||||
var latestVersionString = latestRelease.TagName.Trim('v');
|
||||
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
|
||||
return (null, null, null);
|
||||
|
||||
//Download the release index
|
||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||
@@ -356,10 +367,7 @@ namespace AppScaffolding
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
||||
|
||||
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||
return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
<PackageReference Include="NPOI" Version="2.7.3" />
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="NPOI" Version="2.7.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -521,8 +521,8 @@ namespace ApplicationServices
|
||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||
});
|
||||
|
||||
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion)
|
||||
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(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, audioFormat, audioVersion); });
|
||||
|
||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
|
||||
@@ -4,8 +4,8 @@ using System.Linq;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using Serilog;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
@@ -115,7 +115,29 @@ namespace ApplicationServices
|
||||
|
||||
[Name("IsFinished")]
|
||||
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 List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||
@@ -135,16 +157,16 @@ namespace ApplicationServices
|
||||
HasPdf = a.Book.HasPdf(),
|
||||
SeriesNames = a.Book.SeriesNames(),
|
||||
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,
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
|
||||
CommunityRatingStory = a.Book.Rating?.StoryRating,
|
||||
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
|
||||
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
|
||||
CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
|
||||
PictureId = a.Book.PictureId,
|
||||
IsAbridged = a.Book.IsAbridged,
|
||||
DatePublished = a.Book.DatePublished,
|
||||
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
|
||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
|
||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
|
||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||
@@ -152,8 +174,13 @@ namespace ApplicationServices
|
||||
Language = a.Book.Language,
|
||||
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||
IsFinished = a.Book.UserDefinedItem.IsFinished
|
||||
}).ToList();
|
||||
IsFinished = a.Book.UserDefinedItem.IsFinished,
|
||||
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
|
||||
{
|
||||
@@ -162,7 +189,6 @@ namespace ApplicationServices
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
if (!dtos.Any())
|
||||
return;
|
||||
|
||||
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||
|
||||
@@ -174,7 +200,7 @@ namespace ApplicationServices
|
||||
public static void ToJson(string saveFilePath)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -227,7 +253,13 @@ namespace ApplicationServices
|
||||
nameof(ExportDto.Language),
|
||||
nameof(ExportDto.LastDownloaded),
|
||||
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;
|
||||
foreach (var c in columns)
|
||||
@@ -248,15 +280,10 @@ namespace ApplicationServices
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
col = 0;
|
||||
|
||||
row = sheet.CreateRow(rowIndex);
|
||||
row = sheet.CreateRow(rowIndex++);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||
|
||||
var dateCell = row.CreateCell(col++);
|
||||
dateCell.CellStyle = dateStyle;
|
||||
dateCell.SetCellValue(dto.DateAdded);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle;
|
||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||
@@ -269,56 +296,46 @@ namespace ApplicationServices
|
||||
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||
|
||||
col = createCell(row, col, dto.CommunityRatingOverall);
|
||||
col = createCell(row, col, dto.CommunityRatingPerformance);
|
||||
col = createCell(row, col, dto.CommunityRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall);
|
||||
row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance);
|
||||
row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory);
|
||||
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
||||
|
||||
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.DatePublished).CellStyle = dateStyle;
|
||||
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
||||
|
||||
col = createCell(row, col, dto.MyRatingOverall);
|
||||
col = createCell(row, col, dto.MyRatingPerformance);
|
||||
col = createCell(row, col, dto.MyRatingStory);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.MyRatingOverall);
|
||||
row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance);
|
||||
row.CreateCell(col++).SetCellValue(dto.MyRatingStory);
|
||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||
|
||||
if (dto.LastDownloaded.HasValue)
|
||||
{
|
||||
dateCell = row.CreateCell(col);
|
||||
dateCell.CellStyle = dateStyle;
|
||||
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
||||
}
|
||||
|
||||
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
||||
row.CreateCell(++col).SetCellValue(dto.IsFinished);
|
||||
|
||||
rowIndex++;
|
||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||
row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle;
|
||||
row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsFinished);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsSpatial);
|
||||
row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion);
|
||||
row.CreateCell(col++).SetCellValue(dto.CodecString);
|
||||
row.CreateCell(col++).SetCellValue(dto.SampleRate);
|
||||
row.CreateCell(col++).SetCellValue(dto.ChannelCount);
|
||||
row.CreateCell(col++).SetCellValue(dto.BitRate);
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
workbook.Write(fileData);
|
||||
}
|
||||
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
|
||||
{
|
||||
if (nullableFloat.HasValue)
|
||||
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
|
||||
else
|
||||
row.CreateCell(col++).SetCellValue("");
|
||||
return col;
|
||||
}
|
||||
|
||||
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate)
|
||||
=> nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value)
|
||||
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||
|
||||
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt)
|
||||
=> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="9.4.1.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
|
||||
<PackageReference Include="AudibleApi" Version="9.4.2.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
70
Source/DataLayer/AudioFormat.cs
Normal file
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,7 +13,6 @@ namespace DataLayer.Configurations
|
||||
|
||||
entity.OwnsOne(b => b.Rating);
|
||||
|
||||
entity.Property(nameof(Book._audioFormat));
|
||||
//
|
||||
// CRUCIAL: ignore unmapped collections, even get-only
|
||||
//
|
||||
@@ -50,6 +49,11 @@ namespace DataLayer.Configurations
|
||||
b_udi
|
||||
.Property(udi => udi.LastDownloadedVersion)
|
||||
.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
|
||||
b_udi.OwnsOne(udi => udi.Rating);
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.4">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -43,18 +43,13 @@ namespace DataLayer
|
||||
public ContentType ContentType { get; private set; }
|
||||
public string Locale { get; private set; }
|
||||
|
||||
//This field is now unused, however, there is little sense in adding a
|
||||
//database migration to remove an unused field. Leave it for compatibility.
|
||||
#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0
|
||||
internal long _audioFormat;
|
||||
#pragma warning restore CS0649
|
||||
|
||||
// mutable
|
||||
public string PictureId { get; set; }
|
||||
public string PictureLarge { get; set; }
|
||||
|
||||
// book details
|
||||
public bool IsAbridged { get; private set; }
|
||||
public bool IsSpatial { get; private set; }
|
||||
public DateTime? DatePublished { get; private set; }
|
||||
public string Language { get; private set; }
|
||||
|
||||
@@ -242,10 +237,11 @@ namespace DataLayer
|
||||
public void UpdateProductRating(float overallRating, float performanceRating, float 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
|
||||
IsAbridged |= isAbridged;
|
||||
IsSpatial = isSpatial ?? IsSpatial;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
Language = language?.FirstCharToUpper() ?? Language;
|
||||
}
|
||||
|
||||
@@ -24,24 +24,52 @@ namespace DataLayer
|
||||
{
|
||||
internal int BookId { get; private set; }
|
||||
public Book Book { get; private set; }
|
||||
public DateTime? LastDownloaded { get; private set; }
|
||||
public Version LastDownloadedVersion { get; private set; }
|
||||
/// <summary>
|
||||
/// 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));
|
||||
}
|
||||
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;
|
||||
LastDownloadedFormat = null;
|
||||
LastDownloadedFileVersion = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
LastDownloaded = DateTime.Now;
|
||||
LastDownloaded = DateTime.Now;
|
||||
OnItemChanged(nameof(LastDownloaded));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private UserDefinedItem() { }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Dinah.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace DataLayer
|
||||
{
|
||||
@@ -7,6 +8,7 @@ namespace DataLayer
|
||||
{
|
||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||
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
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)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
@@ -53,6 +53,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -74,9 +77,6 @@ namespace DataLayer.Migrations
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
@@ -318,6 +318,12 @@ namespace DataLayer.Migrations
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
#nullable enable
|
||||
namespace DataLayer
|
||||
{
|
||||
// only library importing should use tracking. All else should be NoTracking.
|
||||
@@ -24,13 +25,13 @@ namespace DataLayer
|
||||
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
|
||||
.ToList();
|
||||
|
||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||
=> context
|
||||
.LibraryBooks
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibraryBook(productId);
|
||||
|
||||
public static LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
|
||||
public static LibraryBook? GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
|
||||
=> library
|
||||
.GetLibrary()
|
||||
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
|
||||
@@ -103,13 +104,11 @@ namespace DataLayer
|
||||
) == true
|
||||
).ToList();
|
||||
|
||||
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
||||
=> bookList
|
||||
.Where(
|
||||
lb =>
|
||||
!lb.AbsentFromLastScan &&
|
||||
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||
);
|
||||
public static bool NeedsPdfDownload(this LibraryBook libraryBook)
|
||||
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
|
||||
public static bool NeedsBookDownload(this LibraryBook libraryBook)
|
||||
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload;
|
||||
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
||||
=> bookList.Where(lb => lb.NeedsPdfDownload() || lb.NeedsBookDownload());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +137,6 @@ namespace DtoImporterService
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
||||
|
||||
if (item.PdfUrl is not null)
|
||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
|
||||
@@ -166,8 +164,9 @@ namespace DtoImporterService
|
||||
|
||||
// 2023-02-01
|
||||
// updateBook must update language on books which were imported before the migration which added language.
|
||||
// Can eventually delete this
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
||||
// 2025-07-30
|
||||
// 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(
|
||||
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AaxDecrypter;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
@@ -34,30 +35,17 @@ namespace FileLiberator
|
||||
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 _, LibraryBookDto libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file does not exist
|
||||
/// </summary>
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
|
||||
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
|
||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension, returnFirstExisting: returnFirstExisting);
|
||||
|
||||
/// <summary>
|
||||
/// PDF: audio file already exists
|
||||
/// </summary>
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
|
||||
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
|
||||
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties partProperties = null, bool returnFirstExisting = false)
|
||||
=> 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
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.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
@@ -19,7 +20,13 @@ namespace FileLiberator
|
||||
private readonly AaxDecrypter.AverageSpeed averageSpeed = new();
|
||||
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)
|
||||
{
|
||||
@@ -32,17 +39,29 @@ namespace FileLiberator
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
var cancellationToken = (CancellationTokenSource = new()).Token;
|
||||
|
||||
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);
|
||||
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (File.Exists(entry.proposedMp3Path) || !File.Exists(entry.m4bPath))
|
||||
{
|
||||
sizeOfCompletedFiles += entry.m4bSize;
|
||||
continue;
|
||||
}
|
||||
|
||||
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||
using var m4bFileStream = File.Open(entry.m4bPath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
var m4bBook = new Mp4File(m4bFileStream);
|
||||
|
||||
//AAXClean.Codecs only supports decoding AAC and E-AC-3 audio.
|
||||
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
|
||||
@@ -69,74 +88,85 @@ namespace FileLiberator
|
||||
lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString();
|
||||
}
|
||||
|
||||
using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
long currentFileNumBytesProcessed = 0;
|
||||
try
|
||||
{
|
||||
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
|
||||
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
await Mp4Operation;
|
||||
|
||||
if (Mp4Operation.IsCanceled)
|
||||
var tempPath = Path.GetTempFileName();
|
||||
using (var mp3File = File.Open(tempPath, FileMode.OpenOrCreate, FileAccess.ReadWrite))
|
||||
{
|
||||
FileUtility.SaferDelete(mp3File.Name);
|
||||
return new StatusHandler { "Cancelled" };
|
||||
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
|
||||
Mp4Operation.ConversionProgressUpdate += m4bBook_ConversionProgressUpdate;
|
||||
await Mp4Operation;
|
||||
}
|
||||
else
|
||||
{
|
||||
var realMp3Path
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
FileUtility.SaferDelete(tempPath);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var realMp3Path
|
||||
= FileUtility.SaferMoveToValidPath(
|
||||
mp3File.Name,
|
||||
proposedMp3Path,
|
||||
tempPath,
|
||||
entry.proposedMp3Path,
|
||||
Configuration.Instance.ReplacementCharacters,
|
||||
extension: "mp3",
|
||||
Configuration.Instance.OverwriteExisting);
|
||||
|
||||
SetFileTime(libraryBook, realMp3Path);
|
||||
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
|
||||
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "AAXClean error");
|
||||
return new StatusHandler { "Conversion failed" };
|
||||
SetFileTime(libraryBook, realMp3Path);
|
||||
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Mp4Operation is not null)
|
||||
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
|
||||
Mp4Operation.ConversionProgressUpdate -= m4bBook_ConversionProgressUpdate;
|
||||
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
sizeOfCompletedFiles += entry.m4bSize;
|
||||
}
|
||||
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
|
||||
{
|
||||
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 estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
|
||||
var remainingBytes = (totalInputSize - bytesCompleted);
|
||||
var estTimeRemaining = remainingBytes / averageSpeed.Average;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
double progressPercent = 100 * e.FractionCompleted;
|
||||
double progressPercent = 100 * bytesCompleted / totalInputSize;
|
||||
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds,
|
||||
TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds
|
||||
BytesReceived = bytesCompleted,
|
||||
TotalBytesToReceive = totalInputSize
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +1,196 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using AaxDecrypter;
|
||||
using ApplicationServices;
|
||||
using AudibleApi.Common;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
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
|
||||
{
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
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)
|
||||
{
|
||||
var entries = new List<FilePathCache.CacheEntry>();
|
||||
// these only work so minimally b/c CacheEntry is a record.
|
||||
// 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);
|
||||
}
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
OnBegin(libraryBook);
|
||||
DownloadValidation(libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
downloadValidation(libraryBook);
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var config = Configuration.Instance;
|
||||
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook);
|
||||
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
|
||||
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
|
||||
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||
FilePathCache.Removed += FilePathCache_Removed;
|
||||
if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile)
|
||||
{
|
||||
// decrypt failed. Delete all output entries but leave the cache files.
|
||||
result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
|
||||
success = await downloadAudiobookAsync(api, config, downloadOptions);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||
FilePathCache.Removed -= FilePathCache_Removed;
|
||||
}
|
||||
if (Configuration.Instance.RetainAaxFile)
|
||||
{
|
||||
//Add the cached aaxc and key files to the entries list to be moved to the Books directory.
|
||||
result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles));
|
||||
}
|
||||
|
||||
// decrypt failed
|
||||
if (!success || getFirstAudioFile(entries) == default)
|
||||
{
|
||||
await Task.WhenAll(
|
||||
entries
|
||||
.Where(f => f.FileType != FileType.AAXC)
|
||||
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
||||
//Set the last downloaded information on the book so that it can be used in the naming templates,
|
||||
//but don't persist it until everything completes successfully (in the finally block)
|
||||
var audioFormat = GetFileFormatInfo(downloadOptions, audioFile);
|
||||
var audioVersion = downloadOptions.ContentMetadata.ContentReference.Version;
|
||||
libraryBook.Book.UserDefinedItem.SetLastDownloaded(Configuration.LibationVersion, audioFormat, audioVersion);
|
||||
|
||||
return
|
||||
abDownloader?.IsCanceled is true
|
||||
? new StatusHandler { "Cancelled" }
|
||||
: new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||
|
||||
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||
|
||||
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
Task[] finalTasks =
|
||||
[
|
||||
Task.Run(() => downloadCoverArt(downloadOptions)),
|
||||
moveFilesTask,
|
||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
||||
];
|
||||
//post-download tasks done in parallel.
|
||||
var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken));
|
||||
Task[] finalTasks =
|
||||
[
|
||||
moveFilesTask,
|
||||
Task.Run(() => DownloadCoverArt(finalStorageDir, downloadOptions, cancellationToken)),
|
||||
Task.Run(() => DownloadRecordsAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
|
||||
Task.Run(() => DownloadMetadataAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
|
||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
|
||||
];
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(finalTasks);
|
||||
}
|
||||
catch
|
||||
{
|
||||
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
|
||||
//Only fail if the downloaded audio files failed to move to Books directory
|
||||
if (moveFilesTask.IsFaulted)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (moveFilesTask.IsCompletedSuccessfully)
|
||||
{
|
||||
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
|
||||
|
||||
{
|
||||
await Task.WhenAll(finalTasks);
|
||||
}
|
||||
catch when (!moveFilesTask.IsFaulted)
|
||||
{
|
||||
//Swallow DownloadCoverArt, DownloadRecordsAsync, DownloadMetadataAsync, and SetCoverAsFolderIcon exceptions.
|
||||
//Only fail if the downloaded audio files failed to move to Books directory
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
|
||||
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();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
|
||||
{
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
||||
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(dlOptions.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(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
|
||||
|
||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
||||
OnFileCreated(dlOptions.LibraryBook, metadataFile);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
catch when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Download/Decrypt was cancelled. {@Book}", libraryBook.LogFriendly());
|
||||
return new StatusHandler { "Cancelled" };
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
cancellationTokenSource.Dispose();
|
||||
cancellationTokenSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
#region Prevent erroneous truncation due to incorrect chapter info
|
||||
@@ -179,155 +201,312 @@ namespace FileLiberator
|
||||
//the chapter. This is never desirable, so pad the last chapter to match
|
||||
//the original audio length.
|
||||
|
||||
var fileDuration = converter.AaxFile.Duration;
|
||||
if (options.Config.StripAudibleBrandAudio)
|
||||
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||
var fileDuration = aaxFile.Duration;
|
||||
if (options.Config.StripAudibleBrandAudio)
|
||||
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||
|
||||
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
||||
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
||||
//Remove the last chapter and re-add it with the durationDelta that will
|
||||
//make the chapter's end coincide with the end of the audio file.
|
||||
var chapters = options.ChapterInfo.Chapters as List<AAXClean.Chapter>;
|
||||
//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
|
||||
|
||||
#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.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.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||
tags.LongDescription ??= tags.Comment;
|
||||
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
||||
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||
tags.LongDescription ??= tags.Comment;
|
||||
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
||||
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
|
||||
tags.Asin = options.LibraryBook.Book.AudibleProductId;
|
||||
tags.Acr = options.ContentMetadata.ContentReference.Acr;
|
||||
tags.Version = options.ContentMetadata.ContentReference.Version;
|
||||
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
|
||||
{
|
||||
tags.Year ??= pubDate.Year.ToString();
|
||||
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||
}
|
||||
}
|
||||
|
||||
private 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);
|
||||
{
|
||||
tags.Year ??= pubDate.Year.ToString();
|
||||
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = getDestinationDirectory(libraryBook);
|
||||
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
|
||||
var realDest
|
||||
= FileUtility.SaferMoveToValidPath(
|
||||
entry.Path,
|
||||
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
|
||||
Configuration.Instance.ReplacementCharacters,
|
||||
overwrite: Configuration.Instance.OverwriteExisting);
|
||||
|
||||
SetFileTime(libraryBook, realDest);
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||
|
||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
{
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
||||
SetFileTime(libraryBook, cue.Path);
|
||||
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)
|
||||
{
|
||||
if (Configuration.Instance.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader)
|
||||
{
|
||||
try
|
||||
{
|
||||
e = OnRequestCoverArt();
|
||||
downloader.SetCoverArt(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
|
||||
}
|
||||
}
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
}
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
}
|
||||
#endregion
|
||||
|
||||
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
if (!Directory.Exists(destinationDir))
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
return destinationDir;
|
||||
#region Validation
|
||||
|
||||
private static void DownloadValidation(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.TitleWithSubtitle.Length > 53)
|
||||
? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..."
|
||||
: libraryBook.Book.TitleWithSubtitle;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new InvalidOperationException(errorString("Account"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new InvalidOperationException(errorString("Locale"));
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Post-success routines
|
||||
/// <summary>Read the audio format from the audio file's metadata.</summary>
|
||||
public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
return firstAudioFile.Extension.ToLowerInvariant() switch
|
||||
{
|
||||
".m4b" or ".m4a" or ".mp4" => GetMp4AudioFormat(),
|
||||
".mp3" => AudioFormatDecoder.FromMpeg3(firstAudioFile.FilePath),
|
||||
_ => AudioFormat.Default
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to determine output audio format should not be considered a failure to download the book
|
||||
Serilog.Log.Logger.Error(ex, "Error determining output audio format for {@Book}. File = '{@audioFile}'", options.LibraryBook, firstAudioFile);
|
||||
return AudioFormat.Default;
|
||||
}
|
||||
|
||||
AudioFormat GetMp4AudioFormat()
|
||||
=> abDownloader is AaxcDownloadConvertBase converter && converter.AaxFile is AAXClean.Mp4File mp4File
|
||||
? AudioFormatDecoder.FromMpeg4(mp4File)
|
||||
: AudioFormatDecoder.FromMpeg4(firstAudioFile.FilePath);
|
||||
}
|
||||
|
||||
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
||||
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||
private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List<TempFile> entries, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
AverageSpeed averageSpeed = new();
|
||||
|
||||
private static void downloadCoverArt(DownloadOptions options)
|
||||
{
|
||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||
var totalSizeToMove = entries.Sum(f => new FileInfo(f.FilePath).Length);
|
||||
long totalBytesMoved = 0;
|
||||
|
||||
var coverPath = "[null]";
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
|
||||
try
|
||||
{
|
||||
var destinationDir = getDestinationDirectory(options.LibraryBook);
|
||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
|
||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||
var destFileName
|
||||
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
libraryBook,
|
||||
destinationDir,
|
||||
entry.Extension,
|
||||
entry.PartProperties,
|
||||
Configuration.Instance.OverwriteExisting);
|
||||
|
||||
if (File.Exists(coverPath))
|
||||
FileUtility.SaferDelete(coverPath);
|
||||
var realDest
|
||||
= FileUtility.SaferMoveToValidPath(
|
||||
entry.FilePath,
|
||||
destFileName,
|
||||
Configuration.Instance.ReplacementCharacters,
|
||||
entry.Extension,
|
||||
Configuration.Instance.OverwriteExisting);
|
||||
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native));
|
||||
if (picBytes.Length > 0)
|
||||
{
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
SetFileTime(options.LibraryBook, coverPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download cover art should not be considered a failure to download the book
|
||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||
}
|
||||
}
|
||||
}
|
||||
#region File Move Progress
|
||||
totalBytesMoved += new FileInfo(realDest).Length;
|
||||
averageSpeed.AddPosition(totalBytesMoved);
|
||||
var estSecsRemaining = (totalSizeToMove - totalBytesMoved) / averageSpeed.Average;
|
||||
|
||||
if (double.IsNormal(estSecsRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
|
||||
|
||||
OnStreamingProgressChanged(new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = 100d * totalBytesMoved / totalSizeToMove,
|
||||
BytesReceived = totalBytesMoved,
|
||||
TotalBytesToReceive = totalSizeToMove
|
||||
});
|
||||
#endregion
|
||||
|
||||
// propagate corrected path for cue file (after this for-loop)
|
||||
entries[i] = entry with { FilePath = realDest };
|
||||
|
||||
SetFileTime(libraryBook, realDest);
|
||||
OnFileCreated(libraryBook, realDest);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
|
||||
if (entries.FirstOrDefault(f => getFileType(f) is FileType.Cue) is TempFile cue
|
||||
&& getFirstAudioFile(entries)?.FilePath is LongPath audioFilePath)
|
||||
{
|
||||
Cue.UpdateFileName(cue.FilePath, audioFilePath);
|
||||
SetFileTime(libraryBook, cue.FilePath);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
}
|
||||
|
||||
private void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Config.DownloadCoverArt) return;
|
||||
|
||||
var coverPath = "[null]";
|
||||
|
||||
try
|
||||
{
|
||||
coverPath
|
||||
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
options.LibraryBook,
|
||||
destinationDir,
|
||||
extension: ".jpg",
|
||||
returnFirstExisting: Configuration.Instance.OverwriteExisting);
|
||||
|
||||
if (File.Exists(coverPath))
|
||||
FileUtility.SaferDelete(coverPath);
|
||||
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken);
|
||||
if (picBytes.Length > 0)
|
||||
{
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
SetFileTime(options.LibraryBook, coverPath);
|
||||
OnFileCreated(options.LibraryBook, coverPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download cover art should not be considered a failure to download the book
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {@metadataFile}.", options.LibraryBook, coverPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DownloadRecordsAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Config.DownloadClipsBookmarks) return;
|
||||
|
||||
var recordsPath = "[null]";
|
||||
var format = options.Config.ClipsBookmarksFileFormat;
|
||||
var formatExtension = FileUtility.GetStandardizedExtension(format.ToString().ToLowerInvariant());
|
||||
|
||||
try
|
||||
{
|
||||
recordsPath
|
||||
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
options.LibraryBook,
|
||||
destinationDir,
|
||||
extension: formatExtension,
|
||||
returnFirstExisting: Configuration.Instance.OverwriteExisting);
|
||||
|
||||
if (File.Exists(recordsPath))
|
||||
FileUtility.SaferDelete(recordsPath);
|
||||
|
||||
var records = await api.GetRecordsAsync(options.AudibleProductId);
|
||||
|
||||
switch (format)
|
||||
{
|
||||
case Configuration.ClipBookmarkFormat.CSV:
|
||||
RecordExporter.ToCsv(recordsPath, records);
|
||||
break;
|
||||
case Configuration.ClipBookmarkFormat.Xlsx:
|
||||
RecordExporter.ToXlsx(recordsPath, records);
|
||||
break;
|
||||
case Configuration.ClipBookmarkFormat.Json:
|
||||
RecordExporter.ToJson(recordsPath, options.LibraryBook, records);
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"Unsupported record export format: {format}");
|
||||
}
|
||||
|
||||
SetFileTime(options.LibraryBook, recordsPath);
|
||||
OnFileCreated(options.LibraryBook, recordsPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download records should not be considered a failure to download the book
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {@recordsPath}.", options.LibraryBook, recordsPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!options.Config.SaveMetadataToFile) return;
|
||||
|
||||
string metadataPath = "[null]";
|
||||
|
||||
try
|
||||
{
|
||||
metadataPath
|
||||
= AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
options.LibraryBook,
|
||||
destinationDir,
|
||||
extension: ".metadata.json",
|
||||
returnFirstExisting: Configuration.Instance.OverwriteExisting);
|
||||
|
||||
if (File.Exists(metadataPath))
|
||||
FileUtility.SaferDelete(metadataPath);
|
||||
|
||||
var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo));
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference));
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.WriteAllText(metadataPath, item.SourceJson.ToString());
|
||||
SetFileTime(options.LibraryBook, metadataPath);
|
||||
OnFileCreated(options.LibraryBook, metadataPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download metadata should not be considered a failure to download the book
|
||||
if (!cancellationToken.IsCancellationRequested)
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading metdatat of {@Book} to {@metadataFile}.", options.LibraryBook, metadataPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Macros
|
||||
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
if (!Directory.Exists(destinationDir))
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
private static FileType getFileType(TempFile file)
|
||||
=> FileTypes.GetFileTypeFromPath(file.FilePath);
|
||||
private static TempFile? getFirstAudioFile(IEnumerable<TempFile> entries)
|
||||
=> entries.FirstOrDefault(f => File.Exists(f.FilePath) && getFileType(f) is FileType.Audio);
|
||||
private static IEnumerable<TempFile> getAaxcFiles(IEnumerable<TempFile> entries)
|
||||
=> entries.Where(f => File.Exists(f.FilePath) && (getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase)));
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
@@ -24,9 +24,10 @@ public partial class DownloadOptions
|
||||
/// <summary>
|
||||
/// Initiate an audiobook download from the audible api.
|
||||
/// </summary>
|
||||
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook)
|
||||
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);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
//Some audiobooks will have incorrect chapters in the metadata returned from the license request,
|
||||
//but the metadata returned by the content metadata endpoint will be correct. Call the content
|
||||
@@ -36,9 +37,8 @@ public partial class DownloadOptions
|
||||
if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs)
|
||||
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
|
||||
|
||||
var options = BuildDownloadOptions(libraryBook, config, license);
|
||||
|
||||
return options;
|
||||
token.ThrowIfCancellationRequested();
|
||||
return BuildDownloadOptions(libraryBook, config, license);
|
||||
}
|
||||
|
||||
private class LicenseInfo
|
||||
@@ -57,16 +57,18 @@ public partial class DownloadOptions
|
||||
=> voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
|
||||
}
|
||||
|
||||
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
|
||||
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||
{
|
||||
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
|
||||
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
return new LicenseInfo(license);
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
//try to request a widevine content license using the user's spatial audio settings
|
||||
@@ -85,8 +87,8 @@ public partial class DownloadOptions
|
||||
return new LicenseInfo(contentLic);
|
||||
|
||||
using var client = new HttpClient();
|
||||
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
|
||||
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
|
||||
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token);
|
||||
var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
|
||||
|
||||
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
|
||||
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
|
||||
@@ -109,7 +111,6 @@ public partial class DownloadOptions
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||
{
|
||||
long chapterStartMs
|
||||
@@ -123,13 +124,6 @@ public partial class DownloadOptions
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||
};
|
||||
|
||||
if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
|
||||
{
|
||||
dlOptions.LibraryBookDto.BitRate = bitrate;
|
||||
dlOptions.LibraryBookDto.SampleRate = sampleRate;
|
||||
dlOptions.LibraryBookDto.Channels = channels;
|
||||
}
|
||||
|
||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||
var chapters
|
||||
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||
@@ -156,43 +150,6 @@ public partial class DownloadOptions
|
||||
return dlOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The most reliable way to get these audio file properties is from the filename itself.
|
||||
/// Using AAXClean to read the metadata works well for everything except AC-4 bitrate.
|
||||
/// </summary>
|
||||
private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels)
|
||||
{
|
||||
bitrate = sampleRate = channels = null;
|
||||
|
||||
if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri))
|
||||
return false;
|
||||
|
||||
var file = Path.GetFileName(uri.LocalPath);
|
||||
|
||||
var match = AdrmAudioProperties().Match(file);
|
||||
if (match.Success)
|
||||
{
|
||||
bitrate = int.Parse(match.Groups[1].Value);
|
||||
sampleRate = int.Parse(match.Groups[2].Value);
|
||||
channels = int.Parse(match.Groups[3].Value);
|
||||
return true;
|
||||
}
|
||||
else if ((match = WidevineAudioProperties().Match(file)).Success)
|
||||
{
|
||||
bitrate = int.Parse(match.Groups[2].Value);
|
||||
sampleRate = int.Parse(match.Groups[1].Value) * 1000;
|
||||
channels = match.Groups[3].Value switch
|
||||
{
|
||||
"ec3" => 6,
|
||||
"ac4" => 3,
|
||||
_ => null
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static LameConfig GetLameOptions(Configuration config)
|
||||
{
|
||||
LameConfig lameConfig = new()
|
||||
@@ -347,12 +304,4 @@ public partial class DownloadOptions
|
||||
chapters.Remove(chapters[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
static double RelativePercentDifference(long num1, long num2)
|
||||
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
|
||||
|
||||
[GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex WidevineAudioProperties();
|
||||
[GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||
private static partial Regex AdrmAudioProperties();
|
||||
}
|
||||
|
||||
@@ -3,10 +3,8 @@ using AAXClean;
|
||||
using Dinah.Core;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.IO;
|
||||
using ApplicationServices;
|
||||
using LibationFileManager.Templates;
|
||||
|
||||
#nullable enable
|
||||
@@ -28,15 +26,12 @@ namespace FileLiberator
|
||||
public string Language => LibraryBook.Book.Language;
|
||||
public string? AudibleProductId => LibraryBookDto.AudibleProductId;
|
||||
public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
|
||||
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
||||
public string? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
||||
public NAudio.Lame.LameConfig? LameConfig { get; }
|
||||
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||
public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio;
|
||||
public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
|
||||
public bool CreateCueSheet => Config.CreateCueSheet;
|
||||
public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks;
|
||||
public long DownloadSpeedBps => Config.DownloadSpeedLimit;
|
||||
public bool RetainEncryptedFile => Config.RetainAaxFile;
|
||||
public bool FixupFile => Config.AllowLibationFixup;
|
||||
public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
|
||||
public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
|
||||
@@ -45,45 +40,9 @@ namespace FileLiberator
|
||||
public AudibleApi.Common.DrmType DrmType { get; }
|
||||
public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
|
||||
|
||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||
{
|
||||
var baseDir = Path.GetDirectoryName(props.OutputFileName);
|
||||
var extension = Path.GetExtension(props.OutputFileName);
|
||||
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension);
|
||||
}
|
||||
|
||||
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
|
||||
|
||||
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
public Configuration Config { get; }
|
||||
private readonly IDisposable cancellation;
|
||||
public void Dispose()
|
||||
@@ -108,8 +67,8 @@ namespace FileLiberator
|
||||
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 == 8 && 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 == 32 && licInfo.DecryptionKeys[0].KeyPart2?.Length == 32 ? AAXClean.FileType.Aaxc
|
||||
: 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.
|
||||
@@ -123,7 +82,6 @@ namespace FileLiberator
|
||||
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
LibraryBookDto = LibraryBook.ToDto();
|
||||
LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec;
|
||||
|
||||
cancellation =
|
||||
config
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
|
||||
#nullable enable
|
||||
@@ -61,7 +62,13 @@ namespace FileLiberator
|
||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||
|
||||
Language = libraryBook.Book.Language
|
||||
Language = libraryBook.Book.Language,
|
||||
Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString,
|
||||
BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate,
|
||||
SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate,
|
||||
Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount,
|
||||
LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion?.ToVersionString(),
|
||||
FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +83,7 @@ namespace FileLiberator
|
||||
.Select(sb
|
||||
=> new SeriesDto(
|
||||
sb.Series.Name,
|
||||
sb.Book.IsEpisodeParent() ? null : sb.Index,
|
||||
sb.Book.IsEpisodeParent() ? null : sb.Order,
|
||||
sb.Series.AudibleSeriesId)
|
||||
).ToList();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||
<PackageReference Include="Polly" Version="8.5.2" />
|
||||
<PackageReference Include="Polly" Version="8.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
74
Source/FileManager/FileSystemTest.cs
Normal file
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));
|
||||
|
||||
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
|
||||
name = ReplacementCharacters.Barebones(true).ReplaceFilenameChars(name);
|
||||
return Task.Run(() => AddFileInternal(name, contents.Span, comment));
|
||||
}
|
||||
|
||||
|
||||
@@ -74,12 +74,14 @@ namespace FileManager
|
||||
}
|
||||
public override int GetHashCode() => Replacements.GetHashCode();
|
||||
|
||||
public static readonly ReplacementCharacters Default
|
||||
= IsWindows
|
||||
? new()
|
||||
{
|
||||
Replacements = new Replacement[]
|
||||
{
|
||||
public static ReplacementCharacters Default(bool ntfs) => ntfs ? HiFi_NTFS : HiFi_Other;
|
||||
public static ReplacementCharacters LoFiDefault(bool ntfs) => ntfs ? LoFi_NTFS : LoFi_Other;
|
||||
public static ReplacementCharacters Barebones(bool ntfs) => ntfs ? BareBones_NTFS : BareBones_Other;
|
||||
|
||||
#region Defaults
|
||||
private static readonly ReplacementCharacters HiFi_NTFS = new()
|
||||
{
|
||||
Replacements = [
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("∕"),
|
||||
Replacement.FilenameBackSlash(""),
|
||||
@@ -91,28 +93,23 @@ namespace FileManager
|
||||
Replacement.Colon("_"),
|
||||
Replacement.Asterisk("✱"),
|
||||
Replacement.QuestionMark("?"),
|
||||
Replacement.Pipe("⏐"),
|
||||
}
|
||||
}
|
||||
: new()
|
||||
{
|
||||
Replacements = new Replacement[]
|
||||
{
|
||||
Replacement.Pipe("⏐")]
|
||||
};
|
||||
|
||||
private static readonly ReplacementCharacters HiFi_Other = new()
|
||||
{
|
||||
Replacements = [
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("∕"),
|
||||
Replacement.FilenameBackSlash("\\"),
|
||||
Replacement.OpenQuote("“"),
|
||||
Replacement.CloseQuote("”"),
|
||||
Replacement.OtherQuote("\"")
|
||||
}
|
||||
};
|
||||
Replacement.OtherQuote("\"")]
|
||||
};
|
||||
|
||||
public static readonly ReplacementCharacters LoFiDefault
|
||||
= IsWindows
|
||||
? new()
|
||||
{
|
||||
Replacements = new Replacement[]
|
||||
{
|
||||
private static readonly ReplacementCharacters LoFi_NTFS = new()
|
||||
{
|
||||
Replacements = [
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("_"),
|
||||
@@ -121,56 +118,54 @@ namespace FileManager
|
||||
Replacement.OtherQuote("'"),
|
||||
Replacement.OpenAngleBracket("{"),
|
||||
Replacement.CloseAngleBracket("}"),
|
||||
Replacement.Colon("-"),
|
||||
}
|
||||
}
|
||||
: new ()
|
||||
{
|
||||
Replacements = new Replacement[]
|
||||
{
|
||||
Replacement.Colon("-")]
|
||||
};
|
||||
|
||||
private static readonly ReplacementCharacters LoFi_Other = new()
|
||||
{
|
||||
Replacements = [
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("\\"),
|
||||
Replacement.OpenQuote("\""),
|
||||
Replacement.CloseQuote("\""),
|
||||
Replacement.OtherQuote("\"")
|
||||
}
|
||||
};
|
||||
Replacement.OtherQuote("\"")]
|
||||
};
|
||||
|
||||
public static readonly ReplacementCharacters Barebones
|
||||
= IsWindows
|
||||
? new ()
|
||||
{
|
||||
Replacements = new Replacement[]
|
||||
{
|
||||
private static readonly ReplacementCharacters BareBones_NTFS = new()
|
||||
{
|
||||
Replacements = [
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("_"),
|
||||
Replacement.OpenQuote("_"),
|
||||
Replacement.CloseQuote("_"),
|
||||
Replacement.OtherQuote("_")
|
||||
}
|
||||
}
|
||||
: new ()
|
||||
{
|
||||
Replacements = new Replacement[]
|
||||
{
|
||||
Replacement.OtherQuote("_")]
|
||||
};
|
||||
|
||||
private static readonly ReplacementCharacters BareBones_Other = new()
|
||||
{
|
||||
Replacements = [
|
||||
Replacement.OtherInvalid("_"),
|
||||
Replacement.FilenameForwardSlash("_"),
|
||||
Replacement.FilenameBackSlash("\\"),
|
||||
Replacement.OpenQuote("\""),
|
||||
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
|
||||
}).ToArray();
|
||||
|
||||
private static readonly char[] invalidSlashes = Path.GetInvalidFileNameChars().Intersect(new[] {
|
||||
private static char[] invalidSlashes { get; } = Path.GetInvalidFileNameChars().Intersect(new[] {
|
||||
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
|
||||
}).ToArray();
|
||||
|
||||
@@ -229,8 +224,11 @@ namespace FileManager
|
||||
return DefaultReplacement;
|
||||
}
|
||||
|
||||
private static bool CharIsPathInvalid(char c)
|
||||
=> invalidPathChars.Contains(c) || AdditionalInvalidFilenameCharacters.Contains(c);
|
||||
|
||||
public static bool ContainsInvalidPathChar(string path)
|
||||
=> path.Any(c => invalidPathChars.Contains(c));
|
||||
=> path.Any(CharIsPathInvalid);
|
||||
public static bool ContainsInvalidFilenameChar(string path)
|
||||
=> ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c));
|
||||
|
||||
@@ -242,7 +240,7 @@ namespace FileManager
|
||||
{
|
||||
var c = fileName[i];
|
||||
|
||||
if (invalidPathChars.Contains(c)
|
||||
if (CharIsPathInvalid(c)
|
||||
|| invalidSlashes.Contains(c)
|
||||
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
|
||||
{
|
||||
@@ -267,14 +265,14 @@ namespace FileManager
|
||||
|
||||
if (
|
||||
(
|
||||
invalidPathChars.Contains(c)
|
||||
|| ( // Replace any other legal characters that they user wants.
|
||||
CharIsPathInvalid(c)
|
||||
|| ( // Replace any other legal characters that they user wants.
|
||||
c != Path.DirectorySeparatorChar
|
||||
&& c != Path.AltDirectorySeparatorChar
|
||||
&& 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 == ':'
|
||||
&& i == 1
|
||||
&& Path.IsPathRooted(pathStr)
|
||||
@@ -282,9 +280,9 @@ namespace FileManager
|
||||
)
|
||||
)
|
||||
{
|
||||
char preceding = i > 0 ? pathStr[i - 1] : default;
|
||||
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
||||
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
||||
char preceding = i > 0 ? pathStr[i - 1] : default;
|
||||
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
||||
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
||||
}
|
||||
else
|
||||
builder.Append(c);
|
||||
@@ -301,23 +299,21 @@ namespace FileManager
|
||||
|
||||
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 replaceArr = jObj[nameof(Replacement)];
|
||||
var dict
|
||||
= replaceArr?.ToObject<Replacement[]>()?.ToList()
|
||||
?? ReplacementCharacters.Default.Replacements;
|
||||
|
||||
var dict = replaceArr?.ToObject<Replacement[]>()?.ToList() ?? defaults;
|
||||
|
||||
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
|
||||
//If not, reset to default.
|
||||
|
||||
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
|
||||
{
|
||||
if (dict.Count < Replacement.FIXED_COUNT
|
||||
|| dict[i].CharacterToReplace != ReplacementCharacters.Barebones.Replacements[i].CharacterToReplace
|
||||
|| dict[i].Description != ReplacementCharacters.Barebones.Replacements[i].Description)
|
||||
|| dict[i].CharacterToReplace != defaults[i].CharacterToReplace
|
||||
|| dict[i].Description != defaults[i].Description)
|
||||
{
|
||||
dict = ReplacementCharacters.Default.Replacements;
|
||||
dict = defaults;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,12 +71,12 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
|
||||
@@ -16,6 +16,8 @@ using Dinah.Core;
|
||||
using LibationAvalonia.Themes;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System.Linq;
|
||||
using LibationUiBase.Forms;
|
||||
using Avalonia.Controls;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia
|
||||
@@ -42,6 +44,9 @@ namespace LibationAvalonia
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
|
||||
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
@@ -106,7 +111,7 @@ namespace LibationAvalonia
|
||||
if (setupDialog.Config.LibationSettingsAreValid)
|
||||
{
|
||||
string? theme = setupDialog.SelectedTheme.Content as string;
|
||||
|
||||
|
||||
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
|
||||
|
||||
await RunMigrationsAsync(setupDialog.Config);
|
||||
@@ -115,7 +120,10 @@ namespace LibationAvalonia
|
||||
ShowMainWindow(desktop);
|
||||
}
|
||||
else
|
||||
await CancelInstallation();
|
||||
{
|
||||
e.Cancel = true;
|
||||
await CancelInstallation(setupDialog);
|
||||
}
|
||||
}
|
||||
else if (setupDialog.IsReturningUser)
|
||||
{
|
||||
@@ -123,7 +131,8 @@ namespace LibationAvalonia
|
||||
}
|
||||
else
|
||||
{
|
||||
await CancelInstallation();
|
||||
e.Cancel = true;
|
||||
await CancelInstallation(setupDialog);
|
||||
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.";
|
||||
try
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(null, body, title, ex);
|
||||
await MessageBox.ShowAdminAlert(setupDialog, body, title, ex);
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -185,6 +194,7 @@ namespace LibationAvalonia
|
||||
{
|
||||
// path did not result in valid settings
|
||||
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}",
|
||||
"New install?",
|
||||
MessageBoxButtons.YesNo,
|
||||
@@ -202,18 +212,18 @@ namespace LibationAvalonia
|
||||
ShowMainWindow(desktop);
|
||||
}
|
||||
else
|
||||
await CancelInstallation();
|
||||
await CancelInstallation(libationFilesDialog);
|
||||
}
|
||||
else
|
||||
await CancelInstallation();
|
||||
await CancelInstallation(libationFilesDialog);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Avalonia.Markup.Xaml.MarkupExtensions;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.VisualTree;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace LibationAvalonia.Controls
|
||||
{
|
||||
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
|
||||
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
|
||||
ele.IsThreeState = dataItem is ISeriesEntry;
|
||||
ele.IsThreeState = dataItem is SeriesEntry;
|
||||
return ele;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ namespace LibationAvalonia.Controls
|
||||
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (sender is DataGridCell cell &&
|
||||
cell.DataContext is IGridEntry clickedEntry &&
|
||||
cell.DataContext is GridEntry clickedEntry &&
|
||||
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
|
||||
OwningGridProperty.GetValue(column) is DataGrid grid)
|
||||
{
|
||||
var allSelected = grid.SelectedItems.OfType<IGridEntry>().ToArray();
|
||||
var allSelected = grid.SelectedItems.OfType<GridEntry>().ToArray();
|
||||
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
|
||||
if (clickedIndex == -1)
|
||||
{
|
||||
@@ -101,7 +101,7 @@ namespace LibationAvalonia.Controls
|
||||
private static string RemoveLineBreaks(string text)
|
||||
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
|
||||
|
||||
private string GetRowClipboardContents(IGridEntry gridEntry)
|
||||
private string GetRowClipboardContents(GridEntry gridEntry)
|
||||
{
|
||||
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
|
||||
return string.Join("\t", contents);
|
||||
@@ -109,7 +109,7 @@ namespace LibationAvalonia.Controls
|
||||
|
||||
public required DataGrid Grid { get; init; }
|
||||
public required DataGridColumn Column { get; init; }
|
||||
public required IGridEntry[] GridEntries { get; init; }
|
||||
public required GridEntry[] GridEntries { get; init; }
|
||||
public required ContextMenu ContextMenu { get; init; }
|
||||
public AvaloniaList<Control> ContextMenuItems
|
||||
=> ContextMenu.ItemsSource as AvaloniaList<Control>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
@@ -90,7 +91,7 @@ namespace LibationAvalonia.Controls
|
||||
|
||||
var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options);
|
||||
|
||||
directoryState.CustomDir = selectedFolders.SingleOrDefault()?.Path?.LocalPath ?? directoryState.CustomDir;
|
||||
directoryState.CustomDir = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? directoryState.CustomDir;
|
||||
}
|
||||
|
||||
private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
|
||||
|
||||
@@ -92,13 +92,11 @@ namespace LibationAvalonia.Controls
|
||||
base.UpdateDataValidation(property, state, error);
|
||||
if (property == CommandProperty)
|
||||
{
|
||||
if (state == BindingValueType.BindingError)
|
||||
var canExecure = !state.HasFlag(BindingValueType.HasError);
|
||||
if (canExecure != _commandCanExecute)
|
||||
{
|
||||
if (_commandCanExecute)
|
||||
{
|
||||
_commandCanExecute = false;
|
||||
UpdateIsEffectivelyEnabled();
|
||||
}
|
||||
_commandCanExecute = canExecure;
|
||||
UpdateIsEffectivelyEnabled();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Controls.Settings
|
||||
|
||||
@@ -58,10 +58,5 @@ namespace LibationAvalonia.Controls.Settings
|
||||
}
|
||||
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
|
||||
}
|
||||
|
||||
public void OpenLogFolderButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media.Imaging;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationFileManager;
|
||||
using NPOI.Util.Collections;
|
||||
using LibationUiBase.ProcessQueue;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@@ -38,11 +36,11 @@ public partial class ThemePreviewControl : UserControl
|
||||
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
|
||||
}
|
||||
|
||||
QueuedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Queued };
|
||||
WorkingBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Working };
|
||||
CompletedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Completed };
|
||||
CancelledBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Cancelled };
|
||||
FailedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Failed };
|
||||
QueuedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Queued };
|
||||
WorkingBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Working };
|
||||
CompletedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Completed };
|
||||
CancelledBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Cancelled };
|
||||
FailedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Failed };
|
||||
|
||||
//Set the current processable so that the empty queue doesn't try to advance.
|
||||
QueuedBook.AddDownloadPdf();
|
||||
|
||||
@@ -3,6 +3,7 @@ using LibationAvalonia.Controls;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -60,7 +61,7 @@ namespace LibationAvalonia.Dialogs
|
||||
private void Link_getlibation(object sender, Avalonia.Input.TappedEventArgs e) => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.WebsiteUrl);
|
||||
|
||||
private void ViewReleaseNotes_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToString(3)}");
|
||||
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToVersionString()}");
|
||||
}
|
||||
|
||||
public class AboutVM : ViewModelBase
|
||||
|
||||
@@ -3,6 +3,7 @@ using AudibleUtilities;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -7,6 +7,7 @@ using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using FileLiberator;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Styling;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
||||
@@ -24,9 +24,8 @@
|
||||
CanUserSortColumns="False"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="False"
|
||||
ItemsSource="{Binding Filters}"
|
||||
ItemsSource="{CompiledBinding Filters}"
|
||||
GridLinesVisibility="All">
|
||||
|
||||
<DataGrid.Columns>
|
||||
|
||||
<DataGridTemplateColumn Header="Delete">
|
||||
@@ -38,7 +37,7 @@
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
IsEnabled="{Binding !IsDefault}"
|
||||
IsEnabled="{CompiledBinding !IsDefault}"
|
||||
Click="DeleteButton_Clicked" />
|
||||
|
||||
</DataTemplate>
|
||||
@@ -48,14 +47,13 @@
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
IsReadOnly="False"
|
||||
Binding="{Binding Name, Mode=TwoWay}"
|
||||
Binding="{CompiledBinding Name, Mode=TwoWay}"
|
||||
Header="Name"/>
|
||||
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
IsReadOnly="False"
|
||||
Binding="{Binding FilterString, Mode=TwoWay}"
|
||||
Binding="{CompiledBinding FilterString, Mode=TwoWay}"
|
||||
Header="Filter"/>
|
||||
|
||||
<DataGridTemplateColumn Header="Move
Up">
|
||||
@@ -67,16 +65,19 @@
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
IsEnabled="{Binding !IsDefault}"
|
||||
ToolTip.Tip="Export account authorization to audible-cli"
|
||||
Click="MoveUpButton_Clicked" />
|
||||
Click="MoveUpButton_Clicked">
|
||||
<Button.IsEnabled>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<CompiledBinding Path="!IsTop" />
|
||||
<CompiledBinding Path="!IsDefault" />
|
||||
</MultiBinding>
|
||||
</Button.IsEnabled>
|
||||
</Button>
|
||||
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
|
||||
|
||||
|
||||
<DataGridTemplateColumn Header="Move
Down">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
@@ -86,15 +87,18 @@
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
IsEnabled="{Binding !IsDefault}"
|
||||
ToolTip.Tip="Export account authorization to audible-cli"
|
||||
Click="MoveDownButton_Clicked" />
|
||||
Click="MoveDownButton_Clicked">
|
||||
<Button.IsEnabled>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<CompiledBinding Path="!IsBottom" />
|
||||
<CompiledBinding Path="!IsDefault" />
|
||||
</MultiBinding>
|
||||
</Button.IsEnabled>
|
||||
</Button>
|
||||
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
<Grid
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class EditQuickFilters : DialogWindow
|
||||
{
|
||||
public ObservableCollection<Filter> Filters { get; } = new();
|
||||
public AvaloniaList<Filter> Filters { get; } = new();
|
||||
|
||||
public class Filter : ViewModels.ViewModelBase
|
||||
{
|
||||
@@ -17,11 +17,8 @@ namespace LibationAvalonia.Dialogs
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _name, value);
|
||||
}
|
||||
}
|
||||
set => this.RaiseAndSetIfChanged(ref _name, value);
|
||||
}
|
||||
|
||||
private string _filterString;
|
||||
public string FilterString
|
||||
@@ -35,6 +32,10 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
}
|
||||
public bool IsDefault { get; private set; } = true;
|
||||
private bool _isTop;
|
||||
private bool _isBottom;
|
||||
public bool IsTop { get => _isTop; set => this.RaiseAndSetIfChanged(ref _isTop, value); }
|
||||
public bool IsBottom { get => _isBottom; set => this.RaiseAndSetIfChanged(ref _isBottom, value); }
|
||||
|
||||
public QuickFilters.NamedFilter AsNamedFilter() => new(FilterString, Name);
|
||||
|
||||
@@ -44,12 +45,12 @@ namespace LibationAvalonia.Dialogs
|
||||
InitializeComponent();
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
Filters = new ObservableCollection<Filter>([
|
||||
new Filter { Name = "Filter 1", FilterString = "[filter1 string]" },
|
||||
Filters = [
|
||||
new Filter { Name = "Filter 1", FilterString = "[filter1 string]", IsTop = true },
|
||||
new Filter { Name = "Filter 2", FilterString = "[filter2 string]" },
|
||||
new Filter { Name = "Filter 3", FilterString = "[filter3 string]" },
|
||||
new Filter { Name = "Filter 4", FilterString = "[filter4 string]" }
|
||||
]);
|
||||
new Filter { Name = "Filter 4", FilterString = "[filter4 string]", IsBottom = true },
|
||||
new Filter()];
|
||||
DataContext = this;
|
||||
return;
|
||||
}
|
||||
@@ -65,6 +66,8 @@ namespace LibationAvalonia.Dialogs
|
||||
ControlToFocusOnShow = this.FindControl<Button>(nameof(saveBtn));
|
||||
|
||||
var allFilters = QuickFilters.Filters.Select(f => new Filter { FilterString = f.Filter, Name = f.Name }).ToList();
|
||||
allFilters[0].IsTop = true;
|
||||
allFilters[^1].IsBottom = true;
|
||||
allFilters.Add(new Filter());
|
||||
|
||||
foreach (var f in allFilters)
|
||||
@@ -81,6 +84,7 @@ namespace LibationAvalonia.Dialogs
|
||||
var newBlank = new Filter();
|
||||
newBlank.PropertyChanged += Filter_PropertyChanged;
|
||||
Filters.Insert(Filters.Count, newBlank);
|
||||
ReIndexFilters();
|
||||
}
|
||||
|
||||
protected override void SaveAndClose()
|
||||
@@ -98,30 +102,54 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
filter.PropertyChanged -= Filter_PropertyChanged;
|
||||
Filters.Remove(filter);
|
||||
ReIndexFilters();
|
||||
}
|
||||
}
|
||||
|
||||
public void MoveUpButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (e.Source is Button btn && btn.DataContext is Filter filter)
|
||||
{
|
||||
var index = Filters.IndexOf(filter);
|
||||
if (index < 1) return;
|
||||
if (e.Source is not Button btn || btn.DataContext is not Filter filter || filter.IsDefault)
|
||||
return;
|
||||
|
||||
Filters.Remove(filter);
|
||||
Filters.Insert(index - 1, filter);
|
||||
}
|
||||
var oldIndex = Filters.IndexOf(filter);
|
||||
if (oldIndex < 1) return;
|
||||
|
||||
var filterCount = Filters.Count(f => !f.IsDefault);
|
||||
|
||||
MoveFilter(oldIndex, oldIndex - 1, filterCount);
|
||||
}
|
||||
|
||||
public void MoveDownButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (e.Source is Button btn && btn.DataContext is Filter filter)
|
||||
{
|
||||
var index = Filters.IndexOf(filter);
|
||||
if (index >= Filters.Count - 2) return;
|
||||
if (e.Source is not Button btn || btn.DataContext is not Filter filter || filter.IsDefault)
|
||||
return;
|
||||
|
||||
Filters.Remove(filter);
|
||||
Filters.Insert(index + 1, filter);
|
||||
var filterCount = Filters.Count(f => !f.IsDefault);
|
||||
var oldIndex = Filters.IndexOf(filter);
|
||||
if (oldIndex >= filterCount - 1) return;
|
||||
|
||||
MoveFilter(oldIndex, oldIndex + 1, filterCount);
|
||||
}
|
||||
|
||||
private void MoveFilter(int oldIndex, int newIndex, int filterCount)
|
||||
{
|
||||
var filter = Filters[oldIndex];
|
||||
Filters.RemoveAt(oldIndex);
|
||||
Filters.Insert(newIndex, filter);
|
||||
|
||||
Filters[oldIndex].IsTop = oldIndex == 0;
|
||||
Filters[newIndex].IsTop = newIndex == 0;
|
||||
Filters[newIndex].IsBottom = newIndex == filterCount - 1;
|
||||
Filters[oldIndex].IsBottom = oldIndex == filterCount - 1;
|
||||
}
|
||||
|
||||
private void ReIndexFilters()
|
||||
{
|
||||
var filterCount = Filters.Count(f => !f.IsDefault);
|
||||
for (int i = filterCount - 1; i >= 0; i--)
|
||||
{
|
||||
Filters[i].IsTop = i == 0;
|
||||
Filters[i].IsBottom = i == filterCount - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
MinWidth="500" MinHeight="450"
|
||||
Width="500" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.EditReplacementChars"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:EditReplacementChars"
|
||||
Title="Illegal Character Replacement">
|
||||
|
||||
<Grid
|
||||
@@ -23,31 +25,30 @@
|
||||
BeginningEdit="ReplacementGrid_BeginningEdit"
|
||||
CellEditEnding="ReplacementGrid_CellEditEnding"
|
||||
KeyDown="ReplacementGrid_KeyDown"
|
||||
ItemsSource="{Binding replacements}">
|
||||
ItemsSource="{CompiledBinding replacements}">
|
||||
|
||||
<DataGrid.Columns>
|
||||
|
||||
|
||||
<DataGridTemplateColumn Header="Char to
Replace">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBox IsReadOnly="{Binding Mandatory}" Text="{Binding CharacterToReplace, Mode=TwoWay}" />
|
||||
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
|
||||
<TextBox IsReadOnly="{CompiledBinding Mandatory}" Text="{CompiledBinding CharacterToReplace, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Header="Replacement
Text">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBox Text="{Binding ReplacementText, Mode=TwoWay}" />
|
||||
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
|
||||
<TextBox Text="{CompiledBinding ReplacementText, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Width="*" Header="Description">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBox IsReadOnly="{Binding Mandatory}" Text="{Binding Description, Mode=TwoWay}" />
|
||||
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
|
||||
<TextBox IsReadOnly="{CompiledBinding Mandatory}" Text="{CompiledBinding Description, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
@@ -55,21 +56,31 @@
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<StackPanel
|
||||
<Grid
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
RowDefinitions="Auto,Auto"
|
||||
Margin="5"
|
||||
Orientation="Horizontal">
|
||||
|
||||
<Button Margin="0,0,10,0" Command="{Binding Defaults}" Content="Defaults" />
|
||||
<Button Margin="0,0,10,0" Command="{Binding LoFiDefaults}" Content="LoFi Defaults" />
|
||||
<Button Command="{Binding Barebones}" Content="Barebones" />
|
||||
</StackPanel>
|
||||
ColumnDefinitions="Auto,Auto,Auto,Auto">
|
||||
|
||||
<TextBlock IsVisible="{CompiledBinding !EnvironmentIsWindows}" Text="This System:" Margin="0,0,10,0" VerticalAlignment="Center" />
|
||||
<TextBlock IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Text="NTFS:" Margin="0,0,10,0" VerticalAlignment="Center" />
|
||||
|
||||
<Button Grid.Column="1" Margin="0,0,10,0" Command="{CompiledBinding Defaults}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="Defaults" />
|
||||
<Button Grid.Column="2" Margin="0,0,10,0" Command="{CompiledBinding LoFiDefaults}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="LoFi Defaults" />
|
||||
<Button Grid.Column="3" Command="{CompiledBinding Barebones}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="Barebones" />
|
||||
|
||||
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="1" Margin="0,10,10,0" Command="{CompiledBinding Defaults}" CommandParameter="True" Content="Defaults" />
|
||||
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="2" Margin="0,10,10,0" Command="{CompiledBinding LoFiDefaults}" CommandParameter="True" Content="LoFi Defaults" />
|
||||
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="3" Margin="0,10,0,0" Command="{CompiledBinding Barebones}" CommandParameter="True" Content="Barebones" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="5"
|
||||
VerticalAlignment="Bottom"
|
||||
Orientation="Horizontal">
|
||||
|
||||
<Button Margin="0,0,10,0" Command="{Binding Close}" Content="Cancel" />
|
||||
|
||||
@@ -13,6 +13,8 @@ namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
Configuration config;
|
||||
|
||||
public bool EnvironmentIsWindows => Configuration.IsWindows;
|
||||
|
||||
private readonly List<ReplacementsExt> SOURCE = new();
|
||||
public DataGridCollectionView replacements { get; }
|
||||
public EditReplacementChars()
|
||||
@@ -23,7 +25,7 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
LoadTable(ReplacementCharacters.Default.Replacements);
|
||||
LoadTable(ReplacementCharacters.Default(true).Replacements);
|
||||
}
|
||||
|
||||
DataContext = this;
|
||||
@@ -35,12 +37,12 @@ namespace LibationAvalonia.Dialogs
|
||||
LoadTable(config.ReplacementCharacters.Replacements);
|
||||
}
|
||||
|
||||
public void Defaults()
|
||||
=> LoadTable(ReplacementCharacters.Default.Replacements);
|
||||
public void LoFiDefaults()
|
||||
=> LoadTable(ReplacementCharacters.LoFiDefault.Replacements);
|
||||
public void Barebones()
|
||||
=> LoadTable(ReplacementCharacters.Barebones.Replacements);
|
||||
public void Defaults(bool isNtfs)
|
||||
=> LoadTable(ReplacementCharacters.Default(isNtfs).Replacements);
|
||||
public void LoFiDefaults(bool isNtfs)
|
||||
=> LoadTable(ReplacementCharacters.LoFiDefault(isNtfs).Replacements);
|
||||
public void Barebones(bool isNtfs)
|
||||
=> LoadTable(ReplacementCharacters.Barebones(isNtfs).Replacements);
|
||||
|
||||
protected override void SaveAndClose()
|
||||
{
|
||||
|
||||
@@ -6,24 +6,28 @@
|
||||
Width="800" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.EditTemplateDialog"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
x:DataType="dialogs:EditTemplateDialog+EditTemplateViewModel"
|
||||
Title="EditTemplateDialog">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<Grid RowDefinitions="Auto,*,Auto" Margin="10">
|
||||
<Grid
|
||||
Grid.Row="0"
|
||||
RowDefinitions="Auto,Auto"
|
||||
ColumnDefinitions="*,Auto" Margin="5">
|
||||
ColumnDefinitions="*,Auto"
|
||||
Margin="0,0,0,10">
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
Text="{Binding Description}" />
|
||||
Margin="0,0,0,10"
|
||||
Text="{CompiledBinding Description}" />
|
||||
|
||||
<TextBox
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
Name="userEditTbox"
|
||||
Text="{Binding UserTemplateText, Mode=TwoWay}" />
|
||||
Text="{CompiledBinding UserTemplateText, Mode=TwoWay}" />
|
||||
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
@@ -32,9 +36,10 @@
|
||||
VerticalAlignment="Stretch"
|
||||
VerticalContentAlignment="Center"
|
||||
Content="Reset to Default"
|
||||
Command="{Binding ResetToDefault}"/>
|
||||
Command="{CompiledBinding ResetToDefault}"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*"
|
||||
Margin="0,0,0,10">
|
||||
|
||||
<DataGrid
|
||||
Grid.Row="0"
|
||||
@@ -44,13 +49,13 @@
|
||||
GridLinesVisibility="All"
|
||||
AutoGenerateColumns="False"
|
||||
DoubleTapped="EditTemplateViewModel_DoubleTapped"
|
||||
ItemsSource="{Binding ListItems}" >
|
||||
ItemsSource="{CompiledBinding ListItems}" >
|
||||
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" Header="Tag">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
|
||||
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{CompiledBinding Item1}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
@@ -61,7 +66,7 @@
|
||||
<TextBlock
|
||||
Height="18"
|
||||
Margin="10,0,10,0"
|
||||
VerticalAlignment="Center" Text="{Binding Item2}" />
|
||||
VerticalAlignment="Center" Text="{CompiledBinding Item2}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
@@ -71,23 +76,22 @@
|
||||
|
||||
<Grid
|
||||
Grid.Column="1"
|
||||
Margin="5,0,5,0"
|
||||
Margin="10,0,0,0"
|
||||
RowDefinitions="Auto,*,Auto"
|
||||
HorizontalAlignment="Stretch">
|
||||
|
||||
<TextBlock
|
||||
Margin="5,5,5,10"
|
||||
Margin="0,0,0,5"
|
||||
Text="Example:"/>
|
||||
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Margin="5"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource DataGridGridLinesBrush}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="WrapWithOverflow"
|
||||
Inlines="{Binding Inlines}" />
|
||||
Inlines="{CompiledBinding Inlines}" />
|
||||
|
||||
</Border>
|
||||
|
||||
@@ -95,13 +99,17 @@
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
Foreground="Firebrick"
|
||||
Text="{Binding WarningText}"
|
||||
IsVisible="{Binding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
Text="{CompiledBinding WarningText}"
|
||||
IsVisible="{CompiledBinding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<controls:LinkLabel
|
||||
Grid.Row="2"
|
||||
VerticalAlignment="Center"
|
||||
Text="Read about naming templates on the Wiki"
|
||||
Command="{Binding GoToNamingTemplateWiki}" />
|
||||
<Button
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
Padding="30,5,30,5"
|
||||
HorizontalAlignment="Right"
|
||||
Content="Save"
|
||||
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Styling;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.IO;
|
||||
@@ -69,7 +70,7 @@ public partial class EditTemplateDialog : DialogWindow
|
||||
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await SaveAndCloseAsync();
|
||||
|
||||
private class EditTemplateViewModel : ViewModels.ViewModelBase
|
||||
internal class EditTemplateViewModel : ViewModels.ViewModelBase
|
||||
{
|
||||
private readonly Configuration config;
|
||||
public InlineCollection Inlines { get; } = new();
|
||||
@@ -95,6 +96,9 @@ public partial class EditTemplateDialog : DialogWindow
|
||||
|
||||
}
|
||||
|
||||
public void GoToNamingTemplateWiki()
|
||||
=> Go.To.Url(@"ht" + "tps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md");
|
||||
|
||||
// hold the work-in-progress value. not guaranteed to be valid
|
||||
private string _userTemplateText;
|
||||
public string UserTemplateText
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
|
||||
@@ -13,11 +13,12 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class LocateAudiobooksDialog : DialogWindow
|
||||
{
|
||||
private event EventHandler<FilePathCache.CacheEntry> FileFound;
|
||||
private event EventHandler<FilePathCache.CacheEntry>? FileFound;
|
||||
private readonly CancellationTokenSource tokenSource = new();
|
||||
private readonly List<string> foundAsins = new();
|
||||
private readonly LocatedAudiobooksViewModel _viewModel;
|
||||
@@ -41,7 +42,7 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
}
|
||||
|
||||
private void LocateAudiobooksDialog_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||
private void LocateAudiobooksDialog_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
tokenSource.Cancel();
|
||||
//If this dialog is closed before it's completed, Closing is fired
|
||||
@@ -50,7 +51,7 @@ namespace LibationAvalonia.Dialogs
|
||||
this.SaveSizeAndLocation(Configuration.Instance);
|
||||
}
|
||||
|
||||
private void LocateAudiobooks_FileFound(object sender, FilePathCache.CacheEntry e)
|
||||
private void LocateAudiobooks_FileFound(object? sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
var newItem = new Tuple<string, string>($"[{e.Id}]", Path.GetFileName(e.Path));
|
||||
_viewModel.FoundFiles.Add(newItem);
|
||||
@@ -63,13 +64,13 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
}
|
||||
|
||||
private async void LocateAudiobooksDialog_Opened(object sender, EventArgs e)
|
||||
private async void LocateAudiobooksDialog_Opened(object? sender, EventArgs e)
|
||||
{
|
||||
var folderPicker = new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Select the folder to search for audiobooks",
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix)
|
||||
SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books?.PathWithoutPrefix ?? "")
|
||||
};
|
||||
|
||||
var selectedFolder = (await StorageProvider.OpenFolderPickerAsync(folderPicker))?.SingleOrDefault()?.TryGetLocalPath();
|
||||
@@ -89,11 +90,13 @@ namespace LibationAvalonia.Dialogs
|
||||
FilePathCache.Insert(book);
|
||||
|
||||
var lb = context.GetLibraryBook_Flat_NoTracking(book.Id);
|
||||
if (lb?.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
|
||||
if (lb is not null && lb.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
|
||||
await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated));
|
||||
|
||||
tokenSource.Token.ThrowIfCancellationRequested();
|
||||
FileFound?.Invoke(this, book);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Threading;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs.Login
|
||||
@@ -16,42 +18,46 @@ namespace LibationAvalonia.Dialogs.Login
|
||||
}
|
||||
|
||||
public async Task<string> Get2faCodeAsync(string prompt)
|
||||
{
|
||||
var dialog = new _2faCodeDialog(prompt);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return dialog.Code;
|
||||
|
||||
return null;
|
||||
}
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var dialog = new _2faCodeDialog(prompt);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return dialog.Code;
|
||||
return null;
|
||||
});
|
||||
|
||||
public async Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
|
||||
{
|
||||
var dialog = new CaptchaDialog(password, captchaImage);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return (dialog.Password, dialog.Answer);
|
||||
return (null, null);
|
||||
}
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var dialog = new CaptchaDialog(password, captchaImage);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return (dialog.Password, dialog.Answer);
|
||||
return (null, null);
|
||||
});
|
||||
|
||||
public async Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
|
||||
{
|
||||
var dialog = new MfaDialog(mfaConfig);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return (dialog.SelectedName, dialog.SelectedValue);
|
||||
return (null, null);
|
||||
}
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var dialog = new MfaDialog(mfaConfig);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return (dialog.SelectedName, dialog.SelectedValue);
|
||||
return (null, null);
|
||||
});
|
||||
|
||||
public async Task<(string email, string password)> GetLoginAsync()
|
||||
{
|
||||
var dialog = new LoginCallbackDialog(_account);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return (_account.AccountId, dialog.Password);
|
||||
return (null, null);
|
||||
}
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var dialog = new LoginCallbackDialog(_account);
|
||||
if (await dialog.ShowDialogAsync() is DialogResult.OK)
|
||||
return (_account.AccountId, dialog.Password);
|
||||
return (null, null);
|
||||
});
|
||||
|
||||
public async Task ShowApprovalNeededAsync()
|
||||
{
|
||||
var dialog = new ApprovalNeededDialog();
|
||||
await dialog.ShowDialogAsync();
|
||||
}
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
var dialog = new ApprovalNeededDialog();
|
||||
await dialog.ShowDialogAsync();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Threading;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -20,6 +22,9 @@ namespace LibationAvalonia.Dialogs.Login
|
||||
}
|
||||
|
||||
public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(() => StartAsyncInternal(choiceIn));
|
||||
|
||||
private async Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn)
|
||||
{
|
||||
if (Configuration.IsWindows && Environment.OSVersion.Version.Major >= 10)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.Dialogs.Login
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels"
|
||||
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Dialogs"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110"
|
||||
MinWidth="265" MinHeight="110"
|
||||
x:DataType="vm:MessageBoxViewModel"
|
||||
x:Class="LibationAvalonia.Dialogs.MessageBoxWindow"
|
||||
Title="{Binding Caption}" ShowInTaskbar="True">
|
||||
Title="{CompiledBinding Caption}" ShowInTaskbar="True">
|
||||
|
||||
<Grid ColumnDefinitions="*" RowDefinitions="*,Auto">
|
||||
|
||||
@@ -14,14 +15,22 @@
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal"
|
||||
VerticalAlignment="Top">
|
||||
|
||||
<Panel Height="32" Width="32" Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
|
||||
<Image IsVisible="{Binding IsAsterisk}" Stretch="Uniform" Source="/Assets/MBIcons/Asterisk_64.png"/>
|
||||
<Image IsVisible="{Binding IsError}" Stretch="Uniform" Source="/Assets/MBIcons/Error_64.png"/>
|
||||
<Image IsVisible="{Binding IsQuestion}" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
|
||||
<Image IsVisible="{Binding IsExclamation}" Stretch="Uniform" Source="/Assets/MBIcons/Exclamation_64.png"/>
|
||||
<Panel Height="32" Width="32" Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
|
||||
<Panel.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.Or}">
|
||||
<CompiledBinding Path="IsAsterisk" />
|
||||
<CompiledBinding Path="IsError" />
|
||||
<CompiledBinding Path="IsQuestion" />
|
||||
<CompiledBinding Path="IsExclamation" />
|
||||
</MultiBinding>
|
||||
</Panel.IsVisible>
|
||||
<Image IsVisible="{CompiledBinding IsAsterisk}" Stretch="Uniform" Source="/Assets/MBIcons/Asterisk_64.png"/>
|
||||
<Image IsVisible="{CompiledBinding IsError}" Stretch="Uniform" Source="/Assets/MBIcons/Error_64.png"/>
|
||||
<Image IsVisible="{CompiledBinding IsQuestion}" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
|
||||
<Image IsVisible="{CompiledBinding IsExclamation}" Stretch="Uniform" Source="/Assets/MBIcons/Exclamation_64.png"/>
|
||||
</Panel>
|
||||
|
||||
<TextBlock Margin="5,0,0,0" Name="messageTextBlock" MinHeight="45" MinWidth="193" TextWrapping="WrapWithOverflow" HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" Text="{Binding Message}" />
|
||||
<TextBlock Margin="5,0,0,0" Name="messageTextBlock" MinHeight="45" MinWidth="193" TextWrapping="WrapWithOverflow" HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" Text="{CompiledBinding Message}" />
|
||||
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
@@ -35,13 +44,13 @@
|
||||
</DockPanel.Styles>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
|
||||
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button1Text}"/>
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button1Text}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button2Text}"/>
|
||||
<Button Grid.Column="1" IsVisible="{CompiledBinding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button2Text}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button3Text}"/>
|
||||
<Button Grid.Column="2" IsVisible="{CompiledBinding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button3Text}"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationAvalonia.ViewModels.Dialogs;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
@@ -2,71 +2,73 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650"
|
||||
MinWidth="800" MinHeight="650"
|
||||
MaxWidth="800" MaxHeight="650"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:SearchSyntaxDialog"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="50"
|
||||
MinWidth="500" MinHeight="650"
|
||||
Width="800" Height="650"
|
||||
x:Class="LibationAvalonia.Dialogs.SearchSyntaxDialog"
|
||||
Title="Filter Options"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
|
||||
<Grid
|
||||
Margin="10,0,10,10"
|
||||
RowDefinitions="Auto,Auto,*"
|
||||
ColumnDefinitions="Auto,Auto,Auto,Auto">
|
||||
RowDefinitions="Auto,*"
|
||||
ColumnDefinitions="*,*,*,*">
|
||||
|
||||
<Grid.Styles>
|
||||
<Style Selector="Grid > Grid">
|
||||
<Setter Property="Margin" Value="10,0" />
|
||||
</Style>
|
||||
<Style Selector="Grid > TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Margin" Value="10" />
|
||||
<Setter Property="Margin" Value="0,5" />
|
||||
</Style>
|
||||
<Style Selector="ListBox">
|
||||
<Setter Property="Margin" Value="0,5,0,10"/>
|
||||
<Style Selector="^ > ListBoxItem">
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
<Style Selector="^ TextBlock">
|
||||
<Setter Property="Margin" Value="8,1"/>
|
||||
</Style>
|
||||
</Style>
|
||||
</Style>
|
||||
</Grid.Styles>
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
<Grid
|
||||
Grid.ColumnSpan="4"
|
||||
Text="Full Lucene query syntax is supported
Fields with similar names are synomyns (eg: Author, Authors, AuthorNames)

TAG FORMAT: [tagName]" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Text="STRING FIELDS" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Text="NUMBER FIELDS" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="2"
|
||||
Text="BOOLEAN (TRUE/FALSE) FIELDS" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="3"
|
||||
Text="ID FIELDS" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Text="{Binding StringFields}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Text="{Binding NumberFields}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="2"
|
||||
Text="{Binding BoolFields}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="3"
|
||||
Text="{Binding IdFields}" />
|
||||
|
||||
RowDefinitions="Auto,Auto">
|
||||
|
||||
<TextBlock
|
||||
Text="Full Lucene query syntax is supported
Fields with similar names are synomyns (eg: Author, Authors, AuthorNames)" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="TAG FORMAT: [tagName]" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="NUMBER FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding StringUsage}" />
|
||||
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding StringFields}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="STRING FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding NumberUsage}" />
|
||||
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding NumberFields}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="2" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="BOOLEAN (TRUE/FALSE) FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding BoolUsage}" />
|
||||
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding BoolFields}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="3" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="ID FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding IdUsage}" />
|
||||
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding IdFields}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -1,59 +1,55 @@
|
||||
using LibationSearchEngine;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class SearchSyntaxDialog : DialogWindow
|
||||
{
|
||||
public string StringFields { get; init; }
|
||||
public string NumberFields { get; init; }
|
||||
public string BoolFields { get; init; }
|
||||
public string IdFields { get; init; }
|
||||
public string StringUsage { get; }
|
||||
public string NumberUsage { get; }
|
||||
public string BoolUsage { get; }
|
||||
public string IdUsage { get; }
|
||||
public string[] StringFields { get; } = SearchEngine.FieldIndexRules.StringFieldNames.ToArray();
|
||||
public string[] NumberFields { get; } = SearchEngine.FieldIndexRules.NumberFieldNames.ToArray();
|
||||
public string[] BoolFields { get; } = SearchEngine.FieldIndexRules.BoolFieldNames.ToArray();
|
||||
public string[] IdFields { get; } = SearchEngine.FieldIndexRules.IdFieldNames.ToArray();
|
||||
|
||||
public SearchSyntaxDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
StringFields = @"
|
||||
Search for wizard of oz:
|
||||
title:oz
|
||||
title:""wizard of oz""
|
||||
StringUsage = """
|
||||
Search for wizard of oz:
|
||||
title:oz
|
||||
title:"wizard of oz"
|
||||
""";
|
||||
|
||||
NumberUsage = """
|
||||
Find books between 1-100 minutes long
|
||||
length:[1 TO 100]
|
||||
Find books exactly 1 hr long
|
||||
length:60
|
||||
Find books published from 2020-1-1 to
|
||||
2023-12-31
|
||||
datepublished:[20200101 TO 20231231]
|
||||
""";
|
||||
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.StringFieldNames);
|
||||
BoolUsage = """
|
||||
Find books that you haven't rated:
|
||||
-IsRated
|
||||
""";
|
||||
|
||||
NumberFields = @"
|
||||
Find books between 1-100 minutes long
|
||||
length:[1 TO 100]
|
||||
Find books exactly 1 hr long
|
||||
length:60
|
||||
Find books published from 2020-1-1 to
|
||||
2023-12-31
|
||||
datepublished:[20200101 TO 20231231]
|
||||
IdUsage = """
|
||||
Alice's Adventures in
|
||||
Wonderland (ID: B015D78L0U)
|
||||
|
||||
id:B015D78L0U
|
||||
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.NumberFieldNames);
|
||||
|
||||
BoolFields = @"
|
||||
Find books that you haven't rated:
|
||||
-IsRated
|
||||
|
||||
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.BoolFieldNames);
|
||||
|
||||
IdFields = @"
|
||||
Alice's Adventures in
|
||||
Wonderland (ID: B015D78L0U)
|
||||
|
||||
id:B015D78L0U
|
||||
|
||||
All of these are synonyms
|
||||
for the ID field
|
||||
|
||||
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.IdFieldNames);
|
||||
|
||||
All of these are synonyms
|
||||
for the ID field
|
||||
""";
|
||||
|
||||
DataContext = this;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using FileManager;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
@@ -38,6 +41,21 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
|
||||
public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await SaveAndCloseAsync();
|
||||
{
|
||||
LongPath lonNewBooks = settingsDisp.ImportantSettings.GetBooksDirectory();
|
||||
if (!System.IO.Directory.Exists(lonNewBooks))
|
||||
{
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(lonNewBooks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.Show(this, $"Error creating Books Location:\n\n{ex.Message}", "Error creating directory", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await SaveAndCloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Dialogs;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using AppScaffolding;
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
@@ -29,7 +31,7 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade) : this()
|
||||
{
|
||||
Title = $"Libation version {upgradeProperties.LatestRelease.ToString(3)} is now available.";
|
||||
Title = $"Libation version {upgradeProperties.LatestRelease.ToVersionString()} is now available.";
|
||||
PackageUrl = upgradeProperties.ZipUrl;
|
||||
DownloadLinkText = upgradeProperties.ZipName;
|
||||
ReleaseNotes = upgradeProperties.Notes;
|
||||
|
||||
@@ -73,13 +73,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.2" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,6 +6,7 @@ using DataLayer;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Dialogs;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -13,92 +14,45 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia
|
||||
{
|
||||
public enum DialogResult
|
||||
{
|
||||
None = 0,
|
||||
OK = 1,
|
||||
Cancel = 2,
|
||||
Abort = 3,
|
||||
Retry = 4,
|
||||
Ignore = 5,
|
||||
Yes = 6,
|
||||
No = 7,
|
||||
TryAgain = 10,
|
||||
Continue = 11
|
||||
}
|
||||
|
||||
public enum MessageBoxIcon
|
||||
{
|
||||
None = 0,
|
||||
Error = 16,
|
||||
Hand = 16,
|
||||
Stop = 16,
|
||||
Question = 32,
|
||||
Exclamation = 48,
|
||||
Warning = 48,
|
||||
Asterisk = 64,
|
||||
Information = 64
|
||||
}
|
||||
|
||||
public enum MessageBoxButtons
|
||||
{
|
||||
OK,
|
||||
OKCancel,
|
||||
AbortRetryIgnore,
|
||||
YesNoCancel,
|
||||
YesNo,
|
||||
RetryCancel,
|
||||
CancelTryContinue
|
||||
}
|
||||
|
||||
public enum MessageBoxDefaultButton
|
||||
{
|
||||
Button1,
|
||||
Button2 = 256,
|
||||
Button3 = 512,
|
||||
}
|
||||
|
||||
public class MessageBox
|
||||
{
|
||||
|
||||
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton)
|
||||
=> ShowCoreAsync(null, text, caption, buttons, icon, defaultButton);
|
||||
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, bool saveAndRestorePosition = true)
|
||||
=> ShowCoreAsync(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition);
|
||||
=> ShowCoreAsync(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1, saveAndRestorePosition);
|
||||
public static Task<DialogResult> Show(string text, string caption, MessageBoxButtons buttons)
|
||||
=> ShowCoreAsync(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(string text, string caption)
|
||||
=> ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(string text)
|
||||
=> ShowCoreAsync(null, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton)
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, icon, defaultButton);
|
||||
|
||||
=> ShowCoreAsync(null, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||
public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon)
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(Window owner, string text, string caption, MessageBoxButtons buttons)
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(Window owner, string text, string caption)
|
||||
=> ShowCoreAsync(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
=> ShowCoreAsync(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
public static Task<DialogResult> Show(Window owner, string text)
|
||||
=> ShowCoreAsync(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1);
|
||||
|
||||
|
||||
public static async Task VerboseLoggingWarning_ShowIfTrue()
|
||||
{
|
||||
// when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured
|
||||
if (Serilog.Log.Logger.IsVerboseEnabled())
|
||||
await Show(@"
|
||||
Warning: verbose logging is enabled.
|
||||
await Show("""
|
||||
Warning: verbose logging is enabled.
|
||||
|
||||
This should be used for debugging only. It creates many
|
||||
more logs and debug files, neither of which are as
|
||||
strictly anonymous.
|
||||
This should be used for debugging only. It creates many
|
||||
more logs and debug files, neither of which are as
|
||||
strictly anonymous.
|
||||
|
||||
When you are finished debugging, it's highly recommended
|
||||
to set your debug MinimumLevel to Information and restart
|
||||
Libation.
|
||||
".Trim(), "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
When you are finished debugging, it's highly recommended
|
||||
to set your debug MinimumLevel to Information and restart
|
||||
Libation.
|
||||
""", "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -138,7 +92,8 @@ Libation.
|
||||
{
|
||||
// for development and debugging, show me what broke!
|
||||
if (System.Diagnostics.Debugger.IsAttached)
|
||||
throw exception;
|
||||
//Wrap the exception to preserve its stack trace.
|
||||
throw new Exception("An unhandled exception was encountered", exception);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -152,12 +107,12 @@ Libation.
|
||||
}
|
||||
|
||||
private static async Task<DialogResult> ShowCoreAsync(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
owner = owner?.IsLoaded is true ? owner : null;
|
||||
var dialog = await Dispatcher.UIThread.InvokeAsync(() => CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition));
|
||||
|
||||
var dialog = CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||
return await DisplayWindow(dialog, owner);
|
||||
}
|
||||
});
|
||||
|
||||
private static MessageBoxWindow CreateMessageBox(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
|
||||
{
|
||||
@@ -175,7 +130,6 @@ Libation.
|
||||
tbx.MinWidth = vm.TextBlockMinWidth;
|
||||
tbx.Text = message;
|
||||
|
||||
|
||||
var thisScreen = owner.Screens?.ScreenFromVisual(owner);
|
||||
|
||||
var maxSize
|
||||
@@ -229,6 +183,5 @@ Libation.
|
||||
return await toDisplay.ShowDialog<DialogResult>(owner);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using Avalonia.Media.Imaging;
|
||||
using DataLayer;
|
||||
using LibationUiBase.GridView;
|
||||
using System;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable
|
||||
{
|
||||
private AvaloniaEntryStatus(LibraryBook libraryBook) : base(libraryBook) { }
|
||||
public static EntryStatus Create(LibraryBook libraryBook) => new AvaloniaEntryStatus(libraryBook);
|
||||
|
||||
protected override Bitmap LoadImage(byte[] picture)
|
||||
=> AvaloniaUtils.TryLoadImageOrDefault(picture, LibationFileManager.PictureSize._80x80);
|
||||
|
||||
//Button icons are handled by LiberateStatusButton
|
||||
protected override Bitmap? GetResourceImage(string rescName) => null;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.ViewModels.Dialogs
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Linq;
|
||||
@@ -14,8 +15,10 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
partial class MainVM
|
||||
{
|
||||
private QuickFilters.NamedFilter? lastGoodFilter = new(string.Empty, null);
|
||||
private QuickFilters.NamedFilter? _selectedNamedFilter = new(string.Empty, null);
|
||||
private string lastGoodSearch = string.Empty;
|
||||
private QuickFilters.NamedFilter? lastGoodFilter => new(lastGoodSearch, null);
|
||||
|
||||
private QuickFilters.NamedFilter? _selectedNamedFilter = new(string.Empty, null);
|
||||
private bool _firstFilterIsDefault = true;
|
||||
|
||||
/// <summary> Library filterting query </summary>
|
||||
@@ -24,7 +27,6 @@ namespace LibationAvalonia.ViewModels
|
||||
/// <summary> Indicates if the first quick filter is the default filter </summary>
|
||||
public bool FirstFilterIsDefault { get => _firstFilterIsDefault; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref _firstFilterIsDefault, value); }
|
||||
|
||||
|
||||
private void Configure_Filters()
|
||||
{
|
||||
FirstFilterIsDefault = QuickFilters.UseDefault;
|
||||
@@ -52,7 +54,7 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
|
||||
public void AddQuickFilterBtn() { if (SelectedNamedFilter != null) QuickFilters.Add(SelectedNamedFilter); }
|
||||
public async Task FilterBtn() => await PerformFilter(SelectedNamedFilter);
|
||||
public async Task FilterBtn(string filterString) => await PerformFilter(new(filterString, null));
|
||||
public async Task FilterHelpBtn() => await new LibationAvalonia.Dialogs.SearchSyntaxDialog().ShowDialog(MainWindow);
|
||||
public void ToggleFirstFilterIsDefault() => FirstFilterIsDefault = !FirstFilterIsDefault;
|
||||
public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow);
|
||||
@@ -64,15 +66,16 @@ namespace LibationAvalonia.ViewModels
|
||||
try
|
||||
{
|
||||
await ProductsDisplay.Filter(tryFilter);
|
||||
lastGoodFilter = namedFilter;
|
||||
lastGoodSearch = namedFilter?.Filter ?? "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
|
||||
await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
|
||||
// re-apply last good filter
|
||||
await PerformFilter(lastGoodFilter);
|
||||
// re-apply last good filter
|
||||
namedFilter = (namedFilter ?? new(string.Empty, null)) with { Filter = lastGoodSearch };
|
||||
await PerformFilter(namedFilter);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Input;
|
||||
using LibationUiBase.Forms;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -19,6 +20,7 @@ namespace LibationAvalonia.ViewModels
|
||||
private bool _removeButtonsVisible = Design.IsDesignMode;
|
||||
private int _numAccountsScanning = 2;
|
||||
private int _accountsCount = 0;
|
||||
public string LocateAudiobooksTip => Configuration.GetHelpText("LocateAudiobooks");
|
||||
|
||||
/// <summary> Auto scanning accounts is enables </summary>
|
||||
public bool AutoScanChecked { get => _autoScanChecked; set => Configuration.Instance.AutoScan = this.RaiseAndSetIfChanged(ref _autoScanChecked, value); }
|
||||
@@ -67,7 +69,8 @@ namespace LibationAvalonia.ViewModels
|
||||
MainWindow.Loaded += (_, _) =>
|
||||
{
|
||||
refreshImportMenu();
|
||||
AccountsSettingsPersister.Saved += refreshImportMenu;
|
||||
AccountsSettingsPersister.Saved += (_, _)
|
||||
=> Avalonia.Threading.Dispatcher.UIThread.Invoke(refreshImportMenu);
|
||||
};
|
||||
|
||||
AutoScanChecked = Configuration.Instance.AutoScan;
|
||||
@@ -171,8 +174,19 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public async Task LocateAudiobooksAsync()
|
||||
{
|
||||
var locateDialog = new LibationAvalonia.Dialogs.LocateAudiobooksDialog();
|
||||
await locateDialog.ShowDialog(MainWindow);
|
||||
var result = await MessageBox.Show(
|
||||
MainWindow,
|
||||
Configuration.GetHelpText(nameof(LibationAvalonia.Dialogs.LocateAudiobooksDialog)),
|
||||
"Locate Previously-Liberated Audiobook Files",
|
||||
MessageBoxButtons.OKCancel,
|
||||
MessageBoxIcon.Information,
|
||||
MessageBoxDefaultButton.Button1);
|
||||
|
||||
if (result is DialogResult.OK)
|
||||
{
|
||||
var locateDialog = new LibationAvalonia.Dialogs.LocateAudiobooksDialog();
|
||||
await locateDialog.ShowDialog(MainWindow);
|
||||
}
|
||||
}
|
||||
|
||||
private void setyNumScanningAccounts(int numScanning)
|
||||
@@ -201,7 +215,7 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(accounts);
|
||||
var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
|
||||
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
|
||||
if (Configuration.Instance.ShowImportedStats && newAdded > 0)
|
||||
@@ -221,7 +235,7 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshImportMenu(object? _ = null, EventArgs? __ = null)
|
||||
private void refreshImportMenu()
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
AccountsCount = persister.AccountsSettings.Accounts.Count;
|
||||
|
||||
@@ -4,6 +4,9 @@ using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using LibationUiBase.Forms;
|
||||
using LibationUiBase;
|
||||
using System.Collections.Generic;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -12,19 +15,14 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
public void Configure_Liberate() { }
|
||||
|
||||
public void BackupAllBooks()
|
||||
public async Task BackupAllBooks()
|
||||
{
|
||||
try
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
var unliberated = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up all library books");
|
||||
|
||||
ProcessQueue.AddDownloadDecrypt(
|
||||
DbContexts
|
||||
.GetLibrary_Flat_NoTracking()
|
||||
.UnLiberated()
|
||||
);
|
||||
if (ProcessQueue.QueueDownloadDecrypt(unliberated))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -32,10 +30,10 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public void BackupAllPdfs()
|
||||
public async Task BackupAllPdfs()
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadPdf(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated));
|
||||
if (ProcessQueue.QueueDownloadPdf(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
|
||||
public async Task ConvertAllToMp3Async()
|
||||
@@ -48,12 +46,8 @@ namespace LibationAvalonia.ViewModels
|
||||
"Convert all M4b => Mp3?",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning);
|
||||
if (result == DialogResult.Yes)
|
||||
{
|
||||
if (result == DialogResult.Yes && ProcessQueue.QueueConvertToMp3(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddConvertMp3(DbContexts.GetLibrary_Flat_NoTracking().Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is ContentType.Product));
|
||||
}
|
||||
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
|
||||
}
|
||||
|
||||
private void setQueueCollapseState(bool collapsed)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.GridView;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -37,50 +38,16 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
if (libraryBooks.Length == 1)
|
||||
if (ProcessQueue.QueueDownloadDecrypt(libraryBooks))
|
||||
setQueueCollapseState(false);
|
||||
else if (libraryBooks.Length == 1 && libraryBooks[0].Book.Audio_Exists())
|
||||
{
|
||||
var item = libraryBooks[0];
|
||||
|
||||
//Remove this item from the queue if it's already present and completed.
|
||||
//Only do this when adding a single book at a time to prevent accidental
|
||||
//extra downloads when queueing in batches.
|
||||
ProcessQueue.RemoveCompleted(item);
|
||||
|
||||
if (item.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||
// liberated: open explorer to file
|
||||
var filePath = AudibleFileStorage.Audio.GetPath(libraryBooks[0].Book.AudibleProductId);
|
||||
if (!Go.To.File(filePath?.ShortPathName))
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin single book backup of {libraryBook}", item);
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadDecrypt(item);
|
||||
}
|
||||
else if (item.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin single pdf backup of {libraryBook}", item);
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadPdf(item);
|
||||
}
|
||||
else if (item.Book.Audio_Exists())
|
||||
{
|
||||
// liberated: open explorer to file
|
||||
var filePath = AudibleFileStorage.Audio.GetPath(item.Book.AudibleProductId);
|
||||
|
||||
if (!Go.To.File(filePath?.ShortPathName))
|
||||
{
|
||||
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
|
||||
await MessageBox.Show($"File not found" + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var toLiberate
|
||||
= libraryBooks
|
||||
.Where(x => x.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload || x.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated)
|
||||
.ToArray();
|
||||
|
||||
if (toLiberate.Length > 0)
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddDownloadDecrypt(toLiberate);
|
||||
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
|
||||
await MessageBox.Show($"File not found" + suffix);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,15 +57,14 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public void LiberateSeriesClicked(ISeriesEntry series)
|
||||
public void LiberateSeriesClicked(SeriesEntry series)
|
||||
{
|
||||
try
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up all {series} episodes", series.LibraryBook);
|
||||
|
||||
ProcessQueue.AddDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated());
|
||||
if (ProcessQueue.QueueDownloadDecrypt(series.Children.Select(c => c.LibraryBook).UnLiberated().ToArray()))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -110,13 +76,8 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
var preLiberated = libraryBooks.Where(lb => lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated).ToArray();
|
||||
if (preLiberated.Length > 0)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
|
||||
if (ProcessQueue.QueueConvertToMp3(libraryBooks))
|
||||
setQueueCollapseState(false);
|
||||
ProcessQueue.AddConvertMp3(preLiberated);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using LibationFileManager;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -27,7 +28,7 @@ namespace LibationAvalonia.ViewModels
|
||||
// in autoScan, new books SHALL NOT show dialog
|
||||
try
|
||||
{
|
||||
await LibraryCommands.ImportAccountAsync(accounts);
|
||||
await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,9 @@ using DataLayer;
|
||||
using Avalonia.Threading;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using ReactiveUI;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Linq;
|
||||
using LibationUiBase;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -71,15 +74,8 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
try
|
||||
{
|
||||
setQueueCollapseState(false);
|
||||
|
||||
Serilog.Log.Logger.Information("Begin backing up visible library books");
|
||||
|
||||
ProcessQueue.AddDownloadDecrypt(
|
||||
ProductsDisplay
|
||||
.GetVisibleBookEntries()
|
||||
.UnLiberated()
|
||||
);
|
||||
if (ProcessQueue.QueueDownloadDecrypt(ProductsDisplay.GetVisibleBookEntries().UnLiberated().ToArray()))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
#nullable enable
|
||||
@@ -7,7 +8,7 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
partial class MainVM
|
||||
{
|
||||
private void Configure_NonUI()
|
||||
public static void Configure_NonUI()
|
||||
{
|
||||
using var ms1 = new MemoryStream();
|
||||
App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1);
|
||||
@@ -23,6 +24,20 @@ namespace LibationAvalonia.ViewModels
|
||||
PictureStorage.SetDefaultImage(PictureSize.Native, ms3.ToArray());
|
||||
|
||||
BaseUtil.SetLoadImageDelegate(AvaloniaUtils.TryLoadImageOrDefault);
|
||||
BaseUtil.SetLoadResourceImageDelegate(LoadResourceImage);
|
||||
}
|
||||
private static Avalonia.Media.Imaging.Bitmap? LoadResourceImage(string resourceName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = App.OpenAsset(resourceName);
|
||||
return new Avalonia.Media.Imaging.Bitmap(stream);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Failed to load resource image: {ResourceName}", resourceName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user