Compare commits

..

96 Commits

Author SHA1 Message Date
Robert McRackan
c4acd5d208 incr ver 2023-02-08 13:56:13 -05:00
rmcrackan
381440db4c Merge pull request #479 from Mbucari/master
Fix #478 and other stuff I'd already worked on
2023-02-08 13:43:56 -05:00
Michael Bucari-Tovo
00c8be1f7e Create LibationUiBase for shared UI code 2023-02-08 09:30:13 -07:00
rmcrackan
d665122aa2 Update GettingStarted.md
Classic vs Chardonnay
2023-02-08 07:49:18 -05:00
Michael Bucari-Tovo
bb40df5fa3 Fix #478 2023-02-07 22:58:29 -07:00
Mbucari
e3c9f70dff Move shared GUI code into AppScaffolding 2023-02-06 16:04:58 -07:00
Mbucari
b351033cec Improve download and convert speed estimate 2023-02-06 15:54:12 -07:00
Mbucari
18f69bc73d Refactor Naming Template 2023-02-06 15:24:18 -07:00
Robert McRackan
39fe7b79d2 Bug fix #474 2023-02-06 08:35:24 -05:00
rmcrackan
85769d797b Merge pull request #473 from Mbucari/master
Made changes discussed in previous PR and fixed #472
2023-02-04 17:32:53 -05:00
Michael Bucari-Tovo
9a80f18e1c Rename ConditionalTagClass to ConditionalTagCollection 2023-02-04 15:19:47 -07:00
Michael Bucari-Tovo
aec8305e52 Fix #472 2023-02-04 12:49:59 -07:00
Michael Bucari-Tovo
a672174a9b Refactor Naming Templates 2023-02-04 12:49:48 -07:00
Mbucari
6f490b4491 Rename TagClass to TagCollection 2023-02-03 17:14:04 -07:00
Mbucari
5917d059e4 Add Enumerable initializers 2023-02-03 17:03:15 -07:00
Robert McRackan
40602c7626 incr ver 2023-02-03 17:26:17 -05:00
rmcrackan
7d5ee2afa8 Merge pull request #471 from Mbucari/master
Add a more general NamingTemplate
2023-02-03 17:24:37 -05:00
Mbucari
08b6f8fa11 Add test 2023-02-03 14:46:48 -07:00
Mbucari
5f9699aa3b Merge branch 'master' of https://github.com/Mbucari/Libation 2023-02-03 14:43:42 -07:00
Mbucari
70607aaaf4 Documentation 2023-02-03 14:43:35 -07:00
Mbucari
1d96d39af7 Add Naming Template Documentation 2023-02-03 14:33:11 -07:00
Mbucari
5557772957 Add conditional negation 2023-02-03 11:47:55 -07:00
Mbucari
5c7db6cd23 Add <series#> tag zero padding (#466) 2023-02-03 11:31:15 -07:00
Mbucari
c72b64d74c Properly truncate filenames 2023-02-03 09:53:40 -07:00
Mbucari
20474e0b3c Add a more general NamingTemplate 2023-02-02 22:48:27 -07:00
Robert McRackan
867085600c New feature #469 - <language> and <language short> template options 2023-02-01 12:12:50 -05:00
rmcrackan
74290ec609 Merge pull request #468 from rmcrackan/dependabot/github_actions/docker/build-push-action-4
Bump docker/build-push-action from 3 to 4
2023-01-31 10:18:40 -05:00
dependabot[bot]
5ee555e60c Bump docker/build-push-action from 3 to 4
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-31 14:11:36 +00:00
Robert McRackan
a36c28d48f incr. ver. 2023-01-30 21:24:54 -05:00
rmcrackan
0877f2c042 Merge pull request #464 from Mbucari/master
Fix #463
2023-01-25 20:48:58 -05:00
Mbucari
2baf5243ea Fix #463 2023-01-25 14:23:02 -07:00
rmcrackan
b7e71f5812 Merge pull request #462 from Mbucari/master
Refactor AaxDecrypter
2023-01-25 07:06:37 -05:00
Michael Bucari-Tovo
2ed1076fab Cleanup 2023-01-24 23:35:05 -07:00
Michael Bucari-Tovo
0b20aa751f Only Dispose of NFS on disposing 2023-01-24 23:00:07 -07:00
Michael Bucari-Tovo
05a4ece8d1 Merged 2023-01-24 22:27:31 -07:00
Michael Bucari-Tovo
25b37c6266 Refactor AaxDecrypter 2023-01-24 22:25:55 -07:00
Robert McRackan
b668cff0ac Update dependenciesd. Build is broken until the ambiguous ref.s moved into Dinah.Core are resolved 2023-01-24 22:40:08 -05:00
Robert McRackan
4d6c742ae9 Bug fix #459 , New feature/setting #366 2023-01-23 22:50:37 -05:00
rmcrackan
933f663d22 Merge pull request #460 from Mbucari/master
Upgrade AAXClean.Codecs to 0.5.12, add moov relocation, and fix #459
2023-01-23 22:46:20 -05:00
Michael Bucari-Tovo
0c55f278a4 Revert Solution Changes 2023-01-23 20:32:27 -07:00
Michael Bucari-Tovo
3f567ee82e Merged 2023-01-23 20:13:19 -07:00
Michael Bucari-Tovo
8dc912c11d Add option to move the moov atom to the beginning of the file. 2023-01-23 20:11:00 -07:00
Michael Bucari-Tovo
f1b4e2a17d Upgrade AAXClean.Codecs to 0.5.11 2023-01-23 19:04:15 -07:00
Michael Bucari-Tovo
630cfdeab3 Upgrade AAXClean.Codecs to 0.5.11 2023-01-23 17:39:08 -07:00
Michael Bucari-Tovo
7029409792 Upgrade AAXClean.Codecs to 0.5.10 and fix #459 2023-01-23 16:30:17 -07:00
rmcrackan
d0727b5a85 Update InstallOnMac.md
Added @Mbucari 's awesome video
2023-01-23 08:17:28 -05:00
rmcrackan
9f52ad5e0a Update README.md
link to docker readme
2023-01-23 08:07:37 -05:00
rmcrackan
501ae643f7 Merge pull request #458 from pixil98/master
Deb build tweaks, Docker read me
2023-01-23 08:06:51 -05:00
Aaron Reisman
400074170e Add docker readme 2023-01-22 17:09:25 -06:00
Aaron Reisman
17103ed066 Get release id correctly 2023-01-22 15:33:27 -06:00
Aaron Reisman
b6b29309c9 Go back to the old way of uploading assets 2023-01-22 15:15:56 -06:00
Aaron Reisman
a04538710f Try with a different glob 2023-01-22 14:53:05 -06:00
Aaron Reisman
01f6f5c137 Add name to release 2023-01-22 14:17:08 -06:00
Aaron Reisman
b1a37cbd8c Switch to a still maintained release action 2023-01-22 13:56:36 -06:00
Aaron Reisman
8c59e1280b Wait for deb to be finished before releasing 2023-01-22 13:20:59 -06:00
Aaron Reisman
00339127aa reference correct deb yaml 2023-01-22 13:15:51 -06:00
Aaron Reisman
5935b40b60 Remove output version 2023-01-22 13:03:23 -06:00
Aaron Reisman
6cfd2dea96 Move deb building out of the build pipeline 2023-01-22 13:01:12 -06:00
rmcrackan
7d3a39c693 Merge pull request #456 from Mbucari/master
Add file creation DateTime to naming templates
2023-01-20 15:18:11 -05:00
Mbucari
6e7a4ea475 Update date format regex and tests 2023-01-20 10:03:55 -07:00
Michael Bucari-Tovo
3479dbc3f0 Date format naming templates 2023-01-20 01:00:22 -07:00
Mbucari
9309aea6d9 Add file creation DateTime to naming templates
typo
2023-01-19 17:12:28 -07:00
Robert McRackan
f72551fa9a incr ver 2023-01-19 08:06:18 -05:00
rmcrackan
e3b237b75f Merge pull request #454 from Mbucari/master
Fixed #453
2023-01-18 22:23:55 -05:00
Mbucari
b1ddf18f73 Merge branch 'rmcrackan:master' into master 2023-01-18 15:47:18 -07:00
Michael Bucari-Tovo
13f522abb8 Fix file extension detection error (#453) 2023-01-18 15:47:05 -07:00
Robert McRackan
3c3d956bf3 remove linux and mac from beta 2023-01-16 13:16:49 -05:00
Robert McRackan
8160547c11 incr ver 2023-01-16 13:06:19 -05:00
rmcrackan
ef71d36dee Merge pull request #451 from Mbucari/patch-3
Updated for new deb package builds
2023-01-16 12:59:23 -05:00
Mbucari
b0d8434455 Updated for new deb package builds 2023-01-16 10:41:21 -07:00
rmcrackan
9be0d58461 Merge pull request #450 from Mbucari/master
Update workflows and AAXClean
2023-01-16 10:38:18 -05:00
Michael Bucari-Tovo
1addcc8211 Update AAXClean and add better error handling 2023-01-15 21:42:03 -07:00
Mbucari
38c75dc8c5 Update workflows 2023-01-12 15:47:01 -07:00
rmcrackan
89c3ea8311 Merge pull request #444 from Mbucari/master
Build and attach deb package
2023-01-12 08:45:14 -05:00
Michael Bucari-Tovo
18ff799fb1 Update build scripts 2023-01-11 21:14:26 -07:00
Mbucari
67b6aaed99 Fix #447 2023-01-11 16:08:01 -07:00
Mbucari
08bb463560 Invoke observables directly instead of through event handler 2023-01-11 14:38:12 -07:00
Mbucari
97767dcabb GLOB pattern 2023-01-11 14:37:12 -07:00
Mbucari
fb18940a5c Use DataGridMyRatingColumn for both user and product ratings. 2023-01-11 14:36:43 -07:00
Mbucari
b823f5fa00 Import average rating 2023-01-11 14:35:14 -07:00
Mbucari
d64fb081a0 Build and attach deb package 2023-01-11 14:15:07 -07:00
Robert McRackan
09118b1ddf nvm. I guess this has been default : true for a long time. Reverting 2023-01-10 10:13:47 -05:00
Robert McRackan
de20590fd5 do not enable AutoScan by default 2023-01-10 10:04:53 -05:00
rmcrackan
1050ffdb24 Merge pull request #443 from Mbucari/master
Chardonnay Bugfix + Moved Default Settings into Configuration
2023-01-10 09:54:49 -05:00
Michael Bucari-Tovo
2e49c7f697 Commit edits before refresh 2023-01-09 18:57:16 -07:00
Mbucari
708cdcc24c Merge branch 'master' of https://github.com/Mbucari/Libation 2023-01-09 16:30:59 -07:00
Mbucari
c89eafd568 Fix rating edits updating search results. 2023-01-09 16:27:19 -07:00
Michael Bucari-Tovo
10de241d53 Cache default values 2023-01-09 16:11:30 -07:00
Mbucari
e58952035f Spaces 2023-01-09 16:06:37 -07:00
Mbucari
50a8c7508a Cache default values 2023-01-09 16:05:55 -07:00
Michael Bucari-Tovo
2b243a6934 Remove testing code 2023-01-09 15:28:36 -07:00
Michael Bucari-Tovo
ece93cb4d7 Finish migrating default Configuration values into Configuration 2023-01-09 15:26:06 -07:00
rmcrackan
5b3ca0ed32 Merge pull request #442 from wtanksleyjr/master
Add a version parameter to DEB creation, do sanity checks, use it.
2023-01-09 16:36:48 -05:00
William Tanksley
d023a943c1 Add a version parameter to DEB creation, do sanity checks, use it. 2023-01-09 13:12:12 -08:00
Michael Bucari-Tovo
4e80af5c53 Bind MyRatingCellEditor background color to the cell's background 2023-01-09 14:06:06 -07:00
Michael Bucari-Tovo
eee785377f Add default values to Configuration 2023-01-09 14:05:33 -07:00
126 changed files with 3638 additions and 2440 deletions

View File

@@ -66,6 +66,8 @@ jobs:
id: zip
working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}
run: |
delfiles=("libmp3lame.x86.dll" "libmp3lame.x64.dll" "ffmpegaac.x86.dll" "ffmpegaac.x64.dll" "ZipExtractor.exe")
for n in "${delfiles[@]}"; do rm "$n"; done
osbuild="$(echo '${{ matrix.os }}' | tr '[:upper:]' '[:lower:]')"
artifact="Libation.${{ steps.get_version.outputs.version }}-${osbuild}-${{ matrix.release_name }}"
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"

View File

@@ -48,8 +48,7 @@ jobs:
if ("${{ inputs.version_override }}".length -gt 0) {
$version = "${{ inputs.version_override }}"
} else {
[xml]$appScaffolding = Get-Content -Path ./Source/AppScaffolding/AppScaffolding.csproj
$version = $appScaffolding.Project.PropertyGroup.Version
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
}
"version=$version" >> $env:GITHUB_OUTPUT
@@ -70,9 +69,12 @@ jobs:
id: zip
working-directory: ./Source/bin/Publish
run: |
$dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
$delfiles = @("libmp3lame.so", "ffmpegaac.so", "glass-with-glow_256.svg", "Libation.desktop")
foreach ($file in $delfiles){ if (test-path $dir$file){ Remove-Item $dir$file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${{ matrix.os }}-${{ matrix.release_name }}\*" -DestinationPath "$artifact.zip"
Compress-Archive -Path "${dir}*" -DestinationPath "$artifact.zip"
- name: Publish artifact
uses: actions/upload-artifact@v3
@@ -80,3 +82,4 @@ jobs:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
if-no-files-found: error

View File

@@ -14,7 +14,7 @@ on:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
default: true
jobs:
windows:
@@ -27,4 +27,4 @@ jobs:
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
run_unit_tests: ${{ inputs.run_unit_tests }}

38
.github/workflows/deb.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
# deb.yml
# Reusable workflow that builds the Linux Debian package.
---
name: deb
on:
workflow_call:
inputs:
version:
type: string
description: 'Version number'
required: true
env:
FILE_NAME: "Libation.${{ inputs.version }}-linux-chardonnay"
jobs:
build_deb:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Download Artifact
uses: actions/download-artifact@v3
with:
name: "${{ env.FILE_NAME }}.tar.gz"
- name: Build .deb
id: deb
run: |
./Scripts/targz2deb.sh "${{ env.FILE_NAME }}.tar.gz" ${{ inputs.version }}
- name: Publish .deb
uses: actions/upload-artifact@v3
with:
name: ${{ env.FILE_NAME }}.deb
path: ${{ env.FILE_NAME }}.deb
if-no-files-found: error

View File

@@ -39,7 +39,7 @@ jobs:
password: ${{ secrets.docker_token }}
- name: Build and push
uses: docker/build-push-action@v3
uses: docker/build-push-action@v4
with:
push: true
build-args: 'FOLDER_NAME=Linux-chardonnay'

View File

@@ -33,9 +33,15 @@ jobs:
with:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
deb:
needs: [prerelease,build]
uses: ./.github/workflows/deb.yml
with:
version: ${{ needs.prerelease.outputs.version }}
release:
needs: [prerelease,build]
needs: [prerelease,build,deb]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
@@ -43,14 +49,11 @@ jobs:
with:
path: artifacts
- name: Create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
- name: Release
id: release
uses: softprops/action-gh-release@v1
with:
tag_name: '${{ github.ref }}'
release_name: 'Libation ${{ steps.version.outputs.version }}'
name: Libation ${{ needs.prerelease.outputs.version }}
body: <Put a body here>
draft: true
prerelease: false
@@ -60,5 +63,5 @@ jobs:
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
with:
release_id: '${{ steps.create_release.outputs.id }}'
release_id: '${{ steps.release.outputs.id }}'
assets_path: ./artifacts

View File

@@ -9,7 +9,7 @@
- [Files and folders](#files-and-folders)
- [Settings](#settings)
- [Custom File Naming](#custom-file-naming)
- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
@@ -28,12 +28,6 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
### Custom File Naming
In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
These templates apply to GUI and CLI.
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.

36
Documentation/Docker.md Normal file
View File

@@ -0,0 +1,36 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
### Setup
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
In Settings.json, make the following changes:
* Change `Books` to `/data`
* Change `InProgress` to `/tmp`
### Running
Once the configuration files are copied and edited, the docker image can be run with the following command.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
--name libation \
--restart=always \
rmcrackan/libation
```
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
-e SLEEP_TIME='10m' \
--name libation \
--restart=always \
rmcrackan/libation
```

View File

@@ -20,14 +20,21 @@
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
##### Which version? Chardonnay vs Classic
Nearly 100% of the difference is look and feel -- it's a matter of preference.
Chardonnay has an updated look and will work and look the same on Windows, Mac, and Linux.
Classic is Windows only. It has an older look because it's built with older, duller, and more mature technology. This tech has built into it better support for things like accessibility for screen readers.
### Installation
* Windows
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
* [Ubuntu Linux (beta)](InstallOnLinux.md)
* [MacOS (beta)](InstallOnMac.md)
* [Ubuntu Linux](InstallOnLinux.md)
* [MacOS](InstallOnMac.md)
### Create Accounts

View File

@@ -4,97 +4,19 @@
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
### Install and Run Libation on Ubuntu
# Run Libation on Ubuntu (Beta)
This walkthrough should get you up and running with Libation on your Ubuntu machine.
New Libation releases are automatically packed into a debian package and are available from the Libation repository's releases page.
Some limitations of the linux release are:
- Cannot customize how illegial filename characters are replaced.
- The Auto-update function is unavailable
Run this command in your terminal to dowbnload and install Libation, replacing the url with the Latest Libation .deb package url:
## Dependencies
### FFMpeg (Optional)
If you want to convert your audiobooks to mp3, install FFMpeg using the following command:
```console
sudo apt-get install -y ffmpeg
```
## Install Libation
Download the most recent linux-64 binaries zip file and save it as `libation-linux-bin.zip`. Save the 'install-libation.sh' bash script to a file. From the terminal make the script file executable:
<details>
<summary>install-libation.sh</summary>
```BASH
#!/bin/bash
FILE=$1
if [ -z "$FILE" ]
then echo "This script must be called with a the Libation Linux bin zip file as an argument."
exit
fi
if [[ "$EUID" -ne 0 ]]
then echo "Please run as root"
exit
fi
if [ ! -f "$FILE" ]
then echo "The file \"$FILE\" does not exist."
exit
fi
echo "Extracting $FILE"
FOLDER="$(dirname "$FILE")/libation_src"
echo "$FOLDER"
sudo -u $SUDO_USER unzip -q -o ${FILE} -d ${FOLDER}
if [ $? -ne 0 ]
then echo "Error unzipping ${FILE}"
exit
fi
sudo -u $SUDO_USER chmod +700 ${FOLDER}/Libation
sudo -u $SUDO_USER chmod +700 ${FOLDER}/Hangover
sudo -u $SUDO_USER chmod +700 ${FOLDER}/LibationCli
#Remove previous installation program files and sym link
rm /usr/bin/Libation
rm /usr/bin/Hangover
rm /usr/bin/LibationCli
rm /usr/bin/libationcli
rm /usr/lib/libation -r
#Copy install files, icon and desktop file
cp ${FOLDER}/glass-with-glow_256.svg /usr/share/icons/hicolor/scalable/apps/libation.svg
cp ${FOLDER}/Libation.desktop /usr/share/applications/Libation.desktop
mv ${FOLDER}/ /usr/lib/libation
chmod +666 /usr/share/icons/hicolor/scalable/apps/libation.svg
gtk-update-icon-cache -f /usr/share/icons/hicolor/
ln -s /usr/lib/libation/Libation /usr/bin/Libation
ln -s /usr/lib/libation/Hangover /usr/bin/Hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/LibationCli
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
echo "Done!"
```
</details>
```console
chmod +700 install-libation.sh
```
Then run the script with the libation binaries zipfile as an argument.
```console
sudo ./install-libation.sh libation-linux-bin.zip
```Console
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
sudo apt install ./libation.deb
```
You should now see Libation among your applications.
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
Report bugs to https://github.com/rmcrackan/Libation/issues

View File

@@ -5,7 +5,7 @@
# Run Libation on MacOS (Beta)
# Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac.
## Install Libation
@@ -38,3 +38,7 @@ Once Gatekeeper reenabled, you can open Libation again without it being blocked.
Thanks [joseph-holland](https://github.com/rmcrackan/Libation/issues/327#issuecomment-1268993349)!
Report bugs to https://github.com/rmcrackan/Libation/issues
## Get Libation running on Mac
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/213933357-983d8ede-2738-4b32-9c6e-40de21ff09c2.mp4)

View File

@@ -0,0 +1,109 @@
# Naming Templates
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
These templates apply to both GUI and CLI.
# Table of Contents
- [Template Tags](#template-tags)
- [Property Tags](#property-tags)
- [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters)
- [Integer Formatters](#integer-formatters)
- [Date Formatters](#date-formatters)
# Template Tags
These are the naming template tags currently supported by Libation.
## Property Tags
These tags will be replaced in the template with the audiobook's values.
|Tag|Description|Type|
|-|-|-|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title|Text|
|\<title short\>|Title. Stop at first colon|Text|
|\<author\>|Author(s)|Text|
|\<first author\>|First author|Text|
|\<narrator\>|Narrator(s)|Text|
|\<first narrator\>|First narrator|Text|
|\<series\>|Name of series|Text|
|\<series#\>|Number order in series|Text|
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|\<samplerate\>|File's original audio sample rate|Integer|
|\<channels\>|Number of audio channels|Integer|
|\<account\>|Audible account of this book|Text|
|\<locale\>|Region/country|Text|
|\<year\>|Year published|Integer|
|\<language\>|Book's language|Text|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|\<file date\>|File creation date/time.|DateTime|
|\<pub date\>|Audiobook publication date|DateTime|
|\<date added\>|Date the book added to your Audible account|DateTime|
|\<ch count\> **‡**|Number of chapters|Integer|
|\<ch title\> **‡**|Chapter title|Text|
|\<ch#\> **‡**|Chapter number|Integer|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
**†** Does not support custom formatting
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
## Conditional Tags
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|Tag|Description|Type|
|-|-|-|
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
# Tag Formatters
**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
## Text Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
## Integer Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|# (a number)|Zero-pads the number|\<bitrate[4]\><br>\<series#[3]\><br>\<samplerate[6]\>|0128<br>001<br>044100|
**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
## Date Formatters
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
### Standard DateTime Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
### Custom DateTime Formatters
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|yy|2-digit year|\<file date[yy]\>|23|
|MM|2-digit month|\<file date[MM]\>|02|
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|

View File

@@ -28,8 +28,9 @@
- [Advanced](Documentation/Advanced.md)
- [Files and folders](Documentation/Advanced.md#files-and-folders)
- [Settings](Documentation/Advanced.md#settings)
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
- [Custom File Naming](Documentation/NamingTemplates.md)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- [Docker](Documentation/Docker.md)
## Getting started

26
Source/targz2deb.sh → Scripts/targz2deb.sh Normal file → Executable file
View File

@@ -1,6 +1,7 @@
#!/bin/bash
FILE=$1
FILE=$1; shift
VERSION=$1; shift
if [ -z "$FILE" ]
then
@@ -14,6 +15,20 @@ then
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$FILE" "$VERSION"
then
echo "This script must be called with a Libation version number that is present in the filename passed."
exit
fi
# remove trailing ".tar.gz"
FOLDER_MAIN=${FILE::-7}
echo "Working dir: $FOLDER_MAIN"
@@ -90,6 +105,9 @@ ln -s /usr/lib/libation/Libation /usr/bin/libation
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
# Increase the maximum number of inotify instances
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
# workaround until this file is moved to the user's home directory
touch /usr/lib/libation/appsettings.json
chmod 666 /usr/lib/libation/appsettings.json
@@ -97,11 +115,10 @@ chmod 666 /usr/lib/libation/appsettings.json
echo "Creating control file..."
echo "Package: Libation
Version: 8.7.0
Version: $VERSION
Architecture: all
Essential: no
Priority: optional
Depends: ffmpeg
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
" >> "$FOLDER_DEBIAN/control"
@@ -111,8 +128,9 @@ chmod +x "$FOLDER_DEBIAN/preinst"
chmod +x "$FOLDER_DEBIAN/postinst"
echo "Creating .deb file..."
dpkg-deb --build $FOLDER_MAIN
dpkg-deb -Zxz --build $FOLDER_MAIN
rm -r "$FOLDER_MAIN"
echo "Done!"

View File

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

View File

@@ -1,15 +1,15 @@
using System;
using AAXClean;
using System;
using System.Threading.Tasks;
using AAXClean;
using Dinah.Core.Net.Http;
namespace AaxDecrypter
{
{
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
{
public event EventHandler<AppleTags> RetrievedMetadata;
protected AaxFile AaxFile;
protected AaxFile AaxFile { get; private set; }
protected Mp4Operation AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
@@ -22,9 +22,17 @@ namespace AaxDecrypter
AaxFile.AppleTags.Cover = coverArt;
}
public override async Task CancelAsync()
{
IsCanceled = true;
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload();
}
protected bool Step_GetMetadata()
{
AaxFile = new AaxFile(InputFileStream);
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
if (DownloadOptions.StripUnabridged)
{
@@ -32,7 +40,7 @@ namespace AaxDecrypter
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
}
if (DownloadOptions.FixupFile)
if (DownloadOptions.FixupFile && !string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
//Finishing configuring lame encoder.
@@ -43,7 +51,6 @@ namespace AaxDecrypter
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate);
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
@@ -53,58 +60,5 @@ namespace AaxDecrypter
return !IsCanceled;
}
protected DownloadProgress Step_DownloadAudiobook_Start()
{
var zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = InputFileStream.Length
};
OnDecryptProgressUpdate(zeroProgress);
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
return zeroProgress;
}
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
{
AaxFile.Close();
CloseInputFileStream();
OnDecryptProgressUpdate(zeroProgress);
}
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = AaxFile.Duration;
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = (e.ProcessPosition / e.TotalDuration);
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
}
public override async Task CancelAsync()
{
IsCanceled = true;
if (AaxFile != null)
await AaxFile.CancelAsync();
AaxFile?.Dispose();
CloseInputFileStream();
}
}
}

View File

@@ -1,80 +1,24 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AAXClean;
using AAXClean;
using AAXClean.Codecs;
using FileManager;
using System;
using System.IO;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
{
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
private List<string> multiPartFilePaths { get; } = new List<string>();
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
private FileStream workingFileStream;
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
public override async Task<bool> RunAsync()
: base(outFileName, cacheDirectory, dlOptions)
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
//Step 1
Serilog.Log.Information("Begin Get Aaxc Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Get Aaxc Metadata");
else
{
Serilog.Log.Information("Failed to Complete Get Aaxc Metadata");
return false;
}
//Step 2
Serilog.Log.Information("Begin Download Decrypted Audiobook");
if (await Step_DownloadAudiobookAsMultipleFilesPerChapter())
Serilog.Log.Information("Completed Download Decrypted Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Download Decrypted Audiobook");
return false;
}
//Step 3
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 4
Serilog.Log.Information("Begin Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Cleanup");
return false;
}
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
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;
}
/*
@@ -101,10 +45,8 @@ The book will be split into the following files:
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
*/
private async Task<bool> Step_DownloadAudiobookAsMultipleFilesPerChapter()
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
var zeroProgress = Step_DownloadAudiobook_Start();
var chapters = DownloadOptions.ChapterInfo.Chapters;
// Ensure split files are at least minChapterLength in duration.
@@ -127,77 +69,79 @@ That naming may not be desirable for everyone, but it's an easy change to instea
}
}
// reset, just in case
multiPartFilePaths.Clear();
try
{
await (AaxConversion = decryptMultiAsync(splitChapters));
ConversionResult result;
if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(workingFileStream?.Name);
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
result = await ConvertToMultiMp4a(splitChapters);
else
result = await ConvertToMultiMp3(splitChapters);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
Step_DownloadAudiobook_End(zeroProgress);
return result == ConversionResult.NoErrorsDetected;
return AaxConversion.IsCompletedSuccessfully;
}
finally
{
workingFileStream?.Dispose();
FinalizeDownload();
}
}
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp4aAsync
return
DownloadOptions.OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMultiMp4aAsync
(
splitChapters,
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.TrimOutputToChapterLength
);
}
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp3Async
)
: AaxFile.ConvertToMultiMp3Async
(
splitChapters,
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.LameConfig,
DownloadOptions.TrimOutputToChapterLength
);
}
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
=> Callback(currentChapter, splitChapters, newSplitCallback as NewSplitCallback);
private void Callback(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
{
MultiConvertFileProperties props = new()
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
{
OutputFileName = OutputFileName,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
Title = newSplitCallback?.Chapter?.Title,
};
newSplitCallback.OutputFile = createOutputFileStream(props);
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
newSplitCallback.TrackNumber = currentChapter;
newSplitCallback.TrackCount = splitChapters.Count;
MultiConvertFileProperties props = new()
{
OutputFileName = OutputFileName,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
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);
}
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
{
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
FileUtility.SaferDelete(fileName);
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
}
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
private Mp4Operation moveMoovToBeginning(string filename)
{
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters);
multiPartFilePaths.Add(fileName);
FileUtility.SaferDelete(fileName);
var file = File.Open(fileName, FileMode.OpenOrCreate);
OnFileCreated(fileName);
return file;
if (DownloadOptions.OutputFormat is OutputFormat.M4b
&& DownloadOptions.MoveMoovToBeginning
&& filename is not null
&& File.Exists(filename))
{
return Mp4File.RelocateMoovAsync(filename);
}
else return Mp4Operation.CompletedOperation;
}
}
}

View File

@@ -1,126 +1,94 @@
using System;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core.Net.Http;
using FileManager;
using System;
using System.IO;
using System.Threading.Tasks;
using AAXClean;
using AAXClean.Codecs;
using FileManager;
namespace AaxDecrypter
{
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{
private readonly AverageSpeed averageSpeed = new();
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
public override async Task<bool> RunAsync()
: base(outFileName, cacheDirectory, dlOptions)
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
//Step 1
Serilog.Log.Information("Begin Step 1: Get Aaxc Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Step 1: Get Aaxc Metadata");
else
{
Serilog.Log.Information("Failed to Complete Step 1: Get Aaxc Metadata");
return false;
}
//Step 2
Serilog.Log.Information("Begin Step 2: Download Decrypted Audiobook");
if (await Step_DownloadAudiobookAsSingleFile())
Serilog.Log.Information("Completed Step 2: Download Decrypted Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Step 2: Download Decrypted Audiobook");
return false;
}
//Step 3
Serilog.Log.Information("Begin Step 3: Create Cue");
if (await Task.Run(Step_CreateCue))
Serilog.Log.Information("Completed Step 3: Create Cue");
else
{
Serilog.Log.Information("Failed to Complete Step 3: Create Cue");
return false;
}
//Step 4
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 5
Serilog.Log.Information("Begin Step 4: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 4: Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Step 4: Cleanup");
return false;
}
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
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;
AsyncSteps["Step 4: Create Cue"] = Step_CreateCueAsync;
}
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
var zeroProgress = Step_DownloadAudiobook_Start();
FileUtility.SaferDelete(OutputFileName);
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
try
{
await (AaxConversion = decryptAsync(outputFile));
ConversionResult decryptionResult = await decryptAsync(outputFile);
if (AaxConversion.IsCompletedSuccessfully
&& DownloadOptions.MoveMoovToBeginning
&& DownloadOptions.OutputFormat is OutputFormat.M4b)
{
outputFile.Close();
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
}
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
Step_DownloadAudiobook_End(zeroProgress);
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
return success;
return AaxConversion.IsCompletedSuccessfully;
}
finally
{
FinalizeDownload();
}
}
private Task<ConversionResult> decryptAsync(Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 ?
AaxFile.ConvertToMp3Async
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
var remainingTimeToProcess = (e.TotalDuration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = 100d * (1 - remainingTimeToProcess / e.TotalDuration.TotalSeconds);
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
}
private Mp4Operation decryptAsync(Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
? AaxFile.ConvertToMp3Async
(
outputFile,
DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
)
: DownloadOptions.FixupFile ?
AaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
)
: AaxFile.ConvertToMp4aAsync(outputFile);
: DownloadOptions.FixupFile
? AaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
)
: AaxFile.ConvertToMp4aAsync(outputFile);
}
}

View File

@@ -1,9 +1,10 @@
using System;
using Dinah.Core;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
using System;
using System.IO;
using System.Threading.Tasks;
using Dinah.Core;
using Dinah.Core.Net.Http;
using FileManager;
namespace AaxDecrypter
{
@@ -19,19 +20,17 @@ namespace AaxDecrypter
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public event EventHandler<string> FileCreated;
public bool IsCanceled { get; set; }
public string TempFilePath { get; }
protected string OutputFileName { get; private set; }
public bool IsCanceled { get; protected set; }
protected AsyncStepSequence AsyncSteps { get; } = new();
protected string OutputFileName { get; }
protected IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position;
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
private NetworkFileStreamPersister nfsPersister;
private string jsonDownloadState { get; }
private readonly NetworkFileStreamPersister nfsPersister;
private readonly DownloadProgress zeroProgress;
private readonly string jsonDownloadState;
private readonly string tempFilePath;
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
{
@@ -45,16 +44,73 @@ namespace AaxDecrypter
Directory.CreateDirectory(cacheDirectory);
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
nfsPersister = OpenNetworkFileStream();
zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = InputFileStream.Length
};
OnDecryptProgressUpdate(zeroProgress);
}
public async Task<bool> RunAsync()
{
var progressTask = Task.Run(reportProgress);
AsyncSteps[$"Cleanup"] = CleanupAsync;
(bool success, var elapsed) = await AsyncSteps.RunAsync();
await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
return success;
async Task reportProgress()
{
AverageSpeed averageSpeed = new();
while (InputFileStream.CanRead && InputFileStream.Length > InputFilePosition && !InputFileStream.IsCancelled)
{
averageSpeed.AddPosition(InputFilePosition);
var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average;
if (double.IsNormal(estSecsRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
var progressPercent = 100d * InputFilePosition / InputFileStream.Length;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = InputFilePosition,
TotalBytesToReceive = InputFileStream.Length
});
await Task.Delay(200);
}
OnDecryptTimeRemaining(TimeSpan.Zero);
OnDecryptProgressUpdate(zeroProgress);
}
}
public abstract Task CancelAsync();
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt)
{
@@ -62,8 +118,6 @@ namespace AaxDecrypter
OnRetrievedCoverArt(coverArt);
}
public abstract Task<bool> RunAsync();
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string authors)
@@ -79,69 +133,67 @@ namespace AaxDecrypter
protected void OnFileCreated(string path)
=> FileCreated?.Invoke(this, path);
protected void CloseInputFileStream()
protected virtual void FinalizeDownload()
{
nfsPersister?.NetworkFileStream?.Close();
nfsPersister?.Dispose();
OnDecryptTimeRemaining(TimeSpan.Zero);
OnDecryptProgressUpdate(zeroProgress);
}
protected bool Step_CreateCue()
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
{
if (!DownloadOptions.CreateCueSheet) return true;
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;
// not a critical step. its failure should not prevent future steps from running
try
{
var path = Path.ChangeExtension(OutputFileName, ".cue");
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
OnFileCreated(path);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
}
return !IsCanceled;
}
protected bool Step_Cleanup()
private async Task<bool> CleanupAsync()
{
bool success = !IsCanceled;
if (success)
if (IsCanceled) return false;
FileUtility.SaferDelete(jsonDownloadState);
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
!string.IsNullOrEmpty(DownloadOptions.AudibleIV) &&
DownloadOptions.RetainEncryptedFile)
{
FileUtility.SaferDelete(jsonDownloadState);
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
FileUtility.SaferMove(tempFilePath, aaxPath);
if (DownloadOptions.AudibleKey is not null &&
DownloadOptions.AudibleIV is not null &&
DownloadOptions.RetainEncryptedFile)
{
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
FileUtility.SaferMove(TempFilePath, aaxPath);
//Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key");
FileUtility.SaferDelete(keyPath);
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
//Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key");
FileUtility.SaferDelete(keyPath);
File.WriteAllText(keyPath, $"Key={DownloadOptions.AudibleKey}\r\nIV={DownloadOptions.AudibleIV}");
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(TempFilePath);
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(tempFilePath);
return success;
}
protected async Task<bool> Step_DownloadClipsBookmarks()
{
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
{
var recordsFile = await DownloadOptions.SaveClipsAndBookmarks(OutputFileName);
if (File.Exists(recordsFile))
OnFileCreated(recordsFile);
}
return !IsCanceled;
}
@@ -151,31 +203,30 @@ namespace AaxDecrypter
try
{
if (!File.Exists(jsonDownloadState))
return nfsp = NewNetworkFilePersister();
return nfsp = newNetworkFilePersister();
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// If More than ~1 hour has elapsed since getting the download url, it will expire.
// The new url will be to the same file.
// The download url expires after 1 hour.
// The new url points to the same file.
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
return nfsp;
}
catch
{
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(TempFilePath);
return nfsp = NewNetworkFilePersister();
FileUtility.SaferDelete(tempFilePath);
return nfsp = newNetworkFilePersister();
}
finally
{
if (nfsp?.NetworkFileStream is not null)
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
}
}
private NetworkFileStreamPersister NewNetworkFilePersister()
{
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
NetworkFileStreamPersister newNetworkFilePersister()
{
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
}
}
}

View File

@@ -0,0 +1,171 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
namespace AaxDecrypter;
public static class LinqStats
{
public static (double mean, double stdDev) BasicStatisticsBy<T>(this IEnumerable<T> values, Func<T, double> selector)
{
var count = values.Count();
var mean = values.Average(selector);
return (mean, Math.Sqrt(values.Sum(s => Math.Pow(selector(s) - mean, 2)) / (count - 1)));
}
public static bool T_Test_2By<T>(this IEnumerable<T> values, Func<T, double> selector, IEnumerable<T> secondGroup, Significance confidence)
{
var n1 = values.Count();
var n2 = secondGroup.Count();
var n = n1 + n2;
if (n1 < 3 || n2 < 3) return false;
(var mean1, var stdDev1) = values.BasicStatisticsBy(selector);
(var mean2, var stdDev2) = secondGroup.BasicStatisticsBy(selector);
var pooledStdDev = Math.Sqrt((((n1 - 1) * (stdDev1 * stdDev1)) + ((n2 - 1) * (stdDev2 * stdDev2))) / (n1 + n2 - 2));
var testStat = Math.Abs(mean1 - mean2) / (pooledStdDev * Math.Sqrt(1d / n1 + 1d / n2));
var crit = T_Stat(Math.Min(n - 2, MAX_DEGREES_FREEDOM), confidence);
return testStat > crit;
}
public static bool T_Test_1By<T>(this IEnumerable<T> values, Func<T, double> selector, double testMean, Significance confidence)
{
var n = values.Count();
if (n < 2) return false;
(var sampleMean, var sampleStdDev) = values.BasicStatisticsBy(selector);
var testStat = Math.Abs(sampleMean - testMean) / (sampleStdDev / Math.Sqrt(n));
var crit = T_Stat(Math.Min(n - 1, MAX_DEGREES_FREEDOM), confidence);
return testStat > crit;
}
private static double T_Stat(int degreesFreedom, Significance confidence)
{
ArgumentValidator.EnsureBetweenInclusive(degreesFreedom, nameof(degreesFreedom), MIN_DEGREES_FREEDOM, MAX_DEGREES_FREEDOM);
return T_TABLE[(int)confidence][degreesFreedom - MIN_DEGREES_FREEDOM];
}
static LinqStats()
{
T_TABLE = new double[][] { T_Table_01, T_Table_05, T_Table_10, T_Table_15, T_Table_20, T_Table_25 };
}
private const int MIN_DEGREES_FREEDOM = 1;
private const int MAX_DEGREES_FREEDOM = 201;
/// <summary>
/// 2-tailed t-Distribution critical values at 75%, 80%, 85%,
/// 90%, 95%, and 99% confidence for 1 - 201 degrees of freedom.
/// </summary>
private readonly static double[][] T_TABLE;
private readonly static double[] T_Table_25 = { 2.414213562, 1.603567451, 1.422625281, 1.344397556, 1.300949037, 1.273349309, 1.254278682, 1.240318261, 1.229659173, 1.221255395, 1.214460246, 1.208852542, 1.204146242, 1.200140298, 1.196689284, 1.193685414, 1.191047107, 1.188711483, 1.186629298, 1.184761434, 1.183076432, 1.181548697, 1.180157199, 1.178884497, 1.177716003, 1.176639425, 1.175644329, 1.174721803, 1.173864189, 1.173064871, 1.1723181, 1.17161886, 1.170962753, 1.17034591, 1.169764906, 1.169216709, 1.168698615, 1.168208212, 1.167743338, 1.167302049, 1.166882595, 1.166483396, 1.166103019, 1.165740162, 1.165393644, 1.165062385, 1.164745398, 1.164441782, 1.164150707, 1.163871412, 1.163603196, 1.163345413, 1.163097467, 1.162858803, 1.162628911, 1.162407316, 1.162193577, 1.161987283, 1.161788052, 1.161595527, 1.161409375, 1.161229286, 1.161054967, 1.160886145, 1.160722566, 1.160563987, 1.160410184, 1.160260944, 1.160116066, 1.159975363, 1.159838656, 1.159705777, 1.159576569, 1.15945088, 1.15932857, 1.159209503, 1.159093552, 1.158980598, 1.158870524, 1.158763222, 1.158658589, 1.158556526, 1.15845694, 1.158359742, 1.158264847, 1.158172173, 1.158081645, 1.157993188, 1.157906731, 1.157822209, 1.157739556, 1.157658712, 1.157579617, 1.157502216, 1.157426454, 1.157352281, 1.157279646, 1.157208502, 1.157138804, 1.157070509, 1.157003573, 1.156937958, 1.156873624, 1.156810534, 1.156748653, 1.156687945, 1.156628379, 1.156569922, 1.156512543, 1.156456213, 1.156400904, 1.156346587, 1.156293237, 1.156240827, 1.156189334, 1.156138733, 1.156089001, 1.156040117, 1.155992058, 1.155944804, 1.155898335, 1.155852631, 1.155807674, 1.155763446, 1.155719928, 1.155677105, 1.155634959, 1.155593475, 1.155552637, 1.15551243, 1.155472839, 1.155433851, 1.155395452, 1.155357629, 1.155320368, 1.155283658, 1.155247486, 1.155211841, 1.15517671, 1.155142084, 1.15510795, 1.1550743, 1.155041122, 1.155008406, 1.154976144, 1.154944326, 1.154912942, 1.154881984, 1.154851443, 1.154821311, 1.15479158, 1.154762241, 1.154733287, 1.154704711, 1.154676505, 1.154648662, 1.154621175, 1.154594037, 1.154567242, 1.154540783, 1.154514654, 1.154488849, 1.154463361, 1.154438185, 1.154413316, 1.154388747, 1.154364474, 1.15434049, 1.154316792, 1.154293373, 1.154270229, 1.154247355, 1.154224746, 1.154202398, 1.154180307, 1.154158467, 1.154136875, 1.154115526, 1.154094417, 1.154073543, 1.1540529, 1.154032485, 1.154012294, 1.153992323, 1.153972568, 1.153953027, 1.153933695, 1.15391457, 1.153895647, 1.153876925, 1.153858399, 1.153840066, 1.153821925, 1.15380397, 1.153786201, 1.153768613, 1.153751204, 1.153733972, 1.153716914, 1.153700026 };
private readonly static double[] T_Table_20 = { 3.077683537, 1.885618083, 1.637744354, 1.533206274, 1.475884049, 1.439755747, 1.414923928, 1.39681531, 1.383028738, 1.372183641, 1.363430318, 1.356217334, 1.350171289, 1.345030374, 1.340605608, 1.336757167, 1.33337939, 1.330390944, 1.327728209, 1.325340707, 1.323187874, 1.321236742, 1.31946024, 1.317835934, 1.316345073, 1.314971864, 1.313702913, 1.312526782, 1.311433647, 1.310415025, 1.309463549, 1.308572793, 1.307737124, 1.306951587, 1.306211802, 1.305513886, 1.304854381, 1.304230204, 1.303638589, 1.303077053, 1.302543359, 1.302035487, 1.301551608, 1.30109006, 1.300649332, 1.300228048, 1.299824947, 1.299438879, 1.299068785, 1.298713694, 1.298372713, 1.298045016, 1.297729843, 1.297426488, 1.2971343, 1.296852673, 1.296581044, 1.29631889, 1.296065725, 1.295821094, 1.295584571, 1.295355762, 1.295134294, 1.29491982, 1.294712013, 1.294510568, 1.294315197, 1.294125629, 1.293941609, 1.293762898, 1.293589269, 1.293420507, 1.293256413, 1.293096793, 1.292941469, 1.292790268, 1.292643029, 1.292499597, 1.292359828, 1.292223583, 1.29209073, 1.291961144, 1.291834705, 1.291711301, 1.291590824, 1.291473171, 1.291358243, 1.291245948, 1.291136195, 1.291028899, 1.290923979, 1.290821356, 1.290720956, 1.290622708, 1.290526543, 1.290432395, 1.290340202, 1.290249904, 1.290161442, 1.290074761, 1.289989809, 1.289906533, 1.289824884, 1.289744816, 1.289666283, 1.289589241, 1.289513648, 1.289439464, 1.289366649, 1.289295166, 1.289224979, 1.289156054, 1.289088355, 1.289021851, 1.28895651, 1.288892302, 1.288829199, 1.288767171, 1.288706191, 1.288646234, 1.288587273, 1.288529284, 1.288472243, 1.288416127, 1.288360913, 1.288306581, 1.288253109, 1.288200477, 1.288148665, 1.288097654, 1.288047427, 1.287997964, 1.287949248, 1.287901264, 1.287853994, 1.287807422, 1.287761534, 1.287716314, 1.287671748, 1.287627821, 1.287584521, 1.287541833, 1.287499745, 1.287458245, 1.287417319, 1.287376957, 1.287337146, 1.287297876, 1.287259135, 1.287220914, 1.2871832, 1.287145985, 1.287109259, 1.287073012, 1.287037235, 1.287001918, 1.286967053, 1.286932631, 1.286898644, 1.286865084, 1.286831942, 1.286799212, 1.286766884, 1.286734952, 1.286703409, 1.286672248, 1.286641461, 1.286611042, 1.286580985, 1.286551283, 1.286521929, 1.286492918, 1.286464244, 1.286435901, 1.286407882, 1.286380184, 1.286352799, 1.286325724, 1.286298952, 1.286272479, 1.286246299, 1.286220408, 1.286194801, 1.286169474, 1.286144421, 1.286119638, 1.286095122, 1.286070867, 1.28604687, 1.286023127, 1.285999633, 1.285976384, 1.285953377, 1.285930609, 1.285908074, 1.285885771, 1.285863694, 1.285841842, 1.285820209, 1.285798794 };
private readonly static double[] T_Table_15 = { 4.16529977, 2.281930588, 1.924319657, 1.778192164, 1.699362566, 1.650173154, 1.616591737, 1.59222144, 1.573735785, 1.559235933, 1.547559766, 1.537956495, 1.529919606, 1.523095061, 1.517227969, 1.51213017, 1.507659754, 1.503707672, 1.500188756, 1.497035518, 1.494193795, 1.491619612, 1.489276897, 1.487135783, 1.485171326, 1.483362535, 1.481691617, 1.48014339, 1.478704821, 1.477364662, 1.47611315, 1.474941772, 1.473843072, 1.47281049, 1.471838233, 1.470921166, 1.470054719, 1.469234815, 1.468457801, 1.467720399, 1.467019655, 1.466352901, 1.465717725, 1.465111933, 1.464533534, 1.463980712, 1.463451805, 1.462945295, 1.46245979, 1.461994009, 1.461546775, 1.461117, 1.460703683, 1.460305896, 1.45992278, 1.459553538, 1.45919743, 1.458853767, 1.458521908, 1.458201256, 1.457891251, 1.457591373, 1.457301133, 1.457020074, 1.456747768, 1.45648381, 1.456227824, 1.455979454, 1.455738365, 1.455504241, 1.455276784, 1.455055715, 1.454840767, 1.45463169, 1.454428246, 1.454230212, 1.454037373, 1.453849529, 1.453666487, 1.453488066, 1.453314093, 1.453144404, 1.452978842, 1.452817259, 1.452659513, 1.452505469, 1.452354998, 1.452207977, 1.452064289, 1.451923821, 1.451786468, 1.451652126, 1.451520697, 1.451392088, 1.451266209, 1.451142973, 1.451022299, 1.450904108, 1.450788323, 1.450674871, 1.450563684, 1.450454694, 1.450347836, 1.450243048, 1.450140271, 1.450039448, 1.449940523, 1.449843444, 1.449748158, 1.449654617, 1.449562773, 1.449472581, 1.449383997, 1.449296977, 1.449211481, 1.449127468, 1.449044902, 1.448963744, 1.448883959, 1.448805513, 1.448728372, 1.448652503, 1.448577876, 1.44850446, 1.448432226, 1.448361146, 1.448291192, 1.448222337, 1.448154557, 1.448087826, 1.44802212, 1.447957415, 1.447893688, 1.447830919, 1.447769085, 1.447708165, 1.44764814, 1.44758899, 1.447530695, 1.447473238, 1.447416601, 1.447360765, 1.447305715, 1.447251433, 1.447197905, 1.447145113, 1.447093044, 1.447041682, 1.446991013, 1.446941023, 1.446891698, 1.446843026, 1.446794994, 1.446747588, 1.446700797, 1.446654609, 1.446609012, 1.446563996, 1.446519548, 1.446475659, 1.446432318, 1.446389514, 1.446347238, 1.44630548, 1.44626423, 1.44622348, 1.44618322, 1.446143442, 1.446104137, 1.446065296, 1.446026911, 1.445988975, 1.44595148, 1.445914417, 1.44587778, 1.445841561, 1.445805753, 1.445770349, 1.445735343, 1.445700727, 1.445666495, 1.445632641, 1.445599159, 1.445566042, 1.445533284, 1.445500881, 1.445468825, 1.445437112, 1.445405736, 1.445374691, 1.445343973, 1.445313576, 1.445283495, 1.445253726, 1.445224264, 1.445195103, 1.445166239, 1.445137668, 1.445109385, 1.445081387 };
private readonly static double[] T_Table_10 = { 6.313751515, 2.91998558, 2.353363435, 2.131846786, 2.015048373, 1.943180281, 1.894578605, 1.859548038, 1.833112933, 1.812461123, 1.795884819, 1.782287556, 1.770933396, 1.761310136, 1.753050356, 1.745883676, 1.739606726, 1.734063607, 1.729132812, 1.724718243, 1.720742903, 1.717144374, 1.713871528, 1.71088208, 1.708140761, 1.70561792, 1.703288446, 1.701130934, 1.699127027, 1.697260887, 1.695518783, 1.693888748, 1.692360309, 1.690924255, 1.689572458, 1.688297714, 1.68709362, 1.68595446, 1.684875122, 1.683851013, 1.682878002, 1.681952357, 1.681070703, 1.680229977, 1.679427393, 1.678660414, 1.677926722, 1.677224196, 1.676550893, 1.675905025, 1.67528495, 1.674689154, 1.674116237, 1.673564906, 1.673033965, 1.672522303, 1.672028888, 1.671552762, 1.671093032, 1.670648865, 1.670219484, 1.669804163, 1.669402222, 1.669013025, 1.668635976, 1.668270514, 1.667916114, 1.667572281, 1.667238549, 1.666914479, 1.666599658, 1.666293696, 1.665996224, 1.665706893, 1.665425373, 1.665151353, 1.664884537, 1.664624645, 1.664371409, 1.664124579, 1.663883913, 1.663649184, 1.663420175, 1.663196679, 1.6629785, 1.662765449, 1.662557349, 1.662354029, 1.662155326, 1.661961084, 1.661771155, 1.661585397, 1.661403674, 1.661225855, 1.661051817, 1.66088144, 1.66071461, 1.660551217, 1.660391156, 1.660234326, 1.66008063, 1.659929976, 1.659782273, 1.659637437, 1.659495383, 1.659356034, 1.659219312, 1.659085144, 1.658953458, 1.658824187, 1.658697265, 1.658572629, 1.658450216, 1.658329969, 1.65821183, 1.658095744, 1.657981659, 1.657869522, 1.657759285, 1.657650899, 1.657544319, 1.657439499, 1.657336397, 1.65723497, 1.657135178, 1.657036982, 1.656940344, 1.656845226, 1.656751594, 1.656659413, 1.656568649, 1.65647927, 1.656391244, 1.656304542, 1.656219133, 1.656134988, 1.65605208, 1.655970382, 1.655889868, 1.655810511, 1.655732287, 1.655655173, 1.655579143, 1.655504177, 1.655430251, 1.655357345, 1.655285437, 1.655214506, 1.655144534, 1.6550755, 1.655007387, 1.654940175, 1.654873847, 1.654808385, 1.654743774, 1.654679996, 1.654617035, 1.654554875, 1.654493503, 1.654432901, 1.654373057, 1.654313957, 1.654255585, 1.654197929, 1.654140976, 1.654084713, 1.654029128, 1.653974208, 1.653919942, 1.653866317, 1.653813324, 1.653760949, 1.653709184, 1.653658017, 1.653607437, 1.653557435, 1.653508002, 1.653459126, 1.6534108, 1.653363013, 1.653315758, 1.653269024, 1.653222803, 1.653177088, 1.653131869, 1.653087138, 1.653042889, 1.652999113, 1.652955802, 1.652912949, 1.652870547, 1.652828589, 1.652787068, 1.652745977, 1.65270531, 1.652665059, 1.652625219, 1.652585784, 1.652546746, 1.652508101 };
private readonly static double[] T_Table_05 = { 12.70620474, 4.30265273, 3.182446305, 2.776445105, 2.570581836, 2.446911851, 2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.20098516, 2.17881283, 2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.10092204, 2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.06865761, 2.063898562, 2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456, 2.039513446, 2.036933343, 2.034515297, 2.032244509, 2.030107928, 2.028094001, 2.026192463, 2.024394164, 2.02269092, 2.02107539, 2.01954097, 2.018081703, 2.016692199, 2.015367574, 2.014103389, 2.012895599, 2.011740514, 2.010634758, 2.009575237, 2.008559112, 2.00758377, 2.006646805, 2.005745995, 2.004879288, 2.004044783, 2.003240719, 2.002465459, 2.001717484, 2.000995378, 2.000297822, 1.999623585, 1.998971517, 1.998340543, 1.997729654, 1.997137908, 1.996564419, 1.996008354, 1.995468931, 1.994945415, 1.994437112, 1.993943368, 1.993463567, 1.992997126, 1.992543495, 1.992102154, 1.99167261, 1.991254395, 1.990847069, 1.99045021, 1.990063421, 1.989686323, 1.989318557, 1.98895978, 1.988609667, 1.988267907, 1.987934206, 1.987608282, 1.987289865, 1.9869787, 1.986674541, 1.986377154, 1.986086317, 1.985801814, 1.985523442, 1.985251004, 1.984984312, 1.984723186, 1.984467455, 1.984216952, 1.983971519, 1.983731003, 1.983495259, 1.983264145, 1.983037526, 1.982815274, 1.982597262, 1.98238337, 1.982173483, 1.98196749, 1.981765282, 1.981566757, 1.981371815, 1.981180359, 1.980992298, 1.980807541, 1.980626002, 1.980447599, 1.980272249, 1.980099876, 1.979930405, 1.979763763, 1.979599878, 1.979438685, 1.979280117, 1.979124109, 1.978970602, 1.978819535, 1.97867085, 1.978524491, 1.978380405, 1.978238539, 1.978098842, 1.977961264, 1.977825758, 1.977692277, 1.977560777, 1.977431212, 1.977303542, 1.977177724, 1.97705372, 1.976931489, 1.976810994, 1.976692198, 1.976575066, 1.976459563, 1.976345655, 1.976233309, 1.976122494, 1.976013178, 1.975905331, 1.975798924, 1.975693928, 1.975590315, 1.975488058, 1.975387131, 1.975287508, 1.975189163, 1.975092073, 1.974996213, 1.97490156, 1.974808092, 1.974715786, 1.974624621, 1.974534576, 1.97444563, 1.974357764, 1.974270957, 1.974185191, 1.974100447, 1.974016708, 1.973933954, 1.973852169, 1.973771337, 1.97369144, 1.973612462, 1.973534388, 1.973457202, 1.973380889, 1.973305434, 1.973230823, 1.973157042, 1.973084077, 1.973011915, 1.972940542, 1.972869946, 1.972800114, 1.972731033, 1.972662692, 1.972595079, 1.972528182, 1.97246199, 1.972396491, 1.972331676, 1.972267533, 1.972204051, 1.972141222, 1.972079034, 1.972017478, 1.971956544, 1.971896224 };
private readonly static double[] T_Table_01 = { 63.65674116, 9.924843201, 5.84090931, 4.604094871, 4.032142984, 3.707428021, 3.499483297, 3.355387331, 3.249835542, 3.169272673, 3.105806516, 3.054539589, 3.012275839, 2.976842734, 2.946712883, 2.920781622, 2.89823052, 2.878440473, 2.860934606, 2.84533971, 2.831359558, 2.818756061, 2.807335684, 2.796939505, 2.787435814, 2.778714533, 2.770682957, 2.763262455, 2.756385904, 2.749995654, 2.744041919, 2.738481482, 2.733276642, 2.728394367, 2.723805589, 2.71948463, 2.715408722, 2.711557602, 2.707913184, 2.704459267, 2.701181304, 2.698066186, 2.695102079, 2.692278266, 2.689585019, 2.687013492, 2.684555618, 2.682204027, 2.679951974, 2.677793271, 2.675722234, 2.673733631, 2.671822636, 2.669984796, 2.668215988, 2.666512398, 2.664870482, 2.663286954, 2.661758752, 2.660283029, 2.658857127, 2.657478565, 2.656145025, 2.654854337, 2.653604469, 2.652393515, 2.651219685, 2.650081299, 2.648976774, 2.647904624, 2.646863444, 2.645851913, 2.644868782, 2.643912872, 2.642983067, 2.642078313, 2.641197611, 2.640340015, 2.639504627, 2.638690596, 2.637897113, 2.63712341, 2.636368757, 2.635632458, 2.634913852, 2.634212309, 2.633527229, 2.632858038, 2.632204191, 2.631565166, 2.630940463, 2.630329608, 2.629732145, 2.629147638, 2.628575671, 2.628015844, 2.627467774, 2.626931096, 2.626405457, 2.625890521, 2.625385965, 2.624891476, 2.624406758, 2.623931523, 2.623465496, 2.623008411, 2.622560015, 2.622120061, 2.621688313, 2.621264543, 2.620848534, 2.620440073, 2.620038957, 2.619644989, 2.619257981, 2.618877749, 2.618504116, 2.618136914, 2.617775976, 2.617421145, 2.617072266, 2.616729191, 2.616391776, 2.616059883, 2.615733377, 2.615412127, 2.615096008, 2.614784899, 2.61447868, 2.614177238, 2.613880461, 2.613588242, 2.613300477, 2.613017065, 2.612737908, 2.61246291, 2.61219198, 2.611925028, 2.611661966, 2.611402711, 2.611147181, 2.610895295, 2.610646976, 2.61040215, 2.610160742, 2.609922682, 2.609687901, 2.609456331, 2.609227907, 2.609002566, 2.608780245, 2.608560883, 2.608344423, 2.608130807, 2.60791998, 2.607711886, 2.607506474, 2.607303692, 2.607103489, 2.606905817, 2.606710628, 2.606517876, 2.606327515, 2.606139501, 2.605953791, 2.605770342, 2.605589114, 2.605410067, 2.605233162, 2.605058359, 2.604885623, 2.604714916, 2.604546204, 2.60437945, 2.604214622, 2.604051686, 2.60389061, 2.603731363, 2.603573912, 2.603418229, 2.603264282, 2.603112045, 2.602961487, 2.602812582, 2.602665303, 2.602519622, 2.602375515, 2.602232955, 2.602091918, 2.60195238, 2.601814317, 2.601677705, 2.601542523, 2.601408747, 2.601276355, 2.601145327, 2.601015642, 2.600887278, 2.600760216, 2.600634436 };
}
public enum Significance
{
P01,
P05,
P10,
P15,
P20,
P25
}
public class AverageSpeed
{
/// <summary>Average speed in units per second</summary>
public double Average { get; private set; }
public TimeSpan SlowWindow { get; }
public TimeSpan FastWindow { get; }
public Significance SlowSignificance { get; }
public Significance FastSignificance { get; }
private DateTime start;
private TimeSpan lastTime;
private double lastPosition = double.NaN;
private readonly record struct Point(TimeSpan Time, double Velocity);
private readonly LinkedList<Point> speeds = new();
private const int MAX_SPEEDS = 200;
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
/// <param name="slowWindow">Total moving average time window</param>
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
{
SlowWindow = ArgumentValidator.EnsureGreaterThan(slowWindow, nameof(slowWindow), fastWindow);
FastWindow = ArgumentValidator.EnsureGreaterThan(fastWindow, nameof(fastWindow), TimeSpan.Zero);
SlowSignificance = slowSignificance;
FastSignificance = fastSignificance;
}
/// <summary>Add a new position to the moving average</summary>
public void AddPosition(double position)
{
var now = DateTime.Now;
if (start == default)
start = now;
var time = now - start;
while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First.Value.Time > SlowWindow))
speeds.RemoveFirst();
if (!double.IsNaN(lastPosition))
{
var newSpeed = (position - lastPosition) / (time - lastTime).TotalSeconds;
speeds.AddLast(new Point(time, newSpeed));
}
lastTime = time;
lastPosition = position;
Average = ComputeNextAverage();
}
private double ComputeNextAverage()
{
if (speeds.Count == 0)
return 0;
else if (speeds.Count == 1)
return speeds.Last.Value.Velocity;
else
{
var n_newest = speeds.Count(s => s.Time > lastTime.Subtract(FastWindow));
var n_oldest = speeds.Count - n_newest;
if (speeds.Take(n_oldest).T_Test_2By(s => s.Velocity, speeds.TakeLast(n_newest), FastSignificance))
{
//Speeds in FastWindow are significantly different from reset of speeds in SlowWindow.
//Discard older speeds and keep only speeds in FastWindow
for (; n_oldest > 0; n_oldest--)
speeds.RemoveFirst();
return speeds.Average(s => s.Velocity);
}
else
return
speeds.T_Test_1By(s => s.Velocity, Average, SlowSignificance)
? speeds.Average(s => s.Velocity)
: Average;
}
}
}

View File

@@ -1,8 +1,7 @@
using System;
using AAXClean;
using Dinah.Core;
using System.IO;
using System.Text;
using AAXClean;
using Dinah.Core;
namespace AaxDecrypter
{
@@ -16,15 +15,14 @@ namespace AaxDecrypter
var startOffset = chapters.StartOffset;
var trackCount = 0;
var trackCount = 1;
foreach (var c in chapters.Chapters)
{
var startTime = c.StartOffset - startOffset;
trackCount++;
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds / 1000d * 75)}");
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
}
return stringBuilder.ToString();
@@ -46,7 +44,7 @@ namespace AaxDecrypter
for (var i = 0; i < cueContents.Length; i++)
{
var line = cueContents[i];
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
continue;
var fileTypeBegins = line.LastIndexOf(" ") + 1;

View File

@@ -5,13 +5,13 @@ using System.Threading.Tasks;
namespace AaxDecrypter
{
public interface IDownloadOptions
{
{
event EventHandler<long> DownloadSpeedChanged;
FileManager.ReplacementCharacters ReplacementCharacters { get; }
string DownloadUrl { get; }
string UserAgent { get; }
string AudibleKey { get; }
string AudibleIV { get; }
TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; }
bool RetainEncryptedFile { get; }
@@ -24,8 +24,9 @@ namespace AaxDecrypter
NAudio.Lame.LameConfig LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitleName(MultiConvertFileProperties props);
Task<string> SaveClipsAndBookmarks(string fileName);
}
string GetMultipartTitle(MultiConvertFileProperties props);
Task<string> SaveClipsAndBookmarksAsync(string fileName);
}
}

View File

@@ -1,7 +1,5 @@
using AAXClean;
using NAudio.Lame;
using System;
using System.Linq;
namespace AaxDecrypter
{

View File

@@ -1,6 +1,4 @@
using System;
using System.IO;
using FileManager;
namespace AaxDecrypter
{
@@ -10,6 +8,6 @@ namespace AaxDecrypter
public int PartsPosition { get; set; }
public int PartsTotal { get; set; }
public string Title { get; set; }
public DateTime FileDate { get; } = DateTime.Now;
}
}

View File

@@ -3,7 +3,6 @@ using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
@@ -83,16 +82,13 @@ namespace AaxDecrypter
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string> requestHeaders = null)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
SaveFilePath = saveFilePath;
Uri = uri;
WritePosition = writePosition;
RequestHeaders = requestHeaders ?? new();
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
@@ -109,8 +105,8 @@ namespace AaxDecrypter
#region Downloader
/// <summary> Update the <see cref="JsonFilePersister"/>. </summary>
private void Update()
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
private void OnUpdate()
{
RequestHeaders["Range"] = $"bytes={WritePosition}-";
try
@@ -167,7 +163,7 @@ namespace AaxDecrypter
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
return Task.Run(async () => await DownloadFile(networkStream), _cancellationSource.Token);
return Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
}
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
@@ -184,7 +180,7 @@ namespace AaxDecrypter
int bytesRead;
do
{
bytesRead = await networkStream.ReadAsync(buff, 0, DOWNLOAD_BUFF_SZ, _cancellationSource.Token);
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
downloadPosition += bytesRead;
@@ -193,7 +189,7 @@ namespace AaxDecrypter
{
await _writeFile.FlushAsync(_cancellationSource.Token);
WritePosition = downloadPosition;
Update();
OnUpdate();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
_downloadedPiece.Set();
}
@@ -233,26 +229,19 @@ namespace AaxDecrypter
networkStream.Close();
_writeFile.Close();
_downloadedPiece.Set();
Update();
OnUpdate();
}
}
#endregion
#region Json Connverters
public static JsonSerializerSettings GetJsonSerializerSettings()
=> new JsonSerializerSettings();
#endregion
#region Download Stream Reader
[JsonIgnore]
public override bool CanRead => true;
public override bool CanRead => _readFile.CanRead;
[JsonIgnore]
public override bool CanSeek => true;
public override bool CanSeek => _readFile.CanSeek;
[JsonIgnore]
public override bool CanWrite => false;
@@ -289,7 +278,7 @@ namespace AaxDecrypter
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
return IsCancelled ? 0: _readFile.Read(buffer, offset, count);
return IsCancelled ? 0 : _readFile.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
@@ -306,7 +295,7 @@ namespace AaxDecrypter
}
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition)
{
while (WritePosition < requiredPosition
@@ -317,20 +306,31 @@ namespace AaxDecrypter
}
}
public override void Close()
{
_cancellationSource.Cancel();
_backgroundDownloadTask?.Wait();
private bool disposed = false;
_readFile.Close();
_writeFile.Close();
Update();
/*
* https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0
*
* In derived classes, do not override the Close() method, instead, put all of the
* Stream cleanup logic in the Dispose(Boolean) method.
*/
protected override void Dispose(bool disposing)
{
if (disposing && !disposed)
{
_cancellationSource.Cancel();
_backgroundDownloadTask?.GetAwaiter().GetResult();
_downloadedPiece?.Dispose();
_cancellationSource?.Dispose();
_readFile.Dispose();
_writeFile.Dispose();
OnUpdate();
}
disposed = true;
base.Dispose(disposing);
}
#endregion
~NetworkFileStream()
{
_downloadedPiece?.Close();
}
}
}

View File

@@ -1,11 +1,9 @@
using Dinah.Core.IO;
using Newtonsoft.Json;
namespace AaxDecrypter
{
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
{
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
{
/// <summary>Alias for Target </summary>
public NetworkFileStream NetworkFileStream => Target;
@@ -17,7 +15,11 @@ namespace AaxDecrypter
public NetworkFileStreamPersister(string path, string jsonPath = null)
: base(path, jsonPath) { }
protected override JsonSerializerSettings GetSerializerSettings() => NetworkFileStream.GetJsonSerializerSettings();
}
protected override void Dispose(bool disposing)
{
if (disposing)
NetworkFileStream?.Dispose();
base.Dispose(disposing);
}
}
}

View File

@@ -1,124 +1,44 @@
using System;
using System.Threading;
using FileManager;
using System;
using System.Threading.Tasks;
using Dinah.Core.Net.Http;
using FileManager;
namespace AaxDecrypter
{
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
{
protected override long InputFilePosition => InputFileStream.WritePosition;
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic) { }
public override async Task<bool> RunAsync()
: base(outFileName, cacheDirectory, dlLic)
{
try
{
Serilog.Log.Information("Begin downloading unencrypted audiobook.");
//Step 1
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Step 1: Get Mp3 Metadata");
else
{
Serilog.Log.Information("Failed to Complete Step 1: Get Mp3 Metadata");
return false;
}
//Step 2
Serilog.Log.Information("Begin Step 2: Download Audiobook");
if (await Task.Run(Step_DownloadAudiobookAsSingleFile))
Serilog.Log.Information("Completed Step 2: Download Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Step 2: Download Audiobook");
return false;
}
//Step 3
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 4
Serilog.Log.Information("Begin Step 3: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 3: Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Step 3: Cleanup");
return false;
}
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
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;
CloseInputFileStream();
FinalizeDownload();
return Task.CompletedTask;
}
protected bool Step_GetMetadata()
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
OnRetrievedCoverArt(null);
return !IsCanceled;
}
private bool Step_DownloadAudiobookAsSingleFile()
{
DateTime startTime = DateTime.Now;
// MUST put InputFileStream.Length first, because it starts background downloader.
while (InputFileStream.Length > InputFilePosition && !InputFileStream.IsCancelled)
await Task.Delay(200);
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
if (IsCanceled)
return false;
else
{
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
Thread.Sleep(200);
FinalizeDownload();
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
OnFileCreated(OutputFileName);
return true;
}
CloseInputFileStream();
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
SetOutputFileName(realOutputFileName);
OnFileCreated(realOutputFileName);
return !IsCanceled;
}
}
}

View File

@@ -2,10 +2,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>8.8.2.1</Version>
<Version>9.2.3.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="4.0.3" />
<PackageReference Include="Octokit" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />

View File

@@ -77,7 +77,6 @@ namespace AppScaffolding
public static void RunPostConfigMigrations(Configuration config)
{
AudibleApiStorage.EnsureAccountsSettingsFileExists();
PopulateMissingConfigValues(config);
//
// migrations go below here
@@ -86,110 +85,6 @@ namespace AppScaffolding
Migrations.migrate_to_v6_6_9(config);
}
public static void PopulateMissingConfigValues(Configuration config)
{
config.InProgress ??= Configuration.WinTemp;
if (!config.Exists(nameof(config.UseCoverAsFolderIcon)))
config.UseCoverAsFolderIcon = false;
if (!config.Exists(nameof(config.BetaOptIn)))
config.BetaOptIn = false;
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!config.Exists(nameof(config.CreateCueSheet)))
config.CreateCueSheet = true;
if (!config.Exists(nameof(config.RetainAaxFile)))
config.RetainAaxFile = false;
if (!config.Exists(nameof(config.SplitFilesByChapter)))
config.SplitFilesByChapter = false;
if (!config.Exists(nameof(config.StripUnabridged)))
config.StripUnabridged = false;
if (!config.Exists(nameof(config.StripAudibleBrandAudio)))
config.StripAudibleBrandAudio = false;
if (!config.Exists(nameof(config.DecryptToLossy)))
config.DecryptToLossy = false;
if (!config.Exists(nameof(config.LameTargetBitrate)))
config.LameTargetBitrate = false;
if (!config.Exists(nameof(config.LameDownsampleMono)))
config.LameDownsampleMono = true;
if (!config.Exists(nameof(config.LameBitrate)))
config.LameBitrate = 64;
if (!config.Exists(nameof(config.LameConstantBitrate)))
config.LameConstantBitrate = false;
if (!config.Exists(nameof(config.LameMatchSourceBR)))
config.LameMatchSourceBR = true;
if (!config.Exists(nameof(config.LameVBRQuality)))
config.LameVBRQuality = 2;
if (!config.Exists(nameof(config.BadBook)))
config.BadBook = Configuration.BadBookAction.Ask;
if (!config.Exists(nameof(config.ShowImportedStats)))
config.ShowImportedStats = true;
if (!config.Exists(nameof(config.ImportEpisodes)))
config.ImportEpisodes = true;
if (!config.Exists(nameof(config.DownloadEpisodes)))
config.DownloadEpisodes = true;
if (!config.Exists(nameof(config.ReplacementCharacters)))
config.ReplacementCharacters = FileManager.ReplacementCharacters.Default;
if (!config.Exists(nameof(config.FolderTemplate)))
config.FolderTemplate = Templates.Folder.DefaultTemplate;
if (!config.Exists(nameof(config.FileTemplate)))
config.FileTemplate = Templates.File.DefaultTemplate;
if (!config.Exists(nameof(config.ChapterFileTemplate)))
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
if (!config.Exists(nameof(config.ChapterTitleTemplate)))
config.ChapterTitleTemplate = Templates.ChapterTitle.DefaultTemplate;
if (!config.Exists(nameof(config.AutoScan)))
config.AutoScan = true;
if (!config.Exists(nameof(config.GridColumnsVisibilities)))
config.GridColumnsVisibilities = new Dictionary<string, bool>();
if (!config.Exists(nameof(config.GridColumnsDisplayIndices)))
config.GridColumnsDisplayIndices = new Dictionary<string, int>();
if (!config.Exists(nameof(config.GridColumnsWidths)))
config.GridColumnsWidths = new Dictionary<string, int>();
if (!config.Exists(nameof(config.DownloadCoverArt)))
config.DownloadCoverArt = true;
if (!config.Exists(nameof(config.DownloadClipsBookmarks)))
config.DownloadClipsBookmarks = false;
if (!config.Exists(nameof(config.ClipsBookmarksFileFormat)))
config.ClipsBookmarksFileFormat = Configuration.ClipBookmarkFormat.CSV;
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
config.AutoDownloadEpisodes = false;
if (!config.Exists(nameof(config.DownloadSpeedLimit)))
config.DownloadSpeedLimit = 0;
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Configuration config)
{

View File

@@ -103,7 +103,10 @@ namespace ApplicationServices
[Name("Audio Format")]
public string AudioFormat { get; set; }
}
[Name("Language")]
public string Language { get; set; }
}
public static class LibToDtos
{
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
@@ -136,7 +139,8 @@ namespace ApplicationServices
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
AudioFormat = a.Book.AudioFormat.ToString()
AudioFormat = a.Book.AudioFormat.ToString(),
Language = a.Book.Language
}).ToList();
}
public static class LibraryExporter
@@ -207,8 +211,9 @@ namespace ApplicationServices
nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat)
};
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language)
};
var col = 0;
foreach (var c in columns)
{
@@ -273,9 +278,10 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language);
rowIndex++;
rowIndex++;
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="7.3.1.1" />
<PackageReference Include="AudibleApi" Version="7.3.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,14 +10,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.1">
<PackageReference Include="Dinah.Core" Version="7.2.1.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.1">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -50,6 +50,7 @@ namespace DataLayer
// book details
public bool IsAbridged { get; private set; }
public DateTime? DatePublished { get; private set; }
public string Language { get; private set; }
// non-null. use "empty pattern"
internal int CategoryId { get; private set; }
@@ -215,11 +216,12 @@ namespace DataLayer
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished)
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language)
{
// don't overwrite with default values
IsAbridged |= isAbridged;
DatePublished = datePublished ?? DatePublished;
Language = language?.FirstCharToUpper() ?? Language;
}
public void UpdateCategory(Category category, DbContext context = null)

View File

@@ -93,10 +93,12 @@ namespace DataLayer
var starString = new string(STAR, fullStars);
if (score - fullStars >= 0.25f)
starString += HALF;
if (score - fullStars >= 0.75f)
starString += STAR;
else if (score - fullStars >= 0.25f)
starString += HALF;
return starString;
return starString;
}
}
}

View File

@@ -0,0 +1,404 @@
// <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("20230201162454_AddBookLanguage")]
partial class AddBookLanguage
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
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<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>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
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.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
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<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("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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<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("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
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.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
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("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddBookLanguage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
table: "Books",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
table: "Books");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
modelBuilder.Entity("DataLayer.Book", b =>
{
@@ -41,6 +41,9 @@ namespace DataLayer.Migrations
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");

View File

@@ -152,9 +152,9 @@ namespace DtoImporterService
book.ReplacePublisher(publisher);
}
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
if (item.PdfUrl is not null)
if (item.PdfUrl is not null)
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
return book;
@@ -174,7 +174,15 @@ namespace DtoImporterService
if (item.PictureLarge is not null)
book.PictureLarge = item.PictureLarge;
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
// 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);
book.UpdateProductRating(
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
(float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0),
(float)(item.Rating?.StoryDistribution?.AverageRating ?? 0));
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);

View File

@@ -25,13 +25,12 @@ namespace FileLiberator
if (seriesParent is not null)
{
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
}
}
}
return Templates.Folder.GetFilename(libraryBook.ToDto());
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
}
/// <summary>

View File

@@ -15,12 +15,11 @@ namespace FileLiberator
public class ConvertToMp3 : AudioDecodable
{
public override string Name => "Convert to Mp3";
private Mp4File m4bBook;
private long fileSize;
private Mp4Operation Mp4Operation;
private readonly AaxDecrypter.AverageSpeed averageSpeed = new();
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
public override Task CancelAsync() => m4bBook?.CancelAsync() ?? Task.CompletedTask;
public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask;
public static bool ValidateMp3(LibraryBook libraryBook)
{
@@ -43,10 +42,7 @@ namespace FileLiberator
var proposedMp3Path = Mp3FileName(m4bPath);
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
fileSize = m4bBook.InputStream.Length;
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
OnTitleDiscovered(m4bBook.AppleTags.Title);
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
@@ -64,49 +60,63 @@ namespace FileLiberator
config.LameMatchSourceBR);
using var mp3File = File.OpenWrite(Path.GetTempFileName());
var result = await m4bBook.ConvertToMp3Async(mp3File, lameConfig);
m4bBook.InputStream.Close();
mp3File.Close();
if (result == ConversionResult.Failed)
try
{
FileUtility.SaferDelete(mp3File.Name);
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
await Mp4Operation;
if (Mp4Operation.IsCanceled)
{
FileUtility.SaferDelete(mp3File.Name);
return new StatusHandler { "Cancelled" };
}
else
{
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3");
OnFileCreated(libraryBook, realMp3Path);
}
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "AAXClean error");
return new StatusHandler { "Conversion failed" };
}
else if (result == ConversionResult.Cancelled)
finally
{
FileUtility.SaferDelete(mp3File.Name);
return new StatusHandler { "Cancelled" };
}
if (Mp4Operation is not null)
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters);
OnFileCreated(libraryBook, realMp3Path);
m4bBook.InputStream.Close();
mp3File.Close();
}
}
return new StatusHandler();
}
finally
{
OnCompleted(libraryBook);
}
return new StatusHandler();
}
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = m4bBook.Duration;
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
var remainingTimeToProcess = (e.TotalDuration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
if (double.IsNormal(estTimeRemaining))
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / e.TotalDuration.TotalSeconds;
OnStreamingProgressChanged(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(fileSize * progressPercent),
TotalBytesToReceive = fileSize
BytesReceived = (long)e.ProcessPosition.TotalSeconds,
TotalBytesToReceive = (long)e.TotalDuration.TotalSeconds
});
}
}

View File

@@ -5,7 +5,6 @@ using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using ApplicationServices;
using AudibleApi;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
@@ -138,40 +137,27 @@ namespace FileLiberator
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
{
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
//I also assume that if DrmType != Adrm, the file will be an mp3.
//These assumptions may be wrong, and only time and bug reports will tell.
//If DrmType != Adrm the delivered file is an unencrypted mp3.
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
var outputFormat
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
? OutputFormat.Mp3
: OutputFormat.M4b;
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
OutputFormat.Mp3 : OutputFormat.M4b;
long chapterStartMs
= config.StripAudibleBrandAudio
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0;
long chapterStartMs = config.StripAudibleBrandAudio ?
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
var dlOptions = new DownloadOptions
(
libraryBook,
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
Resources.USER_AGENT
)
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
{
AudibleKey = contentLic?.Voucher?.Key,
AudibleIV = contentLic?.Voucher?.Iv,
OutputFormat = outputFormat,
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
RetainEncryptedFile = config.RetainAaxFile && encrypted,
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
CreateCueSheet = config.CreateCueSheet,
DownloadClipsBookmarks = config.DownloadClipsBookmarks,
DownloadSpeedBps = config.DownloadSpeedLimit,
LameConfig = GetLameOptions(config),
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
FixupFile = config.AllowLibationFixup
};
OutputFormat = outputFormat,
LameConfig = GetLameOptions(config),
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
};
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
@@ -276,8 +262,10 @@ namespace FileLiberator
foreach (var c in chapters)
{
if (c.Chapters is not null)
{
if (c.Chapters is null)
chaps.Add(c);
else
{
if (c.LengthMs < 10000)
{
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
@@ -295,8 +283,6 @@ namespace FileLiberator
chaps.AddRange(children);
c.Chapters = null;
}
else
chaps.Add(c);
}
return chaps;
}

View File

@@ -3,7 +3,6 @@ using AAXClean;
using Dinah.Core;
using DataLayer;
using LibationFileManager;
using FileManager;
using System.Threading.Tasks;
using System;
using System.IO;
@@ -17,34 +16,39 @@ namespace FileLiberator
public LibraryBook LibraryBook { get; }
public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; }
public string UserAgent { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
public bool TrimOutputToChapterLength { get; init; }
public bool RetainEncryptedFile { get; init; }
public bool StripUnabridged { get; init; }
public bool CreateCueSheet { get; init; }
public bool DownloadClipsBookmarks { get; init; }
public long DownloadSpeedBps { get; init; }
public TimeSpan RuntimeLength { get; init; }
public OutputFormat OutputFormat { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public bool FixupFile { get; init; }
public NAudio.Lame.LameConfig LameConfig { get; init; }
public bool Downsample { get; init; }
public bool MatchSourceBitrate { get; init; }
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
public string UserAgent => AudibleApi.Resources.USER_AGENT;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
public bool CreateCueSheet => config.CreateCueSheet;
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks;
public long DownloadSpeedBps => config.DownloadSpeedLimit;
public bool RetainEncryptedFile => config.RetainAaxFile;
public bool FixupFile => config.AllowLibationFixup;
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
public string GetMultipartFileName(MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
{
var baseDir = Path.GetDirectoryName(props.OutputFileName);
var extension = Path.GetExtension(props.OutputFileName);
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension);
}
public string GetMultipartTitleName(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
public string GetMultipartTitle(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
public async Task<string> SaveClipsAndBookmarks(string fileName)
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
{
if (DownloadClipsBookmarks)
{
var format = Configuration.Instance.ClipsBookmarksFileFormat;
var format = config.ClipsBookmarksFileFormat;
var formatExtension = format.ToString().ToLowerInvariant();
var filePath = Path.ChangeExtension(fileName, formatExtension);
@@ -69,20 +73,21 @@ namespace FileLiberator
return string.Empty;
}
private readonly Configuration config;
private readonly IDisposable cancellation;
public void Dispose() => cancellation?.Dispose();
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
{
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
// no null/empty check for key/iv. unencrypted files do not have them
LibraryBookDto = LibraryBook.ToDto();
cancellation =
Configuration.Instance
config
.ObservePropertyChanged<long>(
nameof(Configuration.DownloadSpeedLimit),
newVal => DownloadSpeedChanged?.Invoke(this, newVal));

View File

@@ -27,22 +27,26 @@ namespace FileLiberator
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
{
Account = libraryBook.Account,
DateAdded = libraryBook.DateAdded,
AudibleProductId = libraryBook.Book.AudibleProductId,
Title = libraryBook.Book.Title ?? "",
Locale = libraryBook.Book.Locale,
YearPublished = libraryBook.Book.DatePublished?.Year,
DatePublished = libraryBook.Book.DatePublished,
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order,
SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
IsPodcast = libraryBook.Book.IsEpisodeChild(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
Channels = libraryBook.Book.AudioFormat.Channels,
Language = libraryBook.Book.Language
};
}
}

View File

@@ -1 +0,0 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
<PackageReference Include="Dinah.Core" Version="7.2.1.1" />
<PackageReference Include="Polly" Version="7.2.3" />
</ItemGroup>

View File

@@ -1,119 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace FileManager
{
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
public class FileNamingTemplate : NamingTemplate
{
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public FileNamingTemplate(string template) : base(template) { }
/// <summary>Generate a valid path for this file or directory</summary>
public LongPath GetFilePath(ReplacementCharacters replacements, bool returnFirstExisting = false)
{
string fileName =
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
FileUtility.RemoveLastCharacter(Template) :
Template;
List<string> pathParts = new();
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, replacements));
while (!string.IsNullOrEmpty(fileName))
{
var file = Path.GetFileName(fileName);
if (Path.IsPathRooted(Template) && file == string.Empty)
{
pathParts.Add(fileName);
break;
}
else
{
pathParts.Add(file);
fileName = Path.GetDirectoryName(fileName);
}
}
pathParts.Reverse();
var fileNamePart = pathParts[^1];
pathParts.Remove(fileNamePart);
var fileExtension = Path.GetExtension(fileNamePart);
fileNamePart = fileNamePart[..^fileExtension.Length];
LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray());
//If file already exists, GetValidFilename will append " (n)" to the filename.
//This could cause the filename length to exceed MaxFilenameLength, so reduce
//allowable filename length by 5 chars, allowing for up to 99 duplicates.
return FileUtility
.GetValidFilename(
Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - fileExtension.Length - 5)) + fileExtension,
replacements,
returnFirstExisting
);
}
private static string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
{
List<StringBuilder> filenameParts = new();
//Build the filename in parts, replacing replacement parameters with
//their values, and storing the parts in a list.
while (!string.IsNullOrEmpty(filename))
{
int openIndex = filename.IndexOf('<');
int closeIndex = filename.IndexOf('>');
if (openIndex == 0 && closeIndex > 0)
{
var key = filename[..(closeIndex + 1)];
if (paramReplacements.ContainsKey(key))
filenameParts.Add(new StringBuilder(paramReplacements[key]));
else
filenameParts.Add(new StringBuilder(key));
filename = filename[(closeIndex + 1)..];
}
else if (openIndex > 0 && closeIndex > openIndex)
{
var other = filename[..openIndex];
filenameParts.Add(new StringBuilder(other));
filename = filename[openIndex..];
}
else
{
filenameParts.Add(new StringBuilder(filename));
filename = string.Empty;
}
}
//Remove 1 character from the end of the longest filename part until
//the total filename is less than max filename length
while (filenameParts.Sum(p => LongPath.GetFilesystemStringLength(p)) > maxFilenameLength)
{
int maxLength = filenameParts.Max(p => p.Length);
var maxEntry = filenameParts.First(p => p.Length == maxLength);
maxEntry.Remove(maxLength - 1, 1);
}
return string.Join("", filenameParts);
}
private static string formatValue(object value, ReplacementCharacters replacements)
{
if (value is null)
return "";
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
// Esp important for file templates.
return replacements.ReplaceFilenameChars(value.ToString());
}
}
}

View File

@@ -49,9 +49,11 @@ namespace FileManager
/// <br/>- ensure uniqueness
/// <br/>- enforce max file length
/// </summary>
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, bool returnFirstExisting = false)
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string fileExtension, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
fileExtension = GetStandardizedExtension(fileExtension);
// remove invalid chars
path = GetSafePath(path, replacements);
@@ -60,21 +62,20 @@ namespace FileManager
var dir = Path.GetDirectoryName(path);
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var extension = Path.GetExtension(path);
var fileName = Path.GetFileName(path);
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
var filenameWithoutExtension = extIndex >= 0 ? fileName.Remove(extIndex, fileExtension.Length) : fileName;
var fileStem
= Path.Combine(dir, filenameWithoutExtension.TruncateFilename(LongPath.MaxFilenameLength - fileExtension.Length))
.TruncateFilename(LongPath.MaxPathLength - fileExtension.Length);
var filename = Path.GetFileNameWithoutExtension(path).TruncateFilename(LongPath.MaxFilenameLength - extension.Length);
var fileStem = Path.Combine(dir, filename);
var fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - extension.Length) + extension;
fullfilename = removeInvalidWhitespace(fullfilename);
var fullfilename = removeInvalidWhitespace(fileStem) + fileExtension;
var i = 0;
while (File.Exists(fullfilename) && !returnFirstExisting)
{
var increm = $" ({++i})";
fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - extension.Length) + increm + extension;
fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - fileExtension.Length) + increm + fileExtension;
}
return fullfilename;
@@ -150,9 +151,10 @@ namespace FileManager
/// <br/>- Perform <see cref="SaferMove"/>
/// <br/>- Return valid path
/// </summary>
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements)
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements, string extension = null)
{
destination = GetValidFilename(destination, replacements);
extension = extension ?? Path.GetExtension(source);
destination = GetValidFilename(destination, replacements, extension);
SaferMove(source, destination);
return destination;
}

View File

@@ -40,6 +40,7 @@ namespace FileManager
}
}
[JsonConstructor]
private LongPath(string path)
{
if (IsWindows && path.Length > MaxPathLength)
@@ -55,17 +56,16 @@ namespace FileManager
//don't care about encoding, so how unicode characters are encoded is
///a choice made by the linux kernel. As best as I can tell, pretty
//much everyone uses UTF-8.
public static int GetFilesystemStringLength(StringBuilder filename)
=> LongPath.IsWindows ?
filename.Length
: Encoding.UTF8.GetByteCount(filename.ToString());
public static int GetFilesystemStringLength(string filename)
=> IsWindows ? filename.Length
: Encoding.UTF8.GetByteCount(filename);
public static implicit operator LongPath(string path)
{
if (!IsWindows) return new LongPath(path);
if (path is null) return null;
if (!IsWindows) return new LongPath(path);
//File I/O functions in the Windows API convert "/" to "\" as part of converting
//the name to an NT-style name, except when using the "\\?\" prefix
path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);

View File

@@ -1,20 +0,0 @@
using System;
using System.Linq;
namespace FileManager
{
public class MetadataNamingTemplate : NamingTemplate
{
public MetadataNamingTemplate(string template) : base(template) { }
public string GetTagContents()
{
var tagValue = Template;
foreach (var r in ParameterReplacements)
tagValue = tagValue.Replace($"<{formatKey(r.Key)}>", r.Value?.ToString() ?? "");
return tagValue;
}
}
}

View File

@@ -1,28 +0,0 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
namespace FileManager
{
public class NamingTemplate
{
/// <summary>Proposed full name. May contain optional html-styled template tags. Eg: &lt;name&gt;</summary>
public string Template { get; }
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public NamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /&lt;name&gt;/ => /Bill Gates/</summary>
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
/// <summary>Convenience method</summary>
public void AddParameterReplacement(string key, object value)
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
=> ParameterReplacements.Add(key, value);
protected static string formatKey(string key)
=> key
.Replace("<", "")
.Replace(">", "");
}
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
namespace FileManager.NamingTemplate;
internal interface IClosingPropertyTag : IPropertyTag
{
/// <summary>The <see cref="Regex"/> used to match the closing <see cref="IPropertyTag.TemplateTag"/> in template strings.</summary>
public Regex NameCloseMatcher { get; }
/// <summary>
/// Determine if the template string starts with <see cref="IPropertyTag.TemplateTag"/>'s closing tag signature,
/// and if it does output the matching tag's <see cref="ITemplateTag"/>
/// </summary>
/// <param name="templateString">Template string</param>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag);
}
public class ConditionalTagCollection<TClass> : TagCollection
{
public ConditionalTagCollection(bool caseSensative = true) :base(typeof(TClass), caseSensative) { }
/// <summary>
/// Register a conditional tag.
/// </summary>
/// <param name="propertyGetter">A Func to get the condition's <see cref="bool"/> value from <see cref="TClass"/></param>
public void Add(ITemplateTag templateTag, Func<TClass, bool> propertyGetter)
{
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
AddPropertyTag(new ConditionalTag(templateTag, Options, expr));
}
private class ConditionalTag : TagBase, IClosingPropertyTag
{
public Regex NameCloseMatcher { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
: base(templateTag, conditionExpression)
{
NameMatcher = new Regex($"^<(!)?{templateTag.TagName}->", options);
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
}
public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag)
{
var match = NameCloseMatcher.Match(templateString);
if (match.Success)
{
exactName = match.Value;
propertyTag = this;
return true;
}
exactName = null;
propertyTag = null;
return false;
}
protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ValueExpression) : ValueExpression;
}
}

View File

@@ -0,0 +1,6 @@
namespace FileManager.NamingTemplate;
public interface ITemplateTag
{
string TagName { get; }
}

View File

@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
namespace FileManager.NamingTemplate;
public class NamingTemplate
{
public string TemplateText { get; private set; }
public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse;
public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
public IEnumerable<string> Warnings => errors.Concat(warnings);
public IEnumerable<string> Errors => errors;
private Delegate templateToString;
private readonly List<string> warnings = new();
private readonly List<string> errors = new();
private readonly IEnumerable<TagCollection> TagCollections;
private readonly List<ITemplateTag> _tagsInUse = new();
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
public const string WARNING_EMPTY = "Template is empty.";
public const string WARNING_WHITE_SPACE = "Template is white space.";
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
/// <summary>
/// Invoke the <see cref="NamingTemplate"/>
/// </summary>
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
public TemplatePart Evaluate(params object[] propertyClasses)
{
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
// First parameter is "this", so ignore it.
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
object[] args = delegateArgTypes.Join(propertyClasses, o => o.ParameterType, i => i.GetType(), (_, i) => i).ToArray();
if (args.Length != delegateArgTypes.Count())
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
return ((TemplatePart)templateToString.DynamicInvoke(args)).FirstPart;
}
/// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary>
/// <param name="template">The template string to parse</param>
/// <param name="tagCollections">A collection of <see cref="TagCollection"/> with
/// properties registered to match to the <paramref name="template"/></param>
public static NamingTemplate Parse(string template, IEnumerable<TagCollection> tagCollections)
{
var namingTemplate = new NamingTemplate(tagCollections);
try
{
BinaryNode intermediate = namingTemplate.IntermediateParse(template);
Expression evalTree = GetExpressionTree(intermediate);
namingTemplate.templateToString = Expression.Lambda(evalTree, tagCollections.Select(tc => tc.Parameter)).Compile();
}
catch(Exception ex)
{
namingTemplate.errors.Add(ex.Message);
}
return namingTemplate;
}
private NamingTemplate(IEnumerable<TagCollection> properties)
{
TagCollections = properties;
}
/// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary>
private static Expression GetExpressionTree(BinaryNode node)
{
if (node is null) return TemplatePart.Blank;
else if (node.IsValue) return node.Expression;
else if (node.IsConditional) return Expression.Condition(node.Expression, concatExpression(node), TemplatePart.Blank);
else return concatExpression(node);
static Expression concatExpression(BinaryNode node)
=> TemplatePart.CreateConcatenation(GetExpressionTree(node.LeftChild), GetExpressionTree(node.RightChild));
}
/// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary>
private BinaryNode IntermediateParse(string templateString)
{
if (templateString is null)
throw new NullReferenceException(ERROR_NULL_IS_INVALID);
else if (string.IsNullOrEmpty(templateString))
warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(templateString))
warnings.Add(WARNING_WHITE_SPACE);
TemplateText = templateString;
BinaryNode topNode = BinaryNode.CreateRoot();
BinaryNode currentNode = topNode;
List<char> literalChars = new();
while (templateString.Length > 0)
{
if (StartsWith(templateString, out string exactPropertyName, out var propertyTag, out var valueExpression))
{
checkAndAddLiterals();
if (propertyTag is IClosingPropertyTag)
currentNode = currentNode.AddNewNode(BinaryNode.CreateConditional(propertyTag.TemplateTag, valueExpression));
else
{
currentNode = currentNode.AddNewNode(BinaryNode.CreateValue(propertyTag.TemplateTag, valueExpression));
_tagsInUse.Add(propertyTag.TemplateTag);
}
templateString = templateString[exactPropertyName.Length..];
}
else if (StartsWithClosing(templateString, out exactPropertyName, out var closingPropertyTag))
{
checkAndAddLiterals();
BinaryNode lastParenth = currentNode;
while (lastParenth?.IsConditional is false)
lastParenth = lastParenth.Parent;
if (lastParenth?.Parent is null)
{
warnings.Add($"Missing <{closingPropertyTag.TemplateTag.TagName}-> open conditional.");
break;
}
else if (lastParenth.Name != closingPropertyTag.TemplateTag.TagName)
{
warnings.Add($"Missing <-{lastParenth.Name}> closing conditional.");
break;
}
currentNode = lastParenth.Parent;
templateString = templateString[exactPropertyName.Length..];
}
else
{
//templateString does not start with a tag, so the first
//character is a literal and not part of a tag expression.
literalChars.Add(templateString[0]);
templateString = templateString[1..];
}
}
checkAndAddLiterals();
//Check for any conditionals that haven't been closed
while (currentNode is not null)
{
if (currentNode.IsConditional)
warnings.Add($"Missing <-{currentNode.Name}> closing conditional.");
currentNode = currentNode.Parent;
}
if (!_tagsInUse.Any())
warnings.Add(WARNING_NO_TAGS);
return topNode;
void checkAndAddLiterals()
{
if (literalChars.Count != 0)
{
currentNode = currentNode.AddNewNode(BinaryNode.CreateValue(string.Concat(literalChars)));
literalChars.Clear();
}
}
}
private bool StartsWith(string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression)
{
foreach (var pc in TagCollections)
{
if (pc.StartsWith(template, out exactName, out propertyTag, out valueExpression))
return true;
}
exactName = null;
valueExpression = null;
propertyTag = null;
return false;
}
private bool StartsWithClosing(string template, out string exactName, out IClosingPropertyTag closingPropertyTag)
{
foreach (var pc in TagCollections)
{
if (pc.StartsWithClosing(template, out exactName, out closingPropertyTag))
return true;
}
exactName = null;
closingPropertyTag = null;
return false;
}
private class BinaryNode
{
public string Name { get; }
public BinaryNode Parent { get; private set; }
public BinaryNode RightChild { get; private set; }
public BinaryNode LeftChild { get; private set; }
public Expression Expression { get; private init; }
public bool IsConditional { get; private init; } = false;
public bool IsValue { get; private init; } = false;
public static BinaryNode CreateRoot() => new("Root");
public static BinaryNode CreateValue(string literal) => new("Literal")
{
IsValue = true,
Expression = TemplatePart.CreateLiteral(literal)
};
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
{
IsValue = true,
Expression = TemplatePart.CreateProperty(templateTag, property)
};
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
{
IsConditional = true,
Expression = property
};
private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right)
{
var newNode = new BinaryNode("Concatenation")
{
LeftChild = left,
RightChild = right
};
newNode.LeftChild.Parent = newNode;
newNode.RightChild.Parent = newNode;
return newNode;
}
private BinaryNode(string name) => Name = name;
public override string ToString() => Name;
public BinaryNode AddNewNode(BinaryNode newNode)
{
BinaryNode currentNode = this;
if (LeftChild is null)
{
newNode.Parent = currentNode;
LeftChild = newNode;
}
else if (RightChild is null)
{
newNode.Parent = currentNode;
RightChild = newNode;
}
else
{
RightChild = CreateConcatenation(RightChild, newNode);
RightChild.Parent = currentNode;
currentNode = RightChild;
}
return newNode.IsConditional ? newNode : currentNode;
}
}
}

View File

@@ -0,0 +1,156 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
namespace FileManager.NamingTemplate;
public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString);
public class PropertyTagCollection<TClass> : TagCollection
{
private readonly Dictionary<Type, MulticastDelegate> defaultFormatters = new();
public PropertyTagCollection(bool caseSensative = true, params MulticastDelegate[] defaultFormatters) : base(typeof(TClass), caseSensative)
{
foreach (var formatter in defaultFormatters)
{
var parameters = formatter.Method.GetParameters();
if (formatter.Method.ReturnType != typeof(string)
|| parameters.Length != 3
|| parameters[0].ParameterType != typeof(ITemplateTag)
|| parameters[2].ParameterType != typeof(string))
throw new ArgumentException($"{nameof(defaultFormatters)} must have a signature of [{nameof(String)} PropertyFormatter<T>({nameof(ITemplateTag)}, T, {nameof(String)})]");
this.defaultFormatters[parameters[1].ParameterType] = formatter;
}
}
/// <summary>
/// Register a nullable value type <typeparamref name="TClass"/> property.
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty> formatter = null)
where TProperty : struct
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary>
/// Register a nullable value type <typeparamref name="TClass"/> property.
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param>
/// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returnes a string</param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, Func<TProperty, string> toString)
where TProperty : struct
=> RegisterWithToString(templateTag, propertyGetter, toString);
/// <summary>
/// Register a <typeparamref name="TClass"/> property
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty> formatter = null)
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary>
/// Register a <typeparamref name="TClass"/> property.
/// </summary>
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
/// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param>
/// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returnes a string</param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, Func<TProperty, string> toString)
=> RegisterWithToString(templateTag, propertyGetter, toString);
private void RegisterWithFormatter<TProperty, TPropertyValue>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue> formatter)
{
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
if ((formatter ??= GetDefaultFormatter<TPropertyValue>()) is null)
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc));
else
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
}
private void RegisterWithToString<TProperty, TPropertyValue>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, Func<TPropertyValue, string> toString)
{
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, toString ?? ToStringFunc));
}
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
private PropertyFormatter<T> GetDefaultFormatter<T>()
{
try
{
var del = defaultFormatters.FirstOrDefault(kvp => kvp.Key == typeof(T)).Value;
return del is null ? null : Delegate.CreateDelegate(typeof(PropertyFormatter<T>), del.Target, del.Method) as PropertyFormatter<T>;
}
catch { return null; }
}
private class PropertyTag<TPropertyValue> : TagBase
{
private Func<Expression, string, Expression> CreateToStringExpression { get; }
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter)
: base(templateTag, propertyGetter)
{
NameMatcher = new Regex(@$"^<{templateTag.TagName.Replace(" ", "\\s*?")}\s*?(?:\[([^\[\]]*?)\]\s*?)?>", options);
CreateToStringExpression = (expVal, format) =>
Expression.Call(
formatter.Target is null ? null : Expression.Constant(formatter.Target),
formatter.Method,
Expression.Constant(templateTag),
expVal,
Expression.Constant(format));
}
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, Func<TPropertyValue, string> toString)
: base(templateTag, propertyGetter)
{
NameMatcher = new Regex(@$"^<{templateTag.TagName.Replace(" ", "\\s*?")}>", options);
CreateToStringExpression = (expVal, _) =>
Expression.Call(
toString.Target is null ? null : Expression.Constant(toString.Target),
toString.Method,
expVal);
}
protected override Expression GetTagExpression(string exactName, string formatString)
{
Expression toStringExpression
= !ReturnType.IsValueType
? Expression.Condition(
Expression.Equal(ValueExpression, Expression.Constant(null)),
Expression.Constant(""),
CreateToStringExpression(ValueExpression, formatString))
: Nullable.GetUnderlyingType(ReturnType) is null
? CreateToStringExpression(ValueExpression, formatString)
: Expression.Condition(
Expression.PropertyOrField(ValueExpression, "HasValue"),
CreateToStringExpression(Expression.PropertyOrField(ValueExpression, "Value"), formatString),
Expression.Constant(""));
return Expression.TryCatch(toStringExpression, Expression.Catch(typeof(Exception), Expression.Constant(exactName)));
}
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
namespace FileManager.NamingTemplate;
internal interface IPropertyTag
{
/// <summary>The tag that will be matched in a tag string</summary>
ITemplateTag TemplateTag { get; }
/// <summary><see cref="TemplateTag"/>'s <see cref="Type"/></summary>
Type ReturnType { get; }
/// <summary>The <see cref="Regex"/> used to match <see cref="TemplateTag"/> in template strings.</summary>
Regex NameMatcher { get; }
/// <summary>
/// Determine if the template string starts with <see cref="TemplateTag"/>, and if it does parse the tag to an <see cref="Expression"/>
/// </summary>
/// <param name="templateString">Template string</param>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWith(string templateString, out string exactName, out Expression propertyValue);
}
internal abstract class TagBase : IPropertyTag
{
public ITemplateTag TemplateTag { get; }
public Regex NameMatcher { get; protected init; }
public Type ReturnType => ValueExpression.Type;
protected Expression ValueExpression { get; }
protected TagBase(ITemplateTag templateTag, Expression propertyExpression)
{
TemplateTag = templateTag;
ValueExpression = propertyExpression;
}
/// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary>
/// <param name="exactName">The exact string that was matched to <see cref="ITemplateTag"/></param>
/// <param name="formatter">The optional format string in the match inside the square brackets</param>
protected abstract Expression GetTagExpression(string exactName, string formatter);
public bool StartsWith(string templateString, out string exactName, out Expression propertyValue)
{
var match = NameMatcher.Match(templateString);
if (match.Success)
{
exactName = match.Value;
propertyValue = GetTagExpression(exactName, match.Groups.Count == 2 ? match.Groups[1].Value.Trim() : "");
return true;
}
exactName = null;
propertyValue = null;
return false;
}
public override string ToString()
{
return $"[Name = {TemplateTag.TagName}, Type = {ReturnType.Name}]";
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
namespace FileManager.NamingTemplate;
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
public abstract class TagCollection : IEnumerable<ITemplateTag>
{
/// <summary>The <see cref="ITemplateTag"/>s registered with this <see cref="TagCollection"/> </summary>
public IEnumerator<ITemplateTag> GetEnumerator() => PropertyTags.Select(p => p.TemplateTag).GetEnumerator();
/// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagCollection"/>'s TClass type.</summary>
internal ParameterExpression Parameter { get; }
protected RegexOptions Options { get; } = RegexOptions.Compiled;
private List<IPropertyTag> PropertyTags { get; } = new();
protected TagCollection(Type classType, bool caseSensative = true)
{
Parameter = Expression.Parameter(classType, classType.Name);
Options |= caseSensative ? RegexOptions.None : RegexOptions.IgnoreCase;
}
/// <summary>
/// Determine if the template string starts with any of the <see cref="TemplateTags"/>s' <see cref="ITemplateTag"/> signatures,
/// and if it does parse the tag to an <see cref="Expression"/>
/// </summary>
/// <param name="templateString">Template string</param>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
internal bool StartsWith(string templateString, out string exactName, out IPropertyTag propertyTag, out Expression propertyValue)
{
foreach (var p in PropertyTags)
{
if (p.StartsWith(templateString, out exactName, out propertyValue))
{
propertyTag = p;
return true;
}
}
propertyValue = null;
propertyTag = null;
exactName = null;
return false;
}
/// <summary>
/// Determine if the template string starts with <see cref="IPropertyTag.TemplateTag"/>'s closing tag signature,
/// and if it does output the matching tag's <see cref="ITemplateTag"/>
/// </summary>
/// <param name="templateString">Template string</param>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
internal bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag closingPropertyTag)
{
foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>())
{
if (cg.StartsWithClosing(templateString, out exactName, out closingPropertyTag))
return true;
}
closingPropertyTag = null;
exactName = null;
return false;
}
private protected void AddPropertyTag(IPropertyTag propertyTag)
{
if (!PropertyTags.Any(c => c.TemplateTag.TagName == propertyTag.TemplateTag.TagName))
PropertyTags.Add(propertyTag);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
namespace FileManager.NamingTemplate;
/// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary>
public class TemplatePart : IEnumerable<TemplatePart>
{
/// <summary>The <see cref="TemplatePart"/> name. If <see cref="TemplatePart"/> is
/// a registered property, this value is <see cref="ITemplateTag.TagName"/></summary>
public string TagName { get; }
/// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is
/// a registered property, otherwise <see cref="null"/> for string literals. </summary>
public ITemplateTag TemplateTag { get; }
/// <summary>The evaluated string.</summary>
public string Value { get; }
private TemplatePart previous;
private TemplatePart next;
private TemplatePart(string name, string value)
{
TagName = name;
Value = value;
}
private TemplatePart(ITemplateTag templateTag, string value)
{
TemplateTag = templateTag;
TagName = templateTag.TagName;
Value = value;
}
internal static Expression Blank
=> CreateExpression("Blank", Expression.Constant(""));
internal static Expression CreateLiteral(string constant)
=> CreateExpression("Literal", Expression.Constant(constant));
internal static Expression CreateProperty(ITemplateTag templateTag, Expression property)
=> Expression.New(tagTemplateConstructorInfo, Expression.Constant(templateTag), property);
internal static Expression CreateConcatenation(Expression left, Expression right)
{
if (left.Type != typeof(TemplatePart) || right.Type != typeof(TemplatePart))
throw new InvalidOperationException($"Cannot concatenate expressions of types {left.Type.Name} and {right.Type.Name}");
return Expression.Add(left, right, addMethodInfo);
}
private static Expression CreateExpression(string name, Expression value)
=> Expression.New(constructorInfo, Expression.Constant(name), value);
private static readonly ConstructorInfo constructorInfo
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(string), typeof(string) });
private static readonly ConstructorInfo tagTemplateConstructorInfo
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(ITemplateTag), typeof(string) });
private static readonly MethodInfo addMethodInfo
= typeof(TemplatePart).GetMethod(nameof(Concatenate), BindingFlags.NonPublic | BindingFlags.Static, new Type[] { typeof(TemplatePart), typeof(TemplatePart) });
public IEnumerator<TemplatePart> GetEnumerator()
{
var firstPart = FirstPart;
do
{
if (firstPart.TemplateTag is not null || firstPart.TagName is not "Blank")
yield return firstPart;
firstPart = firstPart.next;
}
while (firstPart is not null);
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
internal TemplatePart FirstPart
{
get
{
var part = this;
while (part.previous is not null)
part = part.previous;
return part;
}
}
private TemplatePart LastPart
{
get
{
var part = this;
while (part.next is not null)
part = part.next;
return part;
}
}
private static TemplatePart Concatenate(TemplatePart left, TemplatePart right)
{
var last = left.LastPart;
last.next = right;
right.previous = last;
return left.FirstPart;
}
}

View File

@@ -33,29 +33,34 @@ namespace FileManager
createNewFile();
}
public string GetString(string propertyName)
public string GetString(string propertyName, string defaultValue = null)
{
if (!stringCache.ContainsKey(propertyName))
{
var jObject = readFile();
if (!jObject.ContainsKey(propertyName))
return null;
stringCache[propertyName] = jObject[propertyName].Value<string>();
if (jObject.ContainsKey(propertyName))
stringCache[propertyName] = jObject[propertyName].Value<string>();
else
stringCache[propertyName] = defaultValue;
}
return stringCache[propertyName];
}
public T GetNonString<T>(string propertyName)
public T GetNonString<T>(string propertyName, T defaultValue = default)
{
var obj = GetObject(propertyName);
if (obj is null) return default;
if (obj is null)
{
objectCache[propertyName] = defaultValue;
return defaultValue;
}
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
if (obj is JObject jObject) return jObject.ToObject<T>();
if (obj is JValue jValue)
{
if (jValue.Type == JTokenType.String && typeof(T).IsAssignableTo(typeof(Enum)))
if (typeof(T).IsAssignableTo(typeof(Enum)))
{
return
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)

View File

@@ -1 +0,0 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")]

View File

@@ -93,6 +93,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MacOSConfigApp", "LoadByOS\
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsConfigApp", "LoadByOS\WindowsConfigApp\WindowsConfigApp.csproj", "{5F65A509-26E3-4B02-B403-EEB6F0EF391F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationUiBase", "LibationUiBase\LibationUiBase.csproj", "{E90C4651-AF11-41B4-A839-10082D0391F9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hangover", "Hangover", "{FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI", "{53758A35-1C7E-4702-9B96-433ABA457B37}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -207,6 +215,10 @@ Global
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Release|Any CPU.Build.0 = Release|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -218,21 +230,21 @@ Global
{393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {53758A35-1C7E-4702-9B96-433ABA457B37}
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{428163C3-D558-4914-B570-A92069521877} = {47E27674-595D-4F7A-8CFB-127E768E1D1E}
{595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{788294BE-0D8E-40D4-9CEE-67896FBB52CE} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{F2E04270-4551-41C4-99FF-E7125BED708C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{EB781571-8548-477E-82AD-FB9FAB548D2F} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{F612D06F-3134-4B9B-95CD-EB3FC798AE60} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{8A7B01D3-9830-44FD-91A1-D8D010996BEB} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{5C7005BA-7D83-4E99-8073-D970943A7D61} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}
{F612D06F-3134-4B9B-95CD-EB3FC798AE60} = {53758A35-1C7E-4702-9B96-433ABA457B37}
{8A7B01D3-9830-44FD-91A1-D8D010996BEB} = {FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}
{5C7005BA-7D83-4E99-8073-D970943A7D61} = {FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}
{59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F} = {185AC9FF-381E-4AA1-B649-9771F4917214}
{CC275937-DFE4-4383-B1BF-1D5D42B70C98} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
{47325742-5B38-48E7-95FB-CD94E6E07332} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
@@ -241,6 +253,10 @@ Global
{357DF797-4EC2-4DBD-A4BD-D045277F2666} = {9B906374-1142-4D69-86FF-B384806CA5FE}
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C} = {9B906374-1142-4D69-86FF-B384806CA5FE}
{5F65A509-26E3-4B02-B403-EEB6F0EF391F} = {9B906374-1142-4D69-86FF-B384806CA5FE}
{E90C4651-AF11-41B4-A839-10082D0391F9} = {53758A35-1C7E-4702-9B96-433ABA457B37}
{FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{53758A35-1C7E-4702-9B96-433ABA457B37} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{47E27674-595D-4F7A-8CFB-127E768E1D1E} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}

View File

@@ -131,8 +131,6 @@ namespace LibationAvalonia
{
config.Books ??= Path.Combine(Configuration.UserProfile, "Books");
AppScaffolding.LibationScaffolding.PopulateMissingConfigValues(config);
var settingsDialog = new SettingsDialog();
desktop.MainWindow = settingsDialog;
settingsDialog.RestoreSizeAndLocation(Configuration.Instance);

View File

@@ -2,11 +2,24 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using DataLayer;
using ReactiveUI;
using System;
namespace LibationAvalonia.Controls
{
public class StarStringConverter : Avalonia.Data.Converters.IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
=> value is Rating rating ? rating.ToStarString() : string.Empty;
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
=> throw new NotImplementedException();
}
public class DataGridMyRatingColumn : DataGridBoundColumn
{
[Avalonia.Data.AssignBinding]
public Avalonia.Data.IBinding BackgroundBinding { get; set; }
private static Rating DefaultRating => new Rating(0, 0, 0);
public DataGridMyRatingColumn()
{
@@ -21,13 +34,19 @@ namespace LibationAvalonia.Controls
IsEditingMode = false
};
ToolTip.SetTip(myRatingElement, "Click to change ratings");
cell?.AttachContextMenu();
if (!IsReadOnly)
ToolTip.SetTip(myRatingElement, "Click to change ratings");
if (Binding != null)
{
myRatingElement.Bind(BindingTarget, Binding);
}
if (BackgroundBinding != null)
{
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
}
return myRatingElement;
}
@@ -39,6 +58,10 @@ namespace LibationAvalonia.Controls
Name = "CellMyRatingEditor",
IsEditingMode = true
};
if (BackgroundBinding != null)
{
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
}
return myRatingElement;
}

View File

@@ -17,7 +17,7 @@
</Grid.Styles>
<TextBlock Grid.Column="0" Grid.Row="0" Name="tblockOverall" Text="Overall:" />
<TextBlock Grid.Column="0" Grid.Row="1" Name="tblockPerform" Text="Perform:" />
<TextBlock Grid.Column="0" Grid.Row="1" Name="tblockPerform" Text="Perform: " />
<TextBlock Grid.Column="0" Grid.Row="2" Name="tblockStory" Text="Story:" />
<Panel Background="Transparent" PointerExited="Panel_PointerExited" Grid.Column="1" Grid.Row="0">

View File

@@ -1,6 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using DataLayer;
using ReactiveUI;
using System;
using System.Linq;
namespace LibationAvalonia.Controls
@@ -9,6 +11,7 @@ namespace LibationAvalonia.Controls
{
private const string SOLID_STAR = "★";
private const string HOLLOW_STAR = "☆";
private const string HALF_STAR = "½";
public static readonly StyledProperty<Rating> RatingProperty =
AvaloniaProperty.Register<MyRatingCellEditor, Rating>(nameof(Rating));
@@ -19,39 +22,41 @@ namespace LibationAvalonia.Controls
public MyRatingCellEditor()
{
InitializeComponent();
var subscriber = this.ObservableForProperty(p => p.Rating).Subscribe(o => DisplayStarRating(o.Value ?? new Rating(0, 0, 0)));
Unloaded += (_, _) => subscriber.Dispose();
if (Design.IsDesignMode)
Rating = new Rating(5, 4, 3);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
private void DisplayStarRating(Rating rating)
{
if (change.Property.Name == nameof(Rating) && Rating is not null)
{
var blankValue = IsEditingMode ? HOLLOW_STAR : string.Empty;
var blankValue = IsEditingMode ? HOLLOW_STAR : string.Empty;
int rating = 0;
foreach (TextBlock star in panelOverall.Children)
star.Tag = star.Text = Rating.OverallRating > rating++ ? SOLID_STAR : blankValue;
string getStar(float score, int starIndex)
=> Math.Floor(score) > starIndex ? SOLID_STAR
: score < starIndex ? blankValue
: score - starIndex < 0.25 ? blankValue
: score - starIndex > 0.75 ? SOLID_STAR
: HALF_STAR;
rating = 0;
foreach (TextBlock star in panelPerform.Children)
star.Tag = star.Text = Rating.PerformanceRating > rating++ ? SOLID_STAR : blankValue;
int starIndex = 0;
foreach (TextBlock star in panelOverall.Children)
star.Tag = star.Text = getStar(rating.OverallRating, starIndex++);
rating = 0;
foreach (TextBlock star in panelStory.Children)
star.Tag = star.Text = Rating.StoryRating > rating++ ? SOLID_STAR : blankValue;
starIndex = 0;
foreach (TextBlock star in panelPerform.Children)
star.Tag = star.Text = getStar(rating.PerformanceRating, starIndex++);
SetVisible();
}
base.OnPropertyChanged(change);
}
starIndex = 0;
foreach (TextBlock star in panelStory.Children)
star.Tag = star.Text = getStar(rating.StoryRating, starIndex++);
private void SetVisible()
{
ratingsGrid.IsEnabled = IsEditingMode;
tblockOverall.IsVisible = panelOverall.IsVisible = IsEditingMode || Rating?.OverallRating > 0;
tblockPerform.IsVisible = panelPerform.IsVisible = IsEditingMode || Rating?.PerformanceRating > 0;
tblockStory.IsVisible = panelStory.IsVisible = IsEditingMode || Rating?.StoryRating > 0;
tblockOverall.IsVisible = panelOverall.IsVisible = IsEditingMode || rating.OverallRating > 0;
tblockPerform.IsVisible = panelPerform.IsVisible = IsEditingMode || rating.PerformanceRating > 0;
tblockStory.IsVisible = panelStory.IsVisible = IsEditingMode || rating.StoryRating > 0;
}
public void Panel_PointerExited(object sender, Avalonia.Input.PointerEventArgs e)

View File

@@ -120,7 +120,7 @@ namespace LibationAvalonia.Dialogs
SuggestedStartLocation = new BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)),
FileTypeFilter = new FilePickerFileType[]
{
new("JSON files (*.json)") { Patterns = new[] { "json" } },
new("JSON files (*.json)") { Patterns = new[] { "*.json" } },
}
};
@@ -280,7 +280,7 @@ namespace LibationAvalonia.Dialogs
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("JSON files (*.json)") { Patterns = new[] { "json" } },
new("JSON files (*.json)") { Patterns = new[] { "*.json" } },
}
};

View File

@@ -105,6 +105,8 @@ namespace LibationAvalonia.Dialogs
public BookDetailsDialogViewModel(LibraryBook libraryBook)
{
var Book = libraryBook.Book;
//init tags
Tags = libraryBook.Book.UserDefinedItem.Tags;
@@ -115,14 +117,15 @@ namespace LibationAvalonia.Dialogs
//init book details
DetailsText = @$"
Title: {libraryBook.Book.Title}
Author(s): {libraryBook.Book.AuthorNames()}
Narrator(s): {libraryBook.Book.NarratorNames()}
Length: {(libraryBook.Book.LengthInMinutes == 0 ? "" : $"{libraryBook.Book.LengthInMinutes / 60} hr {libraryBook.Book.LengthInMinutes % 60} min")}
Audio Bitrate: {libraryBook.Book.AudioFormat}
Category: {string.Join(" > ", libraryBook.Book.CategoriesNames())}
Title: {Book.Title}
Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(" > ", Book.CategoriesNames())}
Purchase Date: {libraryBook.DateAdded:d}
Audible ID: {libraryBook.Book.AudibleProductId}
Language: {Book.Language}
Audible ID: {Book.AudibleProductId}
".Trim();
var seriesNames = libraryBook.Book.SeriesNames();

View File

@@ -11,14 +11,12 @@ using ReactiveUI;
using Avalonia.Controls.Documents;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Markup.Xaml.Templates;
namespace LibationAvalonia.Dialogs
{
public partial class EditTemplateDialog : DialogWindow
{
// final value. post-validity check
public string TemplateText { get; private set; }
private EditTemplateViewModel _viewModel;
public EditTemplateDialog()
@@ -27,21 +25,22 @@ namespace LibationAvalonia.Dialogs
userEditTbox = this.FindControl<TextBox>(nameof(userEditTbox));
if (Design.IsDesignMode)
{
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
_viewModel = new(Configuration.Instance, Templates.File);
_viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
Title = $"Edit {_viewModel.Template.Name}";
_ = Configuration.Instance.LibationFiles;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel = new(Configuration.Instance, editor);
_viewModel.resetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.TemplateName}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(Templates template, string inputTemplateText) : this()
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
ArgumentValidator.EnsureNotNull(template, nameof(template));
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
_viewModel = new EditTemplateViewModel(Configuration.Instance, template);
_viewModel.resetTextBox(inputTemplateText);
Title = $"Edit {template.Name}";
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
_viewModel.resetTextBox(templateEditor.EditingTemplate.TemplateText);
Title = $"Edit {templateEditor.TemplateName}";
DataContext = _viewModel;
}
@@ -50,7 +49,9 @@ namespace LibationAvalonia.Dialogs
{
var dataGrid = sender as DataGrid;
var item = (dataGrid.SelectedItem as Tuple<string, string>).Item1.Replace("\x200C", "").Replace("...", "");
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
if (string.IsNullOrWhiteSpace(item)) return;
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
@@ -62,7 +63,6 @@ namespace LibationAvalonia.Dialogs
if (!await _viewModel.Validate())
return;
TemplateText = _viewModel.workingTemplateText;
await base.SaveAndCloseAsync();
}
@@ -70,27 +70,30 @@ namespace LibationAvalonia.Dialogs
=> await SaveAndCloseAsync();
public void ResetButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> _viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
=> _viewModel.resetTextBox(_viewModel.TemplateEditor.DefaultTemplate);
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
private readonly Configuration config;
public FontFamily FontFamily { get; } = FontManager.Current.DefaultFontFamilyName;
public InlineCollection Inlines { get; } = new();
public Templates Template { get; }
public EditTemplateViewModel(Configuration configuration, Templates templates)
public ITemplateEditor TemplateEditor { get; }
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
{
config = configuration;
Template = templates;
Description = templates.Description;
TemplateEditor = templates;
Description = templates.TemplateDescription;
ListItems
= new AvaloniaList<Tuple<string, string>>(
Template
.GetTemplateTags()
= new AvaloniaList<Tuple<string, string, string>>(
TemplateEditor
.EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select(
t => new Tuple<string, string>(
t => new Tuple<string, string, string>(
$"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>",
t.Description)
t.Description,
t.DefaultValue)
)
);
@@ -108,84 +111,33 @@ namespace LibationAvalonia.Dialogs
}
}
public string workingTemplateText => Template.Sanitize(UserTemplateText);
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
public string Description { get; }
public AvaloniaList<Tuple<string, string>> ListItems { get; set; }
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
public void resetTextBox(string value) => UserTemplateText = value;
public async Task<bool> Validate()
{
if (Template.IsValid(workingTemplateText))
if (TemplateEditor.EditingTemplate.IsValid)
return true;
var errors = Template
.GetErrors(workingTemplateText)
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
var errors
= TemplateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
private void templateTb_TextChanged()
{
var isChapterTitle = Template == Templates.ChapterTitle;
var isFolder = Template == Templates.Folder;
var libraryBookDto = new LibraryBookDto
{
Account = "my account",
AudibleProductId = "123456789",
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = "1",
BitRate = 128,
SampleRate = 44100,
Channels = 2
};
var chapterName = "A Flight for Life";
var chapterNumber = 4;
var chaptersTotal = 10;
var partFileProperties = new AaxDecrypter.MultiConvertFileProperties()
{
OutputFileName = "",
PartsPosition = chapterNumber,
PartsTotal = chaptersTotal,
Title = chapterName
};
var books = config.Books;
var folder = Templates.Folder.GetPortionFilename(
libraryBookDto,
//Path must be rooted for windows to allow long file paths. This is
//only necessary for folder templates because they may contain several
//subdirectories. Without rooting, we won't be allowed to create a
//relative path longer than MAX_PATH
Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate));
folder = Path.GetRelativePath(books, folder);
var file
= Template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText);
var ext = config.DecryptToLossy ? "mp3" : "m4b";
var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
TemplateEditor.SetTemplateText(UserTemplateText);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
@@ -198,11 +150,12 @@ namespace LibationAvalonia.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !Template.HasWarnings(workingTemplateText)
= !TemplateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
Template
.GetWarnings(workingTemplateText)
TemplateEditor
.EditingTemplate
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@@ -211,20 +164,24 @@ namespace LibationAvalonia.Dialogs
Inlines.Clear();
if (isChapterTitle)
if (!TemplateEditor.IsFilePath)
{
Inlines.Add(new Run(chapterTitle) { FontWeight = bold });
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return;
}
Inlines.Add(new Run(slashWrap(books)) { FontWeight = reg });
var folder = TemplateEditor.GetFolderName();
var file = TemplateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = isFolder ? bold : reg });
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
Inlines.Add(new Run(sing));
Inlines.Add(new Run(slashWrap(file)) { FontWeight = isFolder ? reg : bold });
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
Inlines.Add(new Run($".{ext}"));
}

View File

@@ -526,15 +526,27 @@
Margin="0,5,0,5"
IsChecked="{Binding !AudioSettings.DecryptToLossy, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Download my books in the original audio format (Lossless)" />
<StackPanel >
<TextBlock
TextWrapping="Wrap"
Text="Download my books in the original audio format (Lossless)" />
<CheckBox
Margin="0,0,0,5"
IsEnabled="{Binding !AudioSettings.DecryptToLossy}"
IsChecked="{Binding AudioSettings.MoveMoovToBeginning, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="{Binding AudioSettings.MoveMoovToBeginningText}" />
</CheckBox>
</StackPanel>
</RadioButton>
<RadioButton
Margin="0,5,0,5"
IsEnabled="{Binding AudioSettings.IsMp3Supported}"
IsChecked="{Binding AudioSettings.DecryptToLossy, Mode=TwoWay}">
<TextBlock
@@ -548,7 +560,6 @@
<StackPanel
Grid.Row="0"
IsVisible="{Binding AudioSettings.IsMp3Supported}"
Grid.Column="1">
<controls:GroupBox

View File

@@ -22,7 +22,7 @@ namespace LibationAvalonia.Dialogs
public SettingsDialog()
{
if (Design.IsDesignMode)
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
_ = Configuration.Instance.LibationFiles;
InitializeComponent();
DataContext = settingsDisp = new(config);
@@ -52,21 +52,22 @@ namespace LibationAvalonia.Dialogs
public async void EditFolderTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.Folder, settingsDisp.DownloadDecryptSettings.FolderTemplate);
var newTemplate = await editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.FolderTemplate));
if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.FolderTemplate = newTemplate;
}
public async void EditFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.File, settingsDisp.DownloadDecryptSettings.FileTemplate);
{
var newTemplate = await editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.FileTemplate));
if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.FileTemplate = newTemplate;
}
public async void EditChapterFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.ChapterFile, settingsDisp.DownloadDecryptSettings.ChapterFileTemplate);
var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.ChapterFileTemplate));
if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.ChapterFileTemplate = newTemplate;
}
@@ -79,16 +80,16 @@ namespace LibationAvalonia.Dialogs
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.ChapterTitle, settingsDisp.AudioSettings.ChapterTitleTemplate);
var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterTitleTemplate>.CreateNameEditor(settingsDisp.AudioSettings.ChapterTitleTemplate));
if (newTemplate is not null)
settingsDisp.AudioSettings.ChapterTitleTemplate = newTemplate;
}
private async Task<string> editTemplate(Templates template, string existingTemplate)
private async Task<string> editTemplate(ITemplateEditor template)
{
var form = new EditTemplateDialog(template, existingTemplate);
var form = new EditTemplateDialog(template);
if (await form.ShowDialog<DialogResult>(this) == DialogResult.OK)
return form.TemplateText;
return template.EditingTemplate.TemplateText;
else return null;
}
}
@@ -266,28 +267,8 @@ namespace LibationAvalonia.Dialogs
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
}
public async Task<bool> SaveSettingsAsync(Configuration config)
public Task<bool> SaveSettingsAsync(Configuration config)
{
static Task validationError(string text, string caption)
=> MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error);
// these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning
if (!Templates.Folder.IsValid(FolderTemplate))
{
await validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template");
return false;
}
if (!Templates.File.IsValid(FileTemplate))
{
await validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template");
return false;
}
if (!Templates.ChapterFile.IsValid(ChapterFileTemplate))
{
await validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template");
return false;
}
config.BadBook
= BadBookAbort ? Configuration.BadBookAction.Abort
: BadBookRetry ? Configuration.BadBookAction.Retry
@@ -301,7 +282,7 @@ namespace LibationAvalonia.Dialogs
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
return true;
return Task.FromResult(true);
}
public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon));
@@ -383,6 +364,7 @@ namespace LibationAvalonia.Dialogs
{
private bool _downloadClipsBookmarks;
private bool _decryptToLossy;
private bool _splitFilesByChapter;
private bool _allowLibationFixup;
private bool _lameTargetBitrate;
@@ -391,8 +373,6 @@ namespace LibationAvalonia.Dialogs
private int _lameVBRQuality;
private string _chapterTitleTemplate;
public bool IsMp3Supported => Configuration.IsLinux || Configuration.IsWindows;
public AudioSettings(Configuration config)
{
LoadSettings(config);
@@ -411,6 +391,7 @@ namespace LibationAvalonia.Dialogs
StripUnabridged = config.StripUnabridged;
ChapterTitleTemplate = config.ChapterTitleTemplate;
DecryptToLossy = config.DecryptToLossy;
MoveMoovToBeginning = config.MoveMoovToBeginning;
LameTargetBitrate = config.LameTargetBitrate;
LameDownsampleMono = config.LameDownsampleMono;
LameConstantBitrate = config.LameConstantBitrate;
@@ -433,6 +414,7 @@ namespace LibationAvalonia.Dialogs
config.StripUnabridged = StripUnabridged;
config.ChapterTitleTemplate = ChapterTitleTemplate;
config.DecryptToLossy = DecryptToLossy;
config.MoveMoovToBeginning = MoveMoovToBeginning;
config.LameTargetBitrate = LameTargetBitrate;
config.LameDownsampleMono = LameDownsampleMono;
config.LameConstantBitrate = LameConstantBitrate;
@@ -453,6 +435,7 @@ namespace LibationAvalonia.Dialogs
public string StripAudibleBrandingText { get; } = Configuration.GetDescription(nameof(Configuration.StripAudibleBrandAudio));
public string StripUnabridgedText { get; } = Configuration.GetDescription(nameof(Configuration.StripUnabridged));
public string ChapterTitleTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
public string MoveMoovToBeginningText { get; } = Configuration.GetDescription(nameof(Configuration.MoveMoovToBeginning));
public bool CreateCueSheet { get; set; }
public bool DownloadCoverArt { get; set; }
@@ -462,7 +445,8 @@ namespace LibationAvalonia.Dialogs
public bool MergeOpeningAndEndCredits { get; set; }
public bool StripAudibleBrandAudio { get; set; }
public bool StripUnabridged { get; set; }
public bool DecryptToLossy { get; set; }
public bool DecryptToLossy { get => _decryptToLossy; set => this.RaiseAndSetIfChanged(ref _decryptToLossy, value); }
public bool MoveMoovToBeginning { get; set; }
public bool LameDownsampleMono { get; set; } = Design.IsDesignMode;
public bool LameConstantBitrate { get; set; } = Design.IsDesignMode;

View File

@@ -28,8 +28,7 @@ namespace LibationAvalonia
if (Design.IsDesignMode) return;
try
{
FormSizeAndPosition savedState = config.GetNonString<FormSizeAndPosition>(form.GetType().Name);
var savedState = config.GetNonString<FormSizeAndPosition>(defaultValue: null, form.GetType().Name);
if (savedState is null)
return;

View File

@@ -9,9 +9,7 @@
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationIcon>libation.ico</ApplicationIcon>
<AssemblyName>Libation</AssemblyName>
<IsPublishable>true</IsPublishable>
<PublishReadyToRun>true</PublishReadyToRun>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
@@ -82,12 +80,6 @@
<None Remove="Assets\up.png" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
@@ -117,10 +109,13 @@
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-preview4 " />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview4" />
<PackageReference Include="Avalonia.Xaml.Behaviors" Version="11.0.0-preview4" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibationUiBase\LibationUiBase.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="glass-with-glow_256.svg">

View File

@@ -1,58 +0,0 @@
using Avalonia.Threading;
using System;
using System.Threading.Tasks;
namespace LibationAvalonia
{
public interface ILogForm
{
void WriteLine(string text);
}
// decouple serilog and form. include convenience factory method
public class LogMe
{
public event EventHandler<string> LogInfo;
public event EventHandler<string> LogErrorString;
public event EventHandler<(Exception, string)> LogError;
private LogMe()
{
LogInfo += (_, text) => Serilog.Log.Logger.Information($"Automated backup: {text}");
LogErrorString += (_, text) => Serilog.Log.Logger.Error(text);
LogError += (_, tuple) => Serilog.Log.Logger.Error(tuple.Item1, tuple.Item2 ?? "Automated backup: error");
}
private static ILogForm LogForm;
public static LogMe RegisterForm<T>(T form) where T : ILogForm
{
var logMe = new LogMe();
if (form is null)
return logMe;
LogForm = form;
logMe.LogInfo += LogMe_LogInfo;
logMe.LogErrorString += LogMe_LogErrorString;
logMe.LogError += LogMe_LogError;
return logMe;
}
private static async void LogMe_LogError(object sender, (Exception, string) tuple)
{
await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine(tuple.Item2 ?? "Automated backup: error"));
await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine("ERROR: " + tuple.Item1.Message));
}
private static async void LogMe_LogErrorString(object sender, string text)
=> await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine(text));
private static async void LogMe_LogInfo(object sender, string text)
=> await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine(text));
public void Info(string text) => LogInfo?.Invoke(this, text);
public void Error(string text) => LogErrorString?.Invoke(this, text);
public void Error(Exception ex, string text = null) => LogError?.Invoke(this, (ex, text));
}
}

View File

@@ -4,6 +4,7 @@ using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
using System;
using System.Collections;
@@ -44,8 +45,7 @@ namespace LibationAvalonia.ViewModels
public string Category { get; protected set; }
public string Misc { get; protected set; }
public string Description { get; protected set; }
public string ProductRating { get; protected set; }
public string MyRatingString => MyRating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
public Rating ProductRating { get; protected set; }
protected Rating _myRating;
public Rating MyRating
{
@@ -57,13 +57,6 @@ namespace LibationAvalonia.ViewModels
&& updateReviewTask?.IsCompleted is not false)
{
updateReviewTask = UpdateRating(value);
updateReviewTask.ContinueWith(t =>
{
if (t.Result)
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, value);
this.RaiseAndSetIfChanged(ref _myRating, value);
});
}
}
}
@@ -75,19 +68,24 @@ namespace LibationAvalonia.ViewModels
public abstract bool IsSeries { get; }
public abstract bool IsEpisode { get; }
public abstract bool IsBook { get; }
public abstract double Opacity { get; }
public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
#endregion
#region User rating
private Task<bool> updateReviewTask;
private async Task<bool> UpdateRating(Rating rating)
private Task updateReviewTask;
private async Task UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
return await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating);
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
{
_myRating = rating;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
}
this.RaisePropertyChanged(nameof(MyRating));
}
#endregion
@@ -133,8 +131,17 @@ namespace LibationAvalonia.ViewModels
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == Book.PictureId)
{
// state validation
if (e is null ||
e.Definition.PictureId is null ||
Book?.PictureId is null ||
e.Picture is null ||
e.Picture.Length == 0)
return;
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
using var ms = new System.IO.MemoryStream(e.Picture);
Cover = new Avalonia.Media.Imaging.Bitmap(ms);

View File

@@ -53,7 +53,6 @@ namespace LibationAvalonia.ViewModels
public override bool IsSeries => false;
public override bool IsEpisode => Parent is not null;
public override bool IsBook => Parent is null;
public override double Opacity => Book.UserDefinedItem.Tags.ToLower().Contains("hidden") ? 0.4 : 1;
#endregion
@@ -69,7 +68,7 @@ namespace LibationAvalonia.ViewModels
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
PurchaseDate = libraryBook.DateAdded.ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
@@ -102,7 +101,6 @@ namespace LibationAvalonia.ViewModels
case nameof(udi.Tags):
Book.UserDefinedItem.Tags = udi.Tags;
this.RaisePropertyChanged(nameof(BookTags));
this.RaisePropertyChanged(nameof(Opacity));
break;
case nameof(udi.BookStatus):
Book.UserDefinedItem.BookStatus = udi.BookStatus;

View File

@@ -12,6 +12,7 @@ using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileLiberator;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
namespace LibationAvalonia.ViewModels
@@ -394,7 +395,7 @@ $@" Title: {libraryBook.Book.Title}
return ProcessBookResult.FailedRetry;
}
private string SkipDialogText => @"
private static string SkipDialogText => @"
An error occurred while trying to process this book.
{0}
@@ -404,9 +405,9 @@ An error occurred while trying to process this book.
- IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.)
".Trim();
private MessageBoxButtons SkipDialogButtons => MessageBoxButtons.AbortRetryIgnore;
private MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button1;
private DialogResult SkipResult => DialogResult.Ignore;
private static MessageBoxButtons SkipDialogButtons => MessageBoxButtons.AbortRetryIgnore;
private static MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button1;
private static DialogResult SkipResult => DialogResult.Ignore;
}
#endregion

View File

@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
using System;
using System.Collections.Generic;
@@ -31,7 +32,7 @@ namespace LibationAvalonia.ViewModels
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
if (Design.IsDesignMode)
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
_ = Configuration.Instance.LibationFiles;
SpeedLimit = Configuration.Instance.DownloadSpeedLimit / 1024m / 1024;
}

View File

@@ -77,7 +77,7 @@ namespace LibationAvalonia.ViewModels
//Run query on new list
FilteredInGridEntries = QueryResults(SOURCE, FilterString);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
await refreshGrid();
}
catch (Exception ex)
@@ -86,6 +86,14 @@ namespace LibationAvalonia.ViewModels
}
}
private async Task refreshGrid()
{
if (GridEntries.IsEditingItem)
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
}
private static List<GridEntry> CreateGridEntries(IEnumerable<LibraryBook> dbBooks)
{
var geList = dbBooks
@@ -118,10 +126,11 @@ namespace LibationAvalonia.ViewModels
return bookList;
}
public void ToggleSeriesExpanded(SeriesEntry seriesEntry)
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
{
seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded;
GridEntries.Refresh();
await refreshGrid();
}
#endregion
@@ -140,7 +149,7 @@ namespace LibationAvalonia.ViewModels
FilteredInGridEntries = QueryResults(SOURCE, searchString);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
await refreshGrid();
}
private bool CollectionFilter(object item)
@@ -176,11 +185,7 @@ namespace LibationAvalonia.ViewModels
if (filterResults is not null && FilteredInGridEntries.Intersect(filterResults).Count() != FilteredInGridEntries.Count)
{
FilteredInGridEntries = filterResults;
if (GridEntries.IsEditingItem)
GridEntries.CommitEdit();
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
await refreshGrid();
}
}

View File

@@ -49,7 +49,6 @@ namespace LibationAvalonia.ViewModels
public override bool IsSeries => true;
public override bool IsEpisode => false;
public override bool IsBook => false;
public override double Opacity => 1;
#endregion
@@ -71,7 +70,7 @@ namespace LibationAvalonia.ViewModels
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());

View File

@@ -19,7 +19,7 @@ namespace LibationAvalonia.Views
var options = new FilePickerSaveOptions
{
Title = "Where to export Library",
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books),
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix),
SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}.xlsx",
DefaultExtension = "xlsx",
ShowOverwritePrompt = true,

View File

@@ -6,12 +6,11 @@ using System.Linq;
namespace LibationAvalonia.Views
{
//DONE
public partial class MainWindow
{
private void Configure_ProcessQueue()
{
var collapseState = !Configuration.Instance.GetNonString<bool>(nameof(_viewModel.QueueOpen));
var collapseState = !Configuration.Instance.GetNonString(defaultValue: true, nameof(_viewModel.QueueOpen));
SetQueueCollapseState(collapseState);
}
@@ -51,12 +50,12 @@ namespace LibationAvalonia.Views
private void SetQueueCollapseState(bool collapsed)
{
_viewModel.QueueOpen = !collapsed;
Configuration.Instance.SetNonString(!collapsed, nameof(_viewModel.QueueOpen));
}
public void ToggleQueueHideBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
SetQueueCollapseState(_viewModel.QueueOpen);
Configuration.Instance.SetNonString(_viewModel.QueueOpen, nameof(_viewModel.QueueOpen));
}
}
}

View File

@@ -80,7 +80,7 @@ namespace LibationAvalonia.Views
const string ignoreUpdate = "IgnoreUpdate";
var config = Configuration.Instance;
if (config.GetString(ignoreUpdate) == upgradeProperties.LatestRelease.ToString())
if (config.GetString(propertyName: ignoreUpdate) == upgradeProperties.LatestRelease.ToString())
return;
var notificationResult = await new UpgradeNotificationDialog(upgradeProperties, Configuration.IsWindows).ShowDialog<DialogResult>(this);

View File

@@ -1,10 +1,10 @@
using Avalonia;
using System;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LibationAvalonia.ViewModels;
using ApplicationServices;
using DataLayer;
using LibationUiBase;
namespace LibationAvalonia.Views
{

View File

@@ -5,6 +5,7 @@ using Avalonia.Data.Converters;
using Avalonia.Markup.Xaml;
using DataLayer;
using LibationAvalonia.ViewModels;
using LibationUiBase;
using System;
using System.Collections.Generic;
using System.Globalization;

View File

@@ -19,10 +19,12 @@
CanUserReorderColumns="True">
<DataGrid.Styles>
<Style Selector="DataGridCell > Panel">
<Setter Property="Margin" Value="0,1,0,1"/>
<Style Selector="DataGridCell">
<Setter Property="Height" Value="80"/>
</Style>
<Style Selector="DataGridCell > Panel">
<Setter Property="VerticalAlignment" Value="Stretch"/>
</Style>
<Style Selector="DataGridCell > Panel > TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="HorizontalAlignment" Value="Stretch"/>
@@ -31,6 +33,10 @@
</Style>
</DataGrid.Styles>
<DataGrid.Resources>
<controls:StarStringConverter x:Key="starStringConverter" />
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTemplateColumn
@@ -73,7 +79,7 @@
<controls:DataGridTemplateColumnExt MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding Title}" />
</Panel>
</DataTemplate>
@@ -83,7 +89,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Authors" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding Authors}" />
</Panel>
</DataTemplate>
@@ -93,7 +99,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Narrators" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding Narrators}" />
</Panel>
</DataTemplate>
@@ -103,7 +109,7 @@
<controls:DataGridTemplateColumnExt Width="90" Header="Length" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding Length}" />
</Panel>
</DataTemplate>
@@ -113,7 +119,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Series" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding Series}" />
</Panel>
</DataTemplate>
@@ -123,7 +129,7 @@
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding LongDescription}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<Panel Background="{Binding BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<TextBlock Text="{Binding Description}" FontSize="11" VerticalAlignment="Top" />
</Panel>
</DataTemplate>
@@ -133,39 +139,45 @@
<controls:DataGridTemplateColumnExt Width="100" Header="Category" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding Category}" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="115" Header="Product&#xA;Rating" CanUserSort="True" SortMemberPath="ProductRating" ClipboardContentBinding="{Binding ProductRating}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<TextBlock Text="{Binding ProductRating}" TextWrapping="NoWrap" FontSize="11" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridMyRatingColumn
Header="Product&#xA;Rating"
IsReadOnly="true"
Width="115"
SortMemberPath="ProductRating" CanUserSort="True"
BackgroundBinding="{Binding BackgroundBrush}"
ClipboardContentBinding="{Binding ProductRating, Converter={StaticResource starStringConverter}}"
Binding="{Binding ProductRating}" />
<controls:DataGridTemplateColumnExt Width="90" Header="Purchase&#xA;Date" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding PurchaseDate}" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridMyRatingColumn IsReadOnly="false" Width="115" Header="My Rating" CanUserSort="True" SortMemberPath="MyRating" ClipboardContentBinding="{Binding MyRatingString}" Binding="{Binding MyRating, Mode=TwoWay}" />
<controls:DataGridMyRatingColumn
Header="My Rating"
IsReadOnly="false"
Width="115"
SortMemberPath="MyRating" CanUserSort="True"
BackgroundBinding="{Binding BackgroundBrush}"
ClipboardContentBinding="{Binding MyRating, Converter={StaticResource starStringConverter}}"
Binding="{Binding MyRating, Mode=TwoWay}" />
<controls:DataGridTemplateColumnExt Width="135" Header="Misc" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
<Panel Background="{Binding BackgroundBrush}">
<TextBlock Text="{Binding Misc}" TextWrapping="WrapWithOverflow" FontSize="10" />
</Panel>
</DataTemplate>
@@ -187,6 +199,5 @@
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>

View File

@@ -111,7 +111,7 @@ namespace LibationAvalonia.Views
{
Title = $"Locate the audio file for '{entry.Book.Title}'",
AllowMultiple = false,
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books),
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix),
FileTypeFilter = new FilePickerFileType[]
{
new("All files (*.*)") { Patterns = new[] { "*" } },
@@ -273,13 +273,13 @@ namespace LibationAvalonia.Views
#region Button Click Handlers
public void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
public async void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
var button = args.Source as Button;
if (button.DataContext is SeriesEntry sEntry)
{
_viewModel.ToggleSeriesExpanded(sEntry);
await _viewModel.ToggleSeriesExpanded(sEntry);
//Expanding and collapsing reset the list, which will cause focus to shift
//to the topright cell. Reset focus onto the clicked button's cell.

View File

@@ -20,9 +20,9 @@ namespace LibationFileManager
private PersistentDictionary persistentDictionary;
public T GetNonString<T>([CallerMemberName] string propertyName = "") => persistentDictionary.GetNonString<T>(propertyName);
public T GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "") => persistentDictionary.GetNonString(propertyName, defaultValue);
public object GetObject([CallerMemberName] string propertyName = "") => persistentDictionary.GetObject(propertyName);
public string GetString([CallerMemberName] string propertyName = "") => persistentDictionary.GetString(propertyName);
public string GetString(string defaultValue = null, [CallerMemberName] string propertyName = "") => persistentDictionary.GetString(propertyName, defaultValue);
public void SetNonString(object newValue, [CallerMemberName] string propertyName = "")
{
var existing = getExistingValue(propertyName);
@@ -74,77 +74,85 @@ namespace LibationFileManager
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
[Description("Set cover art as the folder's icon. (Windows only)")]
public bool UseCoverAsFolderIcon { get => GetNonString<bool>(); set => SetNonString(value); }
public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
public bool BetaOptIn { get => GetNonString<bool>(); set => SetNonString(value); }
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books { get => GetString(); set => SetString(value); }
public LongPath Books { get => GetString(); set => SetString(value); }
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
public string InProgress { get => GetString(); set => SetString(value); }
public string InProgress { get
{
var tempDir = GetString();
return string.IsNullOrWhiteSpace(tempDir) ? WinTemp : tempDir;
}
set => SetString(value); }
[Description("Allow Libation to fix up audiobook metadata")]
public bool AllowLibationFixup { get => GetNonString<bool>(); set => SetNonString(value); }
public bool AllowLibationFixup { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Create a cue sheet (.cue)")]
public bool CreateCueSheet { get => GetNonString<bool>(); set => SetNonString(value); }
public bool CreateCueSheet { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Retain the Aax file after successfully decrypting")]
public bool RetainAaxFile { get => GetNonString<bool>(); set => SetNonString(value); }
public bool RetainAaxFile { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Split my books into multiple files by chapter")]
public bool SplitFilesByChapter { get => GetNonString<bool>(); set => SetNonString(value); }
public bool SplitFilesByChapter { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Merge Opening/End Credits into the following/preceding chapters")]
public bool MergeOpeningAndEndCredits { get => GetNonString<bool>(); set => SetNonString(value); }
public bool MergeOpeningAndEndCredits { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Strip \"(Unabridged)\" from audiobook metadata tags")]
public bool StripUnabridged { get => GetNonString<bool>(); set => SetNonString(value); }
public bool StripUnabridged { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Strip audible branding from the start and end of audiobooks.\r\n(e.g. \"This is Audible\")")]
public bool StripAudibleBrandAudio { get => GetNonString<bool>(); set => SetNonString(value); }
public bool StripAudibleBrandAudio { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Decrypt to lossy format?")]
public bool DecryptToLossy { get => GetNonString<bool>(); set => SetNonString(value); }
public bool DecryptToLossy { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Move the mp4 moov atom to the beginning of the file?")]
public bool MoveMoovToBeginning { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Lame encoder target. true = Bitrate, false = Quality")]
public bool LameTargetBitrate { get => GetNonString<bool>(); set => SetNonString(value); }
public bool LameTargetBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Lame encoder downsamples to mono")]
public bool LameDownsampleMono { get => GetNonString<bool>(); set => SetNonString(value); }
public bool LameDownsampleMono { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Lame target bitrate [16,320]")]
public int LameBitrate { get => GetNonString<int>(); set => SetNonString(value); }
public int LameBitrate { get => GetNonString(defaultValue: 64); set => SetNonString(value); }
[Description("Restrict encoder to constant bitrate?")]
public bool LameConstantBitrate { get => GetNonString<bool>(); set => SetNonString(value); }
public bool LameConstantBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Match the source bitrate?")]
public bool LameMatchSourceBR { get => GetNonString<bool>(); set => SetNonString(value); }
public bool LameMatchSourceBR { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Lame target VBR quality [10,100]")]
public int LameVBRQuality { get => GetNonString<int>(); set => SetNonString(value); }
public int LameVBRQuality { get => GetNonString(defaultValue: 2); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
public Dictionary<string, bool> GridColumnsVisibilities { get => GetNonString<EquatableDictionary<string, bool>>()?.Clone() ?? new(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
public Dictionary<string, bool> GridColumnsVisibilities { get => GetNonString(defaultValue: new EquatableDictionary<string, bool>()).Clone(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
public Dictionary<string, int> GridColumnsDisplayIndices { get => GetNonString<EquatableDictionary<string, int>>()?.Clone() ?? new(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
public Dictionary<string, int> GridColumnsDisplayIndices { get => GetNonString(defaultValue: new EquatableDictionary<string, int>()).Clone(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
public Dictionary<string, int> GridColumnsWidths { get => GetNonString<EquatableDictionary<string, int>>()?.Clone() ?? new(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
public Dictionary<string, int> GridColumnsWidths { get => GetNonString(defaultValue: new EquatableDictionary<string, int>()).Clone(); set => SetNonString(value); }
[Description("Save cover image alongside audiobook?")]
public bool DownloadCoverArt { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Save cover image alongside audiobook?")]
public bool DownloadCoverArt { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Download clips and bookmarks?")]
public bool DownloadClipsBookmarks { get => GetNonString<bool>(); set => SetNonString(value); }
public bool DownloadClipsBookmarks { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("File format to save clips and bookmarks")]
public ClipBookmarkFormat ClipsBookmarksFileFormat { get => GetNonString<ClipBookmarkFormat>(); set => SetNonString(value); }
public ClipBookmarkFormat ClipsBookmarksFileFormat { get => GetNonString(defaultValue: ClipBookmarkFormat.CSV); set => SetNonString(value); }
[JsonConverter(typeof(StringEnumConverter))]
public enum ClipBookmarkFormat
@@ -171,33 +179,33 @@ namespace LibationFileManager
}
[Description("When liberating books and there is an error, Libation should:")]
public BadBookAction BadBook { get => GetNonString<BadBookAction>(); set => SetNonString(value); }
public BadBookAction BadBook { get => GetNonString(defaultValue: BadBookAction.Ask); set => SetNonString(value); }
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
public bool ShowImportedStats { get => GetNonString<bool>(); set => SetNonString(value); }
public bool ShowImportedStats { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
public bool ImportEpisodes { get => GetNonString<bool>(); set => SetNonString(value); }
public bool ImportEpisodes { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
public bool DownloadEpisodes { get => GetNonString<bool>(); set => SetNonString(value); }
public bool DownloadEpisodes { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Automatically run periodic scans in the background?")]
public bool AutoScan { get => GetNonString<bool>(); set => SetNonString(value); }
public bool AutoScan { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Auto download books? After scan, download new books in 'checked' accounts.")]
// poorly named setting. Should just be 'AutoDownload'. It is NOT episode specific
public bool AutoDownloadEpisodes { get => GetNonString<bool>(); set => SetNonString(value); }
public bool AutoDownloadEpisodes { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Save all podcast episodes in a series to the series parent folder?")]
public bool SavePodcastsToParentFolder { get => GetNonString<bool>(); set => SetNonString(value); }
public bool SavePodcastsToParentFolder { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Global download speed limit in bytes per second.")]
public long DownloadSpeedLimit
{
get
{
var limit = GetNonString<long>();
var limit = GetNonString(defaultValue: 0L);
return limit <= 0 ? 0 : Math.Max(limit, AaxDecrypter.NetworkFileStream.MIN_BYTES_PER_SECOND);
}
set
@@ -210,42 +218,46 @@ namespace LibationFileManager
#region templates: custom file naming
[Description("Edit how filename characters are replaced")]
public ReplacementCharacters ReplacementCharacters { get => GetNonString<ReplacementCharacters>(); set => SetNonString(value); }
public ReplacementCharacters ReplacementCharacters { get => GetNonString(defaultValue: ReplacementCharacters.Default); set => SetNonString(value); }
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate
{
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
get => getTemplate<Templates.FolderTemplate>();
set => setTemplate<Templates.FolderTemplate>(value);
}
[Description("How to format the saved pdf and audio files")]
public string FileTemplate
{
get => getTemplate(nameof(FileTemplate), Templates.File);
set => setTemplate(nameof(FileTemplate), Templates.File, value);
get => getTemplate<Templates.FileTemplate>();
set => setTemplate<Templates.FileTemplate>(value);
}
[Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate
{
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
get => getTemplate<Templates.ChapterFileTemplate>();
set => setTemplate<Templates.ChapterFileTemplate>(value);
}
[Description("How to format the file's Tile stored in metadata")]
public string ChapterTitleTemplate
{
get => getTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle);
set => setTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle, value);
get => getTemplate<Templates.ChapterTitleTemplate>();
set => setTemplate<Templates.ChapterTitleTemplate>(value);
}
private string getTemplate(string settingName, Templates templ) => templ.GetValid(GetString(settingName));
private void setTemplate(string settingName, Templates templ, string newValue)
private string getTemplate<T>([CallerMemberName] string propertyName = "")
where T : Templates, ITemplate, new()
{
var template = newValue?.Trim();
if (templ.IsValid(template))
SetString(template, settingName);
return Templates.GetTemplate<T>(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText;
}
private void setTemplate<T>(string newValue, [CallerMemberName] string propertyName = "")
where T : Templates, ITemplate, new()
{
SetString(Templates.GetTemplate<T>(newValue).TemplateText, propertyName);
}
#endregion
}

View File

@@ -2,7 +2,7 @@
namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
public partial class Configuration
{
/*
* Use this type in the getter for any Dictionary<TKey, TValue> settings,
@@ -12,7 +12,7 @@ namespace LibationFileManager
private class EquatableDictionary<TKey, TValue> : Dictionary<TKey, TValue>
{
public EquatableDictionary() { }
public EquatableDictionary(IEnumerable<KeyValuePair<TKey, TValue>> keyValuePairs) : base(keyValuePairs) { }
public EquatableDictionary(IEnumerable<KeyValuePair<TKey, TValue>> keyValuePairs) : base(keyValuePairs) { }
public EquatableDictionary<TKey, TValue> Clone() => new(this);
public override bool Equals(object obj)
{

View File

@@ -7,7 +7,7 @@ using FileManager;
namespace LibationFileManager
{
public partial class Configuration
public partial class Configuration : PropertyChangeFilter
{
public bool LibationSettingsAreValid
=> File.Exists(APPSETTINGS_JSON)
@@ -24,9 +24,6 @@ namespace LibationFileManager
if (booksDir is null || !Directory.Exists(booksDir))
return false;
if (string.IsNullOrWhiteSpace(pDic.GetString(nameof(InProgress))))
return false;
return true;
}

View File

@@ -20,15 +20,21 @@ namespace LibationFileManager
public string FirstNarrator => Narrators.FirstOrDefault();
public string SeriesName { get; set; }
public string SeriesNumber { get; set; }
public int? SeriesNumber { get; set; }
public bool IsSeries => !string.IsNullOrEmpty(SeriesName);
public bool IsPodcast { get; set; }
public int BitRate { get; set; }
public int SampleRate { get; set; }
public int Channels { get; set; }
}
public DateTime FileDate { get; set; } = DateTime.Now;
public DateTime? DatePublished { get; set; }
public string Language { get; set; }
}
public class LibraryBookDto : BookDto
{
public string Account { get; set; }
{
public DateTime? DateAdded { get; set; }
public string Account { get; set; }
}
}

View File

@@ -1,335 +0,0 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
namespace LibationFileManager
{
#region Useage
/*
* USEAGE
*************************
* *
* Event Filter Mode *
* *
*************************
propertyChangeFilter.PropertyChanged += MyPropertiesChanged;
[PropertyChangeFilter("MyProperty1")]
[PropertyChangeFilter("MyProperty2")]
void MyPropertiesChanged(object sender, PropertyChangedEventArgsEx e)
{
// Only properties whose names match either "MyProperty1"
// or "MyProperty2" will fire this event handler.
}
******
* OR *
******
propertyChangeFilter.PropertyChanged +=
[PropertyChangeFilter("MyProperty1")]
[PropertyChangeFilter("MyProperty2")]
(_, _) =>
{
// Only properties whose names match either "MyProperty1"
// or "MyProperty2" will fire this event handler.
};
*************************
* *
* Observable Mode *
* *
*************************
using var cancellation = propertyChangeFilter.ObservePropertyChanging<int>("MyProperty", MyPropertyChanging);
void MyPropertyChanging(int oldValue, int newValue)
{
// Only the property whose name match
// "MyProperty" will fire this method.
}
//The observer is delisted when cancellation is disposed
******
* OR *
******
using var cancellation = propertyChangeFilter.ObservePropertyChanged<bool>("MyProperty", s =>
{
// Only the property whose name match
// "MyProperty" will fire this action.
});
//The observer is delisted when cancellation is disposed
*/
#endregion
public abstract class PropertyChangeFilter
{
private readonly Dictionary<string, List<Delegate>> propertyChangedActions = new();
private readonly Dictionary<string, List<Delegate>> propertyChangingActions = new();
private readonly List<(PropertyChangedEventHandlerEx subscriber, PropertyChangedEventHandlerEx wrapper)> changedFilters = new();
private readonly List<(PropertyChangingEventHandlerEx subscriber, PropertyChangingEventHandlerEx wrapper)> changingFilters = new();
public PropertyChangeFilter()
{
PropertyChanging += Configuration_PropertyChanging;
PropertyChanged += Configuration_PropertyChanged;
}
#region Events
protected void OnPropertyChanged(string propertyName, object newValue)
=> _propertyChanged?.Invoke(this, new(propertyName, newValue));
protected void OnPropertyChanging(string propertyName, object oldValue, object newValue)
=> _propertyChanging?.Invoke(this, new(propertyName, oldValue, newValue));
private PropertyChangedEventHandlerEx _propertyChanged;
private PropertyChangingEventHandlerEx _propertyChanging;
public event PropertyChangedEventHandlerEx PropertyChanged
{
add
{
var attributes = getAttributes<PropertyChangeFilterAttribute>(value.Method);
if (attributes.Any())
{
var matches = attributes.Select(a => a.PropertyName).ToArray();
void filterer(object s, PropertyChangedEventArgsEx e)
{
if (e.PropertyName.In(matches)) value(s, e);
}
changedFilters.Add((value, filterer));
_propertyChanged += filterer;
}
else
_propertyChanged += value;
}
remove
{
var del = changedFilters.LastOrDefault(d => d.subscriber == value);
if (del == default)
_propertyChanged -= value;
else
{
_propertyChanged -= del.wrapper;
changedFilters.Remove(del);
}
}
}
public event PropertyChangingEventHandlerEx PropertyChanging
{
add
{
var attributes = getAttributes<PropertyChangeFilterAttribute>(value.Method);
if (attributes.Any())
{
var matches = attributes.Select(a => a.PropertyName).ToArray();
void filterer(object s, PropertyChangingEventArgsEx e)
{
if (e.PropertyName.In(matches)) value(s, e);
}
changingFilters.Add((value, filterer));
_propertyChanging += filterer;
}
else
_propertyChanging += value;
}
remove
{
var del = changingFilters.LastOrDefault(d => d.subscriber == value);
if (del == default)
_propertyChanging -= value;
else
{
_propertyChanging -= del.wrapper;
changingFilters.Remove(del);
}
}
}
private static T[] getAttributes<T>(MethodInfo methodInfo) where T : Attribute
=> Attribute.GetCustomAttributes(methodInfo, typeof(T)) as T[];
#endregion
#region Observables
/// <summary>
/// Clear all subscriptions to Property<b>Changed</b> for <paramref name="propertyName"/>
/// </summary>
public void ClearChangedSubscriptions(string propertyName)
{
if (propertyChangedActions.ContainsKey(propertyName)
&& propertyChangedActions[propertyName] is not null)
propertyChangedActions[propertyName].Clear();
}
/// <summary>
/// Clear all subscriptions to Property<b>Changing</b> for <paramref name="propertyName"/>
/// </summary>
public void ClearChangingSubscriptions(string propertyName)
{
if (propertyChangingActions.ContainsKey(propertyName)
&& propertyChangingActions[propertyName] is not null)
propertyChangingActions[propertyName].Clear();
}
/// <summary>
/// Add an action to be executed when a property's value has changed
/// </summary>
/// <typeparam name="T">The <paramref name="propertyName"/>'s <see cref="Type"/></typeparam>
/// <param name="propertyName">Name of the property whose change triggers the <paramref name="action"/></param>
/// <param name="action">Action to be executed with the NewValue as a parameter</param>
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
public IDisposable ObservePropertyChanged<T>(string propertyName, Action<T> action)
{
validateSubscriber<T>(propertyName, action);
if (!propertyChangedActions.ContainsKey(propertyName))
propertyChangedActions.Add(propertyName, new List<Delegate>());
var actionlist = propertyChangedActions[propertyName];
if (!actionlist.Contains(action))
actionlist.Add(action);
return new Unsubscriber(actionlist, action);
}
/// <summary>
/// Add an action to be executed when a property's value is changing
/// </summary>
/// <typeparam name="T">The <paramref name="propertyName"/>'s <see cref="Type"/></typeparam>
/// <param name="propertyName">Name of the property whose change triggers the <paramref name="action"/></param>
/// <param name="action">Action to be executed with OldValue and NewValue as parameters</param>
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
public IDisposable ObservePropertyChanging<T>(string propertyName, Action<T, T> action)
{
validateSubscriber<T>(propertyName, action);
if (!propertyChangingActions.ContainsKey(propertyName))
propertyChangingActions.Add(propertyName, new List<Delegate>());
var actionlist = propertyChangingActions[propertyName];
if (!actionlist.Contains(action))
actionlist.Add(action);
return new Unsubscriber(actionlist, action);
}
private void validateSubscriber<T>(string propertyName, Delegate action)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(propertyName, nameof(propertyName));
ArgumentValidator.EnsureNotNull(action, nameof(action));
var propertyInfo = GetType().GetProperty(propertyName);
if (propertyInfo is null)
throw new MissingMemberException($"{nameof(Configuration)}.{propertyName} does not exist.");
if (propertyInfo.PropertyType != typeof(T))
throw new InvalidCastException($"{nameof(Configuration)}.{propertyName} is {propertyInfo.PropertyType}, but parameter is {typeof(T)}.");
}
private void Configuration_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
{
if (propertyChangedActions.ContainsKey(e.PropertyName))
{
foreach (var action in propertyChangedActions[e.PropertyName])
{
action.DynamicInvoke(e.NewValue);
}
}
}
private void Configuration_PropertyChanging(object sender, PropertyChangingEventArgsEx e)
{
if (propertyChangingActions.ContainsKey(e.PropertyName))
{
foreach (var action in propertyChangingActions[e.PropertyName])
{
action.DynamicInvoke(e.OldValue, e.NewValue);
}
}
}
private class Unsubscriber : IDisposable
{
private List<Delegate> _observers;
private Delegate _observer;
internal Unsubscriber(List<Delegate> observers, Delegate observer)
{
_observers = observers;
_observer = observer;
}
public void Dispose()
{
if (_observers.Contains(_observer))
_observers.Remove(_observer);
}
}
#endregion
}
public delegate void PropertyChangedEventHandlerEx(object sender, PropertyChangedEventArgsEx e);
public delegate void PropertyChangingEventHandlerEx(object sender, PropertyChangingEventArgsEx e);
public class PropertyChangedEventArgsEx : PropertyChangedEventArgs
{
public object NewValue { get; }
public PropertyChangedEventArgsEx(string propertyName, object newValue) : base(propertyName)
{
NewValue = newValue;
}
}
public class PropertyChangingEventArgsEx : PropertyChangingEventArgs
{
public object OldValue { get; }
public object NewValue { get; }
public PropertyChangingEventArgsEx(string propertyName, object oldValue, object newValue) : base(propertyName)
{
OldValue = oldValue;
NewValue = newValue;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class PropertyChangeFilterAttribute : Attribute
{
public string PropertyName { get; }
public PropertyChangeFilterAttribute(string propertyName)
{
PropertyName = propertyName;
}
}
}

View File

@@ -0,0 +1,139 @@
using AaxDecrypter;
using FileManager;
using System.Collections.Generic;
using System;
using System.IO;
namespace LibationFileManager
{
public interface ITemplateEditor
{
bool IsFolder { get; }
bool IsFilePath { get; }
LongPath BaseDirectory { get; }
string DefaultTemplate { get; }
string TemplateName { get; }
string TemplateDescription { get; }
Templates Folder { get; }
Templates File { get; }
Templates Name { get; }
Templates EditingTemplate { get; }
void SetTemplateText(string templateText);
string GetFolderName();
string GetFileName();
string GetName();
}
public class TemplateEditor<T> : ITemplateEditor where T : Templates, ITemplate, new()
{
public bool IsFolder => EditingTemplate is Templates.FolderTemplate;
public bool IsFilePath => EditingTemplate is not Templates.ChapterTitleTemplate;
public LongPath BaseDirectory { get; private init; }
public string DefaultTemplate { get; private init; }
public string TemplateName { get; private init; }
public string TemplateDescription { get; private init; }
public Templates Folder { get; private set; }
public Templates File { get; private set; }
public Templates Name { get; private set; }
public Templates EditingTemplate
{
get => _editingTemplate;
private set => _editingTemplate = !IsFilePath ? Name = value : IsFolder ? Folder = value : File = value;
}
private Templates _editingTemplate;
public void SetTemplateText(string templateText)
{
Templates.TryGetTemplate<T>(templateText, out var template);
EditingTemplate = template;
}
private static readonly LibraryBookDto libraryBookDto
= new()
{
Account = "my account",
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
AudibleProductId = "123456789",
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = 1,
BitRate = 128,
SampleRate = 44100,
Channels = 2,
Language = "English"
};
private static readonly MultiConvertFileProperties partFileProperties
= new()
{
OutputFileName = "",
PartsPosition = 4,
PartsTotal = 10,
Title = "A Flight for Life"
};
public string GetFolderName()
{
/*
* Path must be rooted for windows to allow long file paths. This is
* only necessary for folder templates because they may contain several
* subdirectories. Without rooting, we won't be allowed to create a
* relative path longer than MAX_PATH.
*/
var dir = Folder.GetFilename(libraryBookDto, BaseDirectory, "");
return Path.GetRelativePath(BaseDirectory, dir);
}
public string GetFileName()
=> File.GetFilename(libraryBookDto, partFileProperties, "", "");
public string GetName()
=> Name.GetName(libraryBookDto, partFileProperties);
public static ITemplateEditor CreateFilenameEditor(LongPath baseDir, string templateText)
{
Templates.TryGetTemplate<T>(templateText, out var template);
var templateEditor = new TemplateEditor<T>
{
_editingTemplate = template,
BaseDirectory = baseDir,
DefaultTemplate = T.DefaultTemplate,
TemplateName = T.Name,
TemplateDescription = T.Description
};
if (!templateEditor.IsFolder && !templateEditor.IsFilePath)
throw new InvalidOperationException($"This method is only for File and Folder templates. Use {nameof(CreateNameEditor)} for name templates");
templateEditor.Folder = templateEditor.IsFolder ? template : Templates.Folder;
templateEditor.File = templateEditor.IsFolder ? Templates.File : template;
return templateEditor;
}
public static ITemplateEditor CreateNameEditor(string templateText)
{
Templates.TryGetTemplate<T>(templateText, out var nameTemplate);
var templateEditor = new TemplateEditor<T>
{
_editingTemplate = nameTemplate,
DefaultTemplate = T.DefaultTemplate,
TemplateName = T.Name,
TemplateDescription = T.Description
};
if (templateEditor.IsFolder || templateEditor.IsFilePath)
throw new InvalidOperationException($"This method is only for name templates. Use {nameof(CreateFilenameEditor)} for file templates");
return templateEditor;
}
}
}

View File

@@ -1,28 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
using FileManager.NamingTemplate;
namespace LibationFileManager
{
public sealed class TemplateTags : Enumeration<TemplateTags>
{
public string TagName => DisplayName;
public sealed class TemplateTags : ITemplateTag
{
public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public string TagName { get; }
public string DefaultValue { get; }
public string Description { get; }
public bool IsChapterOnly { get; }
public string Display { get; }
private static int value = 0;
private TemplateTags(string tagName, string description, bool isChapterOnly = false) : base(value++, tagName)
{
Description = description;
IsChapterOnly = isChapterOnly;
}
private TemplateTags(string tagName, string description, string defaultValue = null, string display = null)
{
TagName = tagName;
Description = description;
DefaultValue = defaultValue ?? $"<{tagName}>";
Display = display ?? $"<{tagName}>";
}
// putting these first is the incredibly lazy way to make them show up first in the EditTemplateDialog
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true);
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true);
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #", true);
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros", true);
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters");
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title");
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #");
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros");
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title");
@@ -38,11 +37,16 @@ namespace LibationFileManager
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels");
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
public static TemplateTags Locale { get; } = new TemplateTags("locale", "Region/country");
public static TemplateTags YearPublished { get; } = new TemplateTags("year", "Year published");
public static TemplateTags Locale { get; } = new ("locale", "Region/country");
public static TemplateTags YearPublished { get; } = new("year", "Year published");
public static TemplateTags Language { get; } = new("language", "Book's language");
public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
// Special case. Isn't mapped to a replacement in Templates.cs
// Included here for display by EditTemplateDialog
public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series");
public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"<file date [{DEFAULT_DATE_FORMAT}]>", "<file date [...]>");
public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{DEFAULT_DATE_FORMAT}]>", "<pub date [...]>");
public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>");
public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<if series-><-if series>", "<if series->...<-if series>");
public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>");
}
}

View File

@@ -2,345 +2,337 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AaxDecrypter;
using Dinah.Core;
using FileManager;
using FileManager.NamingTemplate;
namespace LibationFileManager
{
public interface ITemplate
{
static abstract string Name { get; }
static abstract string Description { get; }
static abstract string DefaultTemplate { get; }
static abstract IEnumerable<TagCollection> TagCollections { get; }
}
public abstract class Templates
{
protected static string[] Valid => Array.Empty<string>();
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes";
public const string WARNING_EMPTY = "Template is empty.";
public const string WARNING_WHITE_SPACE = "Template is white space.";
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>";
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
public static FolderTemplate Folder { get; } = new FolderTemplate();
public static FileTemplate File { get; } = new FileTemplate();
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate();
public static ChapterTitleTemplate ChapterTitle { get; } = new ChapterTitleTemplate();
//Assigning the properties in the static constructor will require all
//Templates users to have a valid configuration file. To allow tests
//to work without access to Configuration, only load templates on demand.
private static FolderTemplate _folder;
private static FileTemplate _file;
private static ChapterFileTemplate _chapterFile;
private static ChapterTitleTemplate _chapterTitle;
public abstract string Name { get; }
public abstract string Description { get; }
public abstract string DefaultTemplate { get; }
protected abstract bool IsChapterized { get; }
public static FolderTemplate Folder => _folder ??= GetTemplate<FolderTemplate>(Configuration.Instance.FolderTemplate);
public static FileTemplate File => _file ??= GetTemplate<FileTemplate>(Configuration.Instance.FileTemplate);
public static ChapterFileTemplate ChapterFile => _chapterFile ??= GetTemplate<ChapterFileTemplate>(Configuration.Instance.ChapterFileTemplate);
public static ChapterTitleTemplate ChapterTitle => _chapterTitle ??= GetTemplate<ChapterTitleTemplate>(Configuration.Instance.ChapterTitleTemplate);
protected Templates() { }
#region Template Parsing
public static T GetTemplate<T>(string templateText) where T : Templates, ITemplate, new()
=> TryGetTemplate<T>(templateText, out var template) ? template : GetDefaultTemplate<T>();
public static bool TryGetTemplate<T>(string templateText, out T template) where T : Templates, ITemplate, new()
{
var namingTemplate = NamingTemplate.Parse(templateText, T.TagCollections);
template = new() { NamingTemplate = namingTemplate };
return !namingTemplate.Errors.Any();
}
private static T GetDefaultTemplate<T>() where T : Templates, ITemplate, new()
=> new() { NamingTemplate = NamingTemplate.Parse(T.DefaultTemplate, T.TagCollections) };
static Templates()
{
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.FolderTemplate))]
(_,e) => _folder = GetTemplate<FolderTemplate>((string)e.NewValue);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.FileTemplate))]
(_, e) => _file = GetTemplate<FileTemplate>((string)e.NewValue);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))]
(_, e) => _chapterFile = GetTemplate<ChapterFileTemplate>((string)e.NewValue);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))]
(_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue);
}
#endregion
#region Template Properties
public IEnumerable<TemplateTags> TagsRegistered => NamingTemplate.TagsRegistered.Cast<TemplateTags>();
public IEnumerable<TemplateTags> TagsInUse => NamingTemplate.TagsInUse.Cast<TemplateTags>();
public string TemplateText => NamingTemplate.TemplateText;
protected NamingTemplate NamingTemplate { get; private set; }
#endregion
#region validation
internal string GetValid(string configValue)
{
var value = configValue?.Trim();
return IsValid(value) ? value : DefaultTemplate;
}
public abstract IEnumerable<string> GetErrors(string template);
public bool IsValid(string template) => !GetErrors(template).Any();
public virtual IEnumerable<string> Errors => NamingTemplate.Errors;
public bool IsValid => !Errors.Any();
public abstract IEnumerable<string> GetWarnings(string template);
public bool HasWarnings(string template) => GetWarnings(template).Any();
public virtual IEnumerable<string> Warnings => NamingTemplate.Warnings;
public bool HasWarnings => Warnings.Any();
protected static string[] GetFileErrors(string template)
{
// File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save.
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
if (ReplacementCharacters.ContainsInvalidFilenameChar(template.Replace("<","").Replace(">","")))
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
return Valid;
}
protected IEnumerable<string> GetStandardWarnings(string template)
{
var warnings = GetErrors(template).ToList();
if (template is null)
return warnings;
if (string.IsNullOrEmpty(template))
warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(template))
warnings.Add(WARNING_WHITE_SPACE);
if (TagCount(template) == 0)
warnings.Add(WARNING_NO_TAGS);
if (!IsChapterized && ContainsChapterOnlyTags(template))
warnings.Add(WARNING_HAS_CHAPTER_TAGS);
return warnings;
}
internal int TagCount(string template)
=> GetTemplateTags()
// for <id><id> == 1, use:
// .Count(t => template.Contains($"<{t.TagName}>"))
// .Sum() impl: <id><id> == 2
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
internal static bool ContainsChapterOnlyTags(string template)
=> TemplateTags.GetAll()
.Where(t => t.IsChapterOnly)
.Any(t => ContainsTag(template, t.TagName));
internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
#endregion
#region to file name
/// <summary>
/// EditTemplateDialog: Get template generated filename for portion of path
/// </summary>
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template)
=> string.IsNullOrWhiteSpace(template)
? ""
: getFileNamingTemplate(libraryBookDto, template, null, null)
.GetFilePath(Configuration.Instance.ReplacementCharacters).PathWithoutPrefix;
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension)
public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
dirFullPath = dirFullPath?.Trim() ?? "";
// for non-series, remove <if series-> and <-if series> tags and everything in between
// for series, remove <if series-> and <-if series> tags, what's in between will remain
template = ifSeriesRegex.Replace(
template,
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
var t = template + FileUtility.GetStandardizedExtension(extension);
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
var fileNamingTemplate = new FileNamingTemplate(fullfilename);
var title = libraryBookDto.Title ?? "";
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Bitrate, libraryBookDto.BitRate);
fileNamingTemplate.AddParameterReplacement(TemplateTags.SampleRate, libraryBookDto.SampleRate);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Channels, libraryBookDto.Channels);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
fileNamingTemplate.AddParameterReplacement(TemplateTags.YearPublished, libraryBookDto.YearPublished?.ToString() ?? "1900");
return fileNamingTemplate;
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps).Select(p => p.Value));
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters replacements = null, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
replacements ??= Configuration.Instance.ReplacementCharacters;
return GetFilename(baseDir, fileExtension,replacements, returnFirstExisting, libraryBookDto);
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters replacements = null, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
replacements ??= Configuration.Instance.ReplacementCharacters;
return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, multiChapProps);
}
protected virtual IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts.Select(p => replacements.ReplaceFilenameChars(p.Value));
private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, params object[] dtos)
{
fileExtension = FileUtility.GetStandardizedExtension(fileExtension);
var parts = NamingTemplate.Evaluate(dtos).ToList();
var pathParts = GetPathParts(GetTemplatePartsStrings(parts, replacements));
//Remove 1 character from the end of the longest filename part until
//the total filename is less than max filename length
for (int i = 0; i < pathParts.Count; i++)
{
var part = pathParts[i];
//If file already exists, GetValidFilename will append " (n)" to the filename.
//This could cause the filename length to exceed MaxFilenameLength, so reduce
//allowable filename length by 5 chars, allowing for up to 99 duplicates.
var maxFilenameLength = LongPath.MaxFilenameLength -
(i < pathParts.Count - 1 || string.IsNullOrEmpty(fileExtension) ? 0 : fileExtension.Length + 5);
while (part.Sum(LongPath.GetFilesystemStringLength) > maxFilenameLength)
{
int maxLength = part.Max(p => p.Length);
var maxEntry = part.First(p => p.Length == maxLength);
var maxIndex = part.IndexOf(maxEntry);
part.RemoveAt(maxIndex);
part.Insert(maxIndex, maxEntry.Remove(maxLength - 1, 1));
}
}
var fullPath = Path.Combine(pathParts.Select(fileParts => string.Concat(fileParts)).Prepend(baseDir).ToArray());
return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting);
}
/// <summary>
/// Organize template parts into directories. Any Extra slashes will be
/// returned as empty directories and are taken care of by Path.Combine()
/// </summary>
/// <returns>A List of template directories. Each directory is a list of template part strings</returns>
private static List<List<string>> GetPathParts(IEnumerable<string> templateParts)
{
List<List<string>> directories = new();
List<string> dir = new();
foreach (var part in templateParts)
{
int slashIndex, lastIndex = 0;
while((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1)
{
dir.Add(part[lastIndex..slashIndex]);
directories.Add(dir);
dir = new();
lastIndex = slashIndex + 1;
}
dir.Add(part[lastIndex..]);
}
directories.Add(dir);
return directories;
}
#endregion
public virtual IEnumerable<TemplateTags> GetTemplateTags()
=> TemplateTags.GetAll()
// yeah, this line is a little funky but it works when you think through it. also: trust the unit tests
.Where(t => IsChapterized || !t.IsChapterOnly);
#region Registered Template Properties
public string Sanitize(string template)
private static readonly PropertyTagCollection<LibraryBookDto> filePropertyTags =
new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter)
{
var value = template ?? "";
//Don't allow formatting of Id
{ TemplateTags.Id, lb => lb.AudibleProductId, v => v },
{ TemplateTags.Title, lb => lb.Title },
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
{ TemplateTags.Author, lb => lb.AuthorNames },
{ TemplateTags.FirstAuthor, lb => lb.FirstAuthor },
{ TemplateTags.Narrator, lb => lb.NarratorNames },
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator },
{ TemplateTags.Series, lb => lb.SeriesName },
{ TemplateTags.SeriesNumber, lb => lb.SeriesNumber },
{ TemplateTags.Language, lb => lb.Language },
//Don't allow formatting of LanguageShort
{ TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort },
{ TemplateTags.Bitrate, lb => lb.BitRate },
{ TemplateTags.SampleRate, lb => lb.SampleRate },
{ TemplateTags.Channels, lb => lb.Channels },
{ TemplateTags.Account, lb => lb.Account },
{ TemplateTags.Locale, lb => lb.Locale },
{ TemplateTags.YearPublished, lb => lb.YearPublished },
{ TemplateTags.DatePublished, lb => lb.DatePublished },
{ TemplateTags.DateAdded, lb => lb.DateAdded },
{ TemplateTags.FileDate, lb => lb.FileDate },
};
// don't use alt slash
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
// don't allow double slashes
var sing = $"{Path.DirectorySeparatorChar}";
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (value.Contains(dbl))
value = value.Replace(dbl, sing);
// trim. don't start or end with slash
while (true)
private static readonly List<TagCollection> chapterPropertyTags = new()
{
new PropertyTagCollection<LibraryBookDto>(caseSensative: true, StringFormatter)
{
var start = value.Length;
value = value
.Trim()
.Trim(Path.DirectorySeparatorChar);
var end = value.Length;
if (start == end)
break;
{ TemplateTags.Title, lb => lb.Title },
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
{ TemplateTags.Series, lb => lb.SeriesName },
},
new PropertyTagCollection<MultiConvertFileProperties>(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter)
{
{ TemplateTags.ChCount, m => m.PartsTotal },
{ TemplateTags.ChNumber, m => m.PartsPosition },
{ TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1)) },
{ TemplateTags.ChTitle, m => m.Title },
{ TemplateTags.FileDate, m => m.FileDate }
}
};
return value;
private static readonly ConditionalTagCollection<LibraryBookDto> conditionalTags = new()
{
{ TemplateTags.IfSeries, lb => lb.IsSeries },
{ TemplateTags.IfPodcast, lb => lb.IsPodcast },
{ TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast },
};
#endregion
#region Tag Formatters
private static string getTitleShort(string title)
=> title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title;
private static string getLanguageShort(string language)
{
if (language is null)
return null;
language = language.Trim();
if (language.Length <= 3)
return language.ToUpper();
return language[..3].ToUpper();
}
public class FolderTemplate : Templates
private static string StringFormatter(ITemplateTag templateTag, string value, string formatString)
{
public override string Name => "Folder Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public override string DefaultTemplate { get; } = "<title short> [<id>]";
protected override bool IsChapterized { get; } = false;
internal FolderTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template)
{
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
if (template.Contains(':'))
return new[] { ERROR_FULL_PATH_IS_INVALID };
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
if (ReplacementCharacters.ContainsInvalidPathChar(template.Replace("<", "").Replace(">", "")))
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
return Valid;
}
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, string baseDir = null)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, baseDir ?? AudibleFileStorage.BooksDirectory, null)
.GetFilePath(Configuration.Instance.ReplacementCharacters);
#endregion
if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value?.ToUpper();
else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value?.ToLower();
else return value;
}
public class FileTemplate : Templates
private static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString)
{
public override string Name => "File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>]";
protected override bool IsChapterized { get; } = false;
internal FileTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, string dirFullPath, string extension, bool returnFirstExisting = false)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension)
.GetFilePath(Configuration.Instance.ReplacementCharacters, returnFirstExisting);
#endregion
if (int.TryParse(formatString, out var numDigits))
return value.ToString($"D{numDigits}");
return value.ToString();
}
public class ChapterFileTemplate : Templates
private static string DateTimeFormatter(ITemplateTag templateTag, DateTime value, string formatString)
{
public override string Name => "Chapter File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
protected override bool IsChapterized { get; } = true;
internal ChapterFileTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
public override IEnumerable<string> GetWarnings(string template)
{
var warnings = GetStandardWarnings(template).ToList();
if (template is null)
return warnings;
// recommended to incl. <ch#> or <ch# 0>
if (!ContainsTag(template, TemplateTags.ChNumber.TagName) && !ContainsTag(template, TemplateTags.ChNumber0.TagName))
warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG);
return warnings;
}
#endregion
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
=> GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath, ReplacementCharacters replacements = null)
{
if (string.IsNullOrWhiteSpace(template)) return string.Empty;
replacements ??= Configuration.Instance.ReplacementCharacters;
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
return fileNamingTemplate.GetFilePath(replacements).PathWithoutPrefix;
}
#endregion
if (string.IsNullOrEmpty(formatString))
return value.ToString(TemplateTags.DEFAULT_DATE_FORMAT);
return value.ToString(formatString);
}
public class ChapterTitleTemplate : Templates
#endregion
public class FolderTemplate : Templates, ITemplate
{
private List<TemplateTags> _templateTags { get; } = new()
{
TemplateTags.Title,
TemplateTags.TitleShort,
TemplateTags.Series,
TemplateTags.ChCount,
TemplateTags.ChNumber,
TemplateTags.ChNumber0,
TemplateTags.ChTitle,
};
public override string Name => "Chapter Title Template";
public static string Name { get; }= "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections => new TagCollection[] { filePropertyTags, conditionalTags };
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
public override IEnumerable<string> Errors
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
public override string DefaultTemplate => "<ch#> - <title short>: <ch title>";
protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts
.Select(tp => tp.TemplateTag is null
//FolderTemplate literals can have directory separator characters
? replacements.ReplacePathChars(tp.Value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar))
: replacements.ReplaceFilenameChars(tp.Value)
).ToList();
}
protected override bool IsChapterized => true;
public class FileTemplate : Templates, ITemplate
{
public static string Name { get; } = "File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate));
public static string DefaultTemplate { get; } = "<title> [<id>]";
public static IEnumerable<TagCollection> TagCollections { get; } = new TagCollection[] { filePropertyTags, conditionalTags };
}
public override IEnumerable<string> GetErrors(string template)
=> new List<string>();
public class ChapterFileTemplate : Templates, ITemplate
{
public static string Name { get; } = "Chapter File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags);
public override IEnumerable<string> GetWarnings(string template)
=> GetStandardWarnings(template).ToList();
public override IEnumerable<string> Warnings
=> NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))
? base.Warnings
: base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG);
}
public string GetTitle(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
=> GetPortionTitle(libraryBookDto, Configuration.Instance.ChapterTitleTemplate, props);
public class ChapterTitleTemplate : Templates, ITemplate
{
public static string Name { get; } = "Chapter Title Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
public static string DefaultTemplate => "<ch#> - <title short>: <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags);
public string GetPortionTitle(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props)
{
if (string.IsNullOrEmpty(template)) return string.Empty;
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
var fileNamingTemplate = new MetadataNamingTemplate(template);
var title = libraryBookDto.Title ?? "";
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
return fileNamingTemplate.GetTagContents();
}
public override IEnumerable<TemplateTags> GetTemplateTags() => _templateTags;
protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts.Select(p => p.Value);
}
}
}

View File

@@ -1,13 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FileManager;
namespace LibationFileManager
{
public static class UtilityExtensions
{
public static void AddParameterReplacement(this NamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
=> fileNamingTemplate.AddParameterReplacement(templateTags.TagName, value);
}
}

View File

@@ -1,6 +1,4 @@
using System;
namespace LibationWinForms.ProcessQueue
namespace LibationUiBase
{
public interface ILogForm
{

View File

@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<IsPublishable>true</IsPublishable>
<PublishReadyToRun>true</PublishReadyToRun>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>

View File

@@ -1,10 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LibationWinForms.ProcessQueue
namespace LibationUiBase
{
// decouple serilog and form. include convenience factory method
public class LogMe

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections;
namespace LibationAvalonia
namespace LibationUiBase
{
internal class ObjectComparer<T> : IComparer where T : IComparable
public class ObjectComparer<T> : IComparer where T : IComparable
{
public int Compare(object x, object y) => ((T)x).CompareTo(y);
}

View File

@@ -52,6 +52,7 @@ Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Boo
Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(" > ", Book.CategoriesNames())}
Purchase Date: {_libraryBook.DateAdded:d}
Language: {Book.Language}
Audible ID: {Book.AudibleProductId}
".Trim();

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Windows.Forms;
@@ -10,33 +9,19 @@ namespace LibationWinForms.Dialogs
{
public partial class EditTemplateDialog : Form
{
// final value. post-validity check
public string TemplateText { get; private set; }
// hold the work-in-progress value. not guaranteed to be valid
private string _workingTemplateText;
private string workingTemplateText
{
get => _workingTemplateText;
set => _workingTemplateText = template.Sanitize(value);
}
private void resetTextBox(string value) => this.templateTb.Text = workingTemplateText = value;
private void resetTextBox(string value) => this.templateTb.Text = value;
private Configuration config { get; } = Configuration.Instance;
private Templates template { get; }
private string inputTemplateText { get; }
private ITemplateEditor templateEditor { get;}
public EditTemplateDialog()
{
InitializeComponent();
this.SetLibationIcon();
}
public EditTemplateDialog(Templates template, string inputTemplateText) : this()
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
this.template = ArgumentValidator.EnsureNotNull(template, nameof(template));
this.inputTemplateText = inputTemplateText ?? "";
this.templateEditor = ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
}
private void EditTemplateDialog_Load(object sender, EventArgs e)
@@ -44,85 +29,31 @@ namespace LibationWinForms.Dialogs
if (this.DesignMode)
return;
if (template is null)
if (templateEditor is null)
{
MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(template)} is null"));
MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(templateEditor)} is null"));
return;
}
warningsLbl.Text = "";
this.Text = $"Edit {template.Name}";
this.Text = $"Edit {templateEditor.TemplateName}";
this.templateLbl.Text = template.Description;
resetTextBox(inputTemplateText);
this.templateLbl.Text = templateEditor.TemplateDescription;
resetTextBox(templateEditor.EditingTemplate.TemplateText);
// populate list view
foreach (var tag in template.GetTemplateTags())
listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }));
foreach (TemplateTags tag in templateEditor.EditingTemplate.TagsRegistered)
listView1.Items.Add(new ListViewItem(new[] { tag.Display, tag.Description }) { Tag = tag.DefaultValue });
listView1.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent);
}
private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(template.DefaultTemplate);
private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(templateEditor.DefaultTemplate);
private void templateTb_TextChanged(object sender, EventArgs e)
{
workingTemplateText = templateTb.Text;
var isChapterTitle = template == Templates.ChapterTitle;
var isFolder = template == Templates.Folder;
var libraryBookDto = new LibraryBookDto
{
Account = "my account",
AudibleProductId = "123456789",
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = "1",
BitRate = 128,
SampleRate = 44100,
Channels = 2
};
var chapterName = "A Flight for Life";
var chapterNumber = 4;
var chaptersTotal = 10;
var partFileProperties = new AaxDecrypter.MultiConvertFileProperties()
{
OutputFileName = "",
PartsPosition = chapterNumber,
PartsTotal = chaptersTotal,
Title = chapterName
};
var books = config.Books;
var folder = Templates.Folder.GetPortionFilename(
libraryBookDto,
//Path must be rooted for windows to allow long file paths. This is
//only necessary for folder templates because they may contain several
//subdirectories. Without rooting, we won't be allowed to create a
//relative path longer than MAX_PATH
Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate));
folder = Path.GetRelativePath(books, folder);
var file
= template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText);
var ext = config.DecryptToLossy ? "mp3" : "m4b";
var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
templateEditor.SetTemplateText(templateTb.Text);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
@@ -135,11 +66,12 @@ namespace LibationWinForms.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
warningsLbl.Text
= !template.HasWarnings(workingTemplateText)
= !templateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
template
.GetWarnings(workingTemplateText)
templateEditor
.EditingTemplate
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@@ -149,51 +81,52 @@ namespace LibationWinForms.Dialogs
richTextBox1.Clear();
richTextBox1.SelectionFont = reg;
if (isChapterTitle)
if (!templateEditor.IsFilePath)
{
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(chapterTitle);
richTextBox1.AppendText(templateEditor.GetName());
return;
}
richTextBox1.AppendText(slashWrap(books));
var folder = templateEditor.GetFolderName();
var file = templateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
richTextBox1.AppendText(slashWrap(templateEditor.BaseDirectory.PathWithoutPrefix));
richTextBox1.AppendText(sing);
if (isFolder)
if (templateEditor.IsFolder)
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(slashWrap(folder));
if (isFolder)
if (templateEditor.IsFolder)
richTextBox1.SelectionFont = reg;
richTextBox1.AppendText(sing);
if (!isFolder)
if (templateEditor.IsFilePath && !templateEditor.IsFolder)
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(file);
if (!isFolder)
richTextBox1.SelectionFont = reg;
richTextBox1.SelectionFont = reg;
richTextBox1.AppendText($".{ext}");
}
private void saveBtn_Click(object sender, EventArgs e)
{
if (!template.IsValid(workingTemplateText))
if (!templateEditor.EditingTemplate.IsValid)
{
var errors = template
.GetErrors(workingTemplateText)
var errors = templateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
TemplateText = workingTemplateText;
this.DialogResult = DialogResult.OK;
this.Close();
}
@@ -206,9 +139,11 @@ namespace LibationWinForms.Dialogs
private void listView1_DoubleClick(object sender, EventArgs e)
{
var itemText = listView1.SelectedItems[0].Text.Replace("...", "");
var text = templateTb.Text;
var itemText = listView1.SelectedItems[0].Tag as string;
if (string.IsNullOrEmpty(itemText)) return;
var text = templateTb.Text;
var selStart = Math.Min(Math.Max(0, templateTb.SelectionStart), text.Length);
templateTb.Text = text.Insert(selStart, itemText);

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