Compare commits

...

97 Commits

Author SHA1 Message Date
Robert McRackan
34f56d2fd7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-03-08 14:07:51 -05:00
Robert McRackan
c45ffaf4a6 Incr ver 2023-03-08 14:07:47 -05:00
rmcrackan
ae43ab103e Merge pull request #524 from Mbucari/master
Improve library scan speed and Track and display book availability
2023-03-08 14:06:45 -05:00
Mbucari
559977ce0b Add 'Unavailable' book and pdf counts. 2023-03-08 11:26:07 -07:00
Mbucari
ccd4d3e26d Check for null Plan array 2023-03-08 11:21:47 -07:00
MBucari
e76f99ff28 Fix rmcrackan/Libation#523 2023-03-07 22:34:36 -07:00
MBucari
d3607583ab Tweak episode scan 2023-03-07 20:32:50 -07:00
MBucari
3ebd4ce243 Show AbsentFromLastScan book status in grid 2023-03-07 20:02:22 -07:00
Mbucari
f6dcc0db1d Add AbsentFromLastScan 2023-03-07 18:58:18 -07:00
MBucari
bd49db83e4 Improve library scan performance 2023-03-07 15:30:22 -07:00
Mbucari
4140722a6d Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-06 16:56:57 -07:00
Mbucari
da36f9414d Improve library scan performance 2023-03-06 16:49:52 -07:00
Mbucari
1510f71ca6 Merge branch 'rmcrackan:master' into master 2023-03-03 16:33:36 -07:00
Mbucari
cdb27ef712 Add last downloaded info to exports 2023-03-03 15:06:06 -07:00
Robert McRackan
790319ed98 incr ver 2023-03-03 15:58:05 -05:00
rmcrackan
1b0fb2b316 Merge pull request #522 from Mbucari/master
Resolved Several Issues (It's not as bad as  2,453 lines suggests)
2023-03-03 15:56:59 -05:00
Mbucari
02371f2221 Deleting folders with custom icons no longer triggers system file warning 2023-03-03 10:56:31 -07:00
Mbucari
2b672f86be Fatten up the chevrons 2023-03-02 19:57:43 -07:00
Mbucari
36176bff33 Update ImageSharp 2023-03-02 19:41:59 -07:00
Mbucari
174b0c26b8 Update fileicon to latest version 2023-03-02 19:40:38 -07:00
Mbucari
26c60e8e79 Convert queue expand/collapse button text to images (rmcrackan/Libation#339) 2023-03-02 19:23:03 -07:00
Mbucari
d94759d868 Add Last Download column to grid (rmcrackan/Libation#498) 2023-03-02 18:52:45 -07:00
Mbucari
bd7e45ca3c Add last download into to database 2023-03-02 15:09:10 -07:00
Mbucari
52a863c62a Add audiobook Trash Bin 2023-03-02 13:12:32 -07:00
Mbucari
fe55b90ee3 Fix rmcrackan/Libation#511 2023-03-01 22:14:57 -07:00
Mbucari
df224cc7f3 Move TrackedQueue to LibationUiBase 2023-03-01 09:33:17 -07:00
Mbucari
2a59329350 Merge branch 'rmcrackan:master' into master 2023-02-28 16:41:14 -07:00
Mbucari
abdf0e7261 Parallelize post-liberation tasks 2023-02-28 16:40:53 -07:00
Mbucari
b9c2a1cce3 Add folder icon support to MacOS 2023-02-28 15:57:27 -07:00
rmcrackan
aa86fca08f Update InstallOnMac.md 2023-02-28 15:46:51 -05:00
Robert McRackan
cf9ec9facf did last tag incorrect. New version 2023-02-28 10:13:26 -05:00
Robert McRackan
f6084ef10c v9.4.1 2023-02-28 10:04:47 -05:00
rmcrackan
740b73beb7 Merge pull request #518 from Mbucari/master
Improve Audible login and Libation Upgrade
2023-02-28 09:51:08 -05:00
Mbucari
5c45802391 Fixed review comments 2023-02-28 07:42:26 -07:00
MBucari
429aa603f5 Update workflows 2023-02-27 21:41:59 -07:00
MBucari
80ea394934 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-02-27 16:33:16 -07:00
Mbucari
bce4437c79 Change workflows 2023-02-27 16:18:48 -07:00
Mbucari
b6ad1a289b Remove windows desktop runtime dependency from chardonnay 2023-02-27 16:13:40 -07:00
Mbucari
2a22d05f37 Remove windows desktop runtime dependency from chardonnay 2023-02-27 15:08:54 -07:00
Mbucari
d787843fd2 Unify upgrade process and add update progress bar 2023-02-27 14:08:15 -07:00
Mbucari
ded58f687d Update 2FA and Captcha controls 2023-02-27 14:08:14 -07:00
Mbucari
1f1f34b6ce Merge branch 'rmcrackan:master' into master 2023-02-27 09:36:53 -07:00
Mbucari
ffadf90f4f Fix MFA and 2FA 2023-02-27 09:36:19 -07:00
rmcrackan
67807efacf Merge pull request #515 from Mbucari/patch-4
Update InstallOnMac.md
2023-02-26 15:29:55 -05:00
Mbucari
980f5afa54 Update InstallOnMac.md 2023-02-25 19:42:45 -07:00
Robert McRackan
b2f68760b2 New audible api login 2023-02-24 15:52:14 -05:00
rmcrackan
faf86711a5 Merge pull request #509 from Mbucari/master
Add More MP3 Options and improved AAXClean
2023-02-24 15:35:38 -05:00
Mbucari
4a78b9d28f Revert workflow change 2023-02-24 12:38:29 -07:00
Michael Bucari-Tovo
1b0a7f5062 New mp3 options and improved encoding performance 2023-02-24 12:12:41 -07:00
Mbucari
49982043e0 Merge branch 'rmcrackan:master' into master 2023-02-24 11:15:14 -07:00
Robert McRackan
378cf7057e updated to AudibleApi v8 2023-02-24 13:12:18 -05:00
Mbucari
abdc0f018e Update build-linux.yml 2023-02-22 09:23:15 -07:00
Robert McRackan
c65f61b92e Fix paypal links 2023-02-22 07:33:58 -05:00
Robert McRackan
c12805c8ce incr ver for release 2023-02-19 14:55:55 -05:00
rmcrackan
67f9a6db78 Merge pull request #503 from Mbucari/master
Mac and Linux Arm64 releases and Fixed #502
2023-02-19 14:52:09 -05:00
Mbucari
bb6336ce2a Update .releaseindex.json 2023-02-19 11:27:23 -07:00
Michael Bucari-Tovo
af7a4a6acf Add comments 2023-02-19 11:11:28 -07:00
Michael Bucari-Tovo
21d18aa565 Final edits 2023-02-19 10:59:42 -07:00
Michael Bucari-Tovo
c96875ba5d Add '-chardonnay' to build assets name 2023-02-19 10:23:49 -07:00
Michael Bucari-Tovo
6ebbfb8e59 Refactor SetReleaseIdentifier() 2023-02-19 10:20:01 -07:00
Michael Bucari-Tovo
1e6e28cd57 Start downloading asynchronously 2023-02-18 22:38:26 -07:00
Michael Bucari-Tovo
defed72862 Force garbage collection after completing a Processable 2023-02-18 22:16:46 -07:00
Michael Bucari-Tovo
71503b34b5 Fix macOS crash 2023-02-18 20:29:10 -07:00
Michael Bucari-Tovo
a00849fb6f Refactor InteropFactory 2023-02-18 13:57:00 -07:00
Michael Bucari-Tovo
14b63c0883 Add apple UUTYPEs 2023-02-18 10:27:37 -07:00
Michael Bucari-Tovo
59d556733e Edit Mac Build Script 2023-02-17 23:46:28 -07:00
Michael Bucari-Tovo
a99a175683 Update AAXClean to fix #502 2023-02-17 23:20:35 -07:00
Michael Bucari-Tovo
26fedcfb60 Fix DirectorySelectControl not displaying known dir 2023-02-17 22:58:24 -07:00
Michael Bucari-Tovo
dde8024506 More thread safety to address #492 2023-02-17 22:57:43 -07:00
Michael Bucari-Tovo
25f7c29380 New linux build workflows 2023-02-17 18:04:34 -07:00
Robert McRackan
2f347e83e8 fix linux 'can update'. upgrade aaxclean 2023-02-16 07:57:36 -05:00
rmcrackan
080a74884d Update InstallOnMac.md
new mac setup video
2023-02-16 07:44:09 -05:00
Robert McRackan
2dbeb64c38 incr ver. updates for mac and linux 2023-02-15 08:38:13 -05:00
rmcrackan
bb508c0718 Merge pull request #489 from Mbucari/master
Mac App Bundle and added mp3 conversion support on mac
2023-02-15 08:33:06 -05:00
Michael Bucari-Tovo
9a450b0d63 add 'macOS' to mac bundle name 2023-02-15 06:31:09 -07:00
Michael Bucari-Tovo
c1de0e60d2 Hopefully fix #492 2023-02-14 23:07:40 -07:00
Mbucari
dc7c03661d Add auto update to linux and macos 2023-02-14 23:06:14 -07:00
Mbucari
952eee6d32 Merge branch 'rmcrackan:master' into master 2023-02-13 21:42:11 -07:00
Michael Bucari-Tovo
472a0f30b9 Launch hangover from Libation app bundle for mac 2023-02-13 21:40:53 -07:00
Robert McRackan
73533c58a8 update dependencies 2023-02-13 21:14:56 -05:00
Mbucari
65ef018719 Move NameListFormatter to its own class 2023-02-13 10:09:13 -07:00
Mbucari
f0ca349539 Update UNSAFE_MigrationHelper with new appsettings.json getter 2023-02-13 09:03:03 -07:00
Mbucari
500b287721 Fix #490 2023-02-13 08:08:10 -07:00
Mbucari
21f3ae45d3 Delete deb.yml 2023-02-12 22:25:39 -07:00
Michael Bucari-Tovo
d496564f0d Edit Mac and Linux bundle build workflows 2023-02-12 21:50:33 -07:00
Michael Bucari-Tovo
6fdd6293ce Ensure appsettings.json is created in a writable location. 2023-02-12 15:32:51 -07:00
Michael Bucari-Tovo
3bca495521 Add MacOS app bundle workflow 2023-02-11 23:38:17 -07:00
Michael Bucari-Tovo
0fb580f1a5 Ensure appsettings.json is created in a writable location. 2023-02-11 20:06:04 -07:00
Michael Bucari-Tovo
a7cd47e0b1 Update AAXClean 2023-02-11 18:34:07 -07:00
Robert McRackan
30aecedfae incr ver 2023-02-10 23:16:22 -05:00
rmcrackan
e72799efe5 Merge pull request #487 from Mbucari/master
Custom author and narrator names formatting and batch locate books
2023-02-10 23:14:31 -05:00
Michael Bucari-Tovo
ee8c0ae27b Use new .NET regular expression source generators 2023-02-10 19:45:52 -07:00
Mbucari
5b4a4341ad More agressive garbage collection 2023-02-10 15:03:43 -07:00
Mbucari
56823c1105 Move FindAudiobooks() to AudioFileStorage 2023-02-10 14:54:29 -07:00
Mbucari
1f4ada604a Make suggested changes 2023-02-10 14:37:28 -07:00
Mbucari
3a4ab80892 Add human name parsing and formatting to naming templates 2023-02-10 12:53:12 -07:00
Mbucari
bba9c2ba7b Add Locate Audiobooks function (#485) 2023-02-10 09:35:21 -07:00
204 changed files with 6560 additions and 2114 deletions

View File

@@ -1,5 +1,5 @@
# build-linux.yml
# Reusable workflow that builds the Linux and MacOS versions of Libation.
# Reusable workflow that builds the Linux and MacOS (x64 and arm64) versions of Libation.
---
name: build
@@ -19,15 +19,15 @@ on:
env:
DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x'
RELEASE_NAME: 'chardonnay'
jobs:
build:
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [Linux, MacOS]
ui: [Avalonia]
release_name: [chardonnay]
os: [ubuntu-latest, macos-latest]
arch: [x64, arm64]
steps:
- uses: actions/checkout@v3
- name: Setup .NET
@@ -45,37 +45,63 @@ jobs:
then
version="${inputVersion}"
else
version="$(grep -oP '(?<=<Version>).*(?=</Version)' ./Source/AppScaffolding/AppScaffolding.csproj)"
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LibationCli/LibationCli.csproj -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}
os=${{ matrix.os }}
target_os="$(echo ${os/-latest/} | sed 's/ubuntu/linux/')"
display_os="$(echo ${target_os/macos/macOS} | sed 's/linux/Linux/')"
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
RUNTIME_IDENTIFIER="$(echo ${target_os/macos/osx})-${{ matrix.arch }}"
echo "$RUNTIME_IDENTIFIER"
dotnet publish \
LibationAvalonia/LibationAvalonia.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LibationCli/LibationCli.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
HangoverAvalonia/HangoverAvalonia.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
- name: Build bundle
id: bundle
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ matrix.arch }}-${{ env.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 }}"
BUNDLE_DIR=$(pwd)
echo "Bundle dir: ${BUNDLE_DIR}"
cd ..
SCRIPT=../../../Scripts/Bundle_${{ steps.publish.outputs.display_os }}.sh
chmod +rx ${SCRIPT}
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ matrix.arch }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
tar -zcvf "../${artifact}.tar.gz" .
- name: Publish artifact
- name: Publish bundle
uses: actions/upload-artifact@v3
with:
name: ${{ steps.zip.outputs.artifact }}.tar.gz
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.tar.gz
if-no-files-found: error
name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error

View File

@@ -60,21 +60,49 @@ jobs:
- name: Publish
working-directory: ./Source
run: |
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LibationCli/LibationCli.csproj -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LibationCli/LibationCli.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact
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 } }
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
$delfiles = @(
"libmp3lame.x64.so",
"libmp3lame.arm64.so",
"libmp3lame.x64.dylib",
"libmp3lame.arm64.dylib",
"ffmpegaac.x64.so",
"ffmpegaac.arm64.so",
"ffmpegaac.x64.dylib",
"ffmpegaac.arm64.dylib",
"WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json"
)
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${dir}*" -DestinationPath "$artifact.zip"
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
- name: Publish artifact
uses: actions/upload-artifact@v3

View File

@@ -1,38 +0,0 @@
# 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

@@ -33,15 +33,9 @@ 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,deb]
needs: [prerelease,build]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
@@ -53,7 +47,7 @@ jobs:
id: release
uses: softprops/action-gh-release@v1
with:
name: Libation ${{ needs.prerelease.outputs.version }}
name: Libation v${{ needs.prerelease.outputs.version }}
body: <Put a body here>
draft: true
prerelease: false

View File

@@ -1,6 +1,8 @@
{
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
}

21
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
}
]
}

42
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "build",
"dependsOn": [
"build_libation",
"build_linuxconfigapp"
]
},
{
"label": "build_libation",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
"label": "build_linuxconfigapp",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
}
]
}

View File

@@ -1,6 +1,6 @@
## [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)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.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**.

View File

@@ -1,6 +1,6 @@
## [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)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.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**.

View File

@@ -1,6 +1,6 @@
## [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)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.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**.

View File

@@ -1,6 +1,6 @@
## [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)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.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**.

View File

@@ -1,44 +1,43 @@
## [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)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.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**.
# Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac.
## Install Libation
- Download latest MacOS zip to downloads folder
- Extract and rename folder to Libation
- in terminal type cd and then drag your folder of libation to terminal so it looks like `cd/users/YourName/Downloads/Libation`
- Type following commands
- Download the file from the latest release and extract it.
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
- Move the extracted Libation app bundle to your applications folder.
- Open a terminal (Go > Utilities > Terminal)
- Copy/paste/run the following command (you'll be prompted to enter your password)
```Console
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
```
- Close the terminal and use Libation!
```console
chmod +x ./Libation
sudo spctl --add --label "Libation" ./Libation
./Libation
## Running Hangover
Libation comes with a recovery app called Hangover. You can start it by running this command:
```Console
open /Applications/Libation.app --args hangover
```
## Trouble with Gatekeeper?
## Runnign LibationCli
If Gatekeeper is giving you trouble with Libation:
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
```Console
open /Applications/Libation.app --args cli
```
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
Disable the block
`sudo spctl --master-disable`
Launch Libation and login, etc. and allow the rules to update then re-enable the block.
`sudo spctl --master-enable`
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
Then use `./LibationCli` to execute a command.
## Get Libation running on Mac
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/213933357-983d8ede-2738-4b32-9c6e-40de21ff09c2.mp4)
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)

View File

@@ -10,6 +10,7 @@ These templates apply to both GUI and CLI.
- [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters)
- [Name List Formatters](#name-list-formatters)
- [Integer Formatters](#integer-formatters)
- [Date Formatters](#date-formatters)
@@ -26,9 +27,9 @@ These tags will be replaced in the template with the audiobook's values.
|\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title|Text|
|\<title short\>|Title. Stop at first colon|Text|
|\<author\>|Author(s)|Text|
|\<author\>|Author(s)|Name List|
|\<first author\>|First author|Text|
|\<narrator\>|Narrator(s)|Text|
|\<narrator\>|Narrator(s)|Name List|
|\<first narrator\>|First narrator|Text|
|\<series\>|Name of series|Text|
|\<series#\>|Number order in series|Text|
@@ -73,7 +74,7 @@ As an example, this folder template will place all Liberated podcasts into a "Po
# 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**, **Name List**, **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|
@@ -81,12 +82,18 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
## Name List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|sort(F \| M \| L)|Sorts the names by first, middle, or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
## 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.
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
## 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).

View File

@@ -1,6 +1,6 @@
## [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)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.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**.

View File

@@ -0,0 +1,32 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512px" enable-background="new 0 0 512 512">
<path id="slosh" transform=
"translate(-50 23)
scale(0.7, 0.7)
rotate(12 256,256)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
<use href="#slosh" transform="translate(512 0) scale(-1 1)" />
</svg>

After

Width:  |  Height:  |  Size: 736 B

28
Images/libation_glass.svg Normal file
View File

@@ -0,0 +1,28 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
<path id="glass" d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
z" />
<path id="wine-level" d=
"M146,128
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
z"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

View File

@@ -0,0 +1,30 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
<g transform="translate(0 80) rotate(90 256,256)">
<path id="glass" d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
z" />
<path id="wine-level" d=
"M345,44
A 192,184 0 0 1 366,126
A 320,180 55 0 1 345,226
z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 638 B

33
Images/libation_slosh.svg Normal file
View File

@@ -0,0 +1,33 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
<path
transform=
"rotate(15 256,256)
translate(0 25)
scale(0.93, 0.93)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
</svg>

After

Width:  |  Height:  |  Size: 649 B

View File

@@ -2,7 +2,7 @@
## [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)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.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**.
# Table of Contents

146
Scripts/Bundle_Linux.sh Normal file
View File

@@ -0,0 +1,146 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation Linux bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z "$ARCH" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb
FOLDER_EXEC=$DEB_DIR/usr/lib/libation
echo "Exec dir: $FOLDER_EXEC"
mkdir -p $FOLDER_EXEC
echo "Moving bins from $BIN_DIR to $FOLDER_EXEC"
mv "${BIN_DIR}/"* $FOLDER_EXEC
if [ $? -ne 0 ]
then echo "Error moving ${BIN_DIR} files"
exit
fi
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
if [[ "$ARCH" == "arm64" ]]
then
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
else
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
fi
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $FOLDER_EXEC/$n
done
FOLDER_ICON=$DEB_DIR/usr/share/icons/hicolor/scalable/apps/
echo "Icon dir: $FOLDER_ICON"
FOLDER_DESKTOP=$DEB_DIR/usr/share/applications
echo "Desktop dir: $FOLDER_DESKTOP"
FOLDER_DEBIAN=$DEB_DIR/DEBIAN
echo "Debian dir: $FOLDER_DEBIAN"
mkdir -p $FOLDER_ICON
mkdir -p $FOLDER_DESKTOP
mkdir -p $FOLDER_DEBIAN
echo "Copying icon..."
cp $FOLDER_EXEC/libation_glass.svg $FOLDER_ICON/libation.svg
echo "Copying desktop file..."
cp $FOLDER_EXEC/Libation.desktop $FOLDER_DESKTOP/Libation.desktop
echo "Creating pre-install file..."
echo "#!/bin/bash
# Pre-install script, removes previous installation program files and sym links
echo \"Removing previously created symlinks...\"
rm /usr/bin/libation
rm /usr/bin/hangover
rm /usr/bin/libationcli
echo \"Removing previously installed Libation files...\"
rm -r /usr/lib/libation
# making sure it won't stop installation
exit 0
" >> $FOLDER_DEBIAN/preinst
echo "Creating post-install file..."
echo "#!/bin/bash
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
# Increase the maximum number of inotify instances
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
# 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
" >> $FOLDER_DEBIAN/postinst
echo "Creating control file..."
echo "Package: Libation
Version: $VERSION
Architecture: $ARCH
Essential: no
Priority: optional
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
" >> $FOLDER_DEBIAN/control
echo "Changing permissions for pre- and post-install files..."
chmod +x "$FOLDER_DEBIAN/preinst"
chmod +x "$FOLDER_DEBIAN/postinst"
if [ "$(uname -s)" == "Darwin" ]; then
echo "macOS detected, installing dpkg"
brew install dpkg
fi
DEB_FILE=Libation.${VERSION}-linux-chardonnay-${ARCH}.deb
echo "Creating $DEB_FILE"
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
echo "moving to ./bundle/$DEB_FILE"
mkdir bundle
mv $DEB_FILE ./bundle/$DEB_FILE
rm -r "$BIN_DIR"
echo "Done!"

114
Scripts/Bundle_MacOS.sh Normal file
View File

@@ -0,0 +1,114 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation macos bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z $VERSION ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z $ARCH ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" $ARCH
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
BUNDLE=./Libation.app
echo "Bundle dir: $BUNDLE"
if [[ -d $BUNDLE ]]
then
echo "$BUNDLE directory already exists, aborting."
exit
fi
BUNDLE_CONTENTS=$BUNDLE/Contents
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
BUNDLE_RESOURCES=$BUNDLE_CONTENTS/Resources
echo "Resources dir: $BUNDLE_RESOURCES"
BUNDLE_MACOS=$BUNDLE_CONTENTS/MacOS
echo "MacOS dir: $BUNDLE_MACOS"
mkdir -p $BUNDLE_CONTENTS
mkdir -p $BUNDLE_RESOURCES
mkdir -p $BUNDLE_MACOS
mv "${BIN_DIR}/"* $BUNDLE_MACOS
if [ $? -ne 0 ]
then echo "Error moving ${BIN_DIR} files"
exit
fi
echo "Make fileicon executable..."
chmod +x $BUNDLE_MACOS/fileicon
echo "Moving icon..."
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
echo "Set LSArchitecturePriority to $PLIST_ARCH"
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
echo "Set CFBundleVersion to $VERSION"
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
delfiles=( 'libmp3lame.arm64.so' 'libmp3lame.x64.so' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.so' 'ffmpegaac.x64.so' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
if [[ "$ARCH" == "arm64" ]]
then
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
else
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
fi
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $BUNDLE_MACOS/$n
done
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
echo "Signing executables in: $BUNDLE"
codesign --force --deep -s - $BUNDLE
echo "Creating app bundle: $APP_FILE"
tar -czvf $APP_FILE $BUNDLE
mkdir bundle
echo "moving to ./bundle/$APP_FILE"
mv $APP_FILE ./bundle/$APP_FILE
rm -r $BUNDLE
echo "Done!"

View File

@@ -1,136 +0,0 @@
#!/bin/bash
FILE=$1; shift
VERSION=$1; shift
if [ -z "$FILE" ]
then
echo "This script must be called with a the Libation Linux bin zip file as an argument."
exit
fi
if [ ! -f "$FILE" ]
then
echo "The file \"$FILE\" does not exist."
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"
if [[ -d "$FOLDER_MAIN" ]]
then
echo "$FOLDER_MAIN directory already exists, aborting."
exit
fi
FOLDER_EXEC="$FOLDER_MAIN/usr/lib/libation"
echo "Exec dir: $FOLDER_EXEC"
FOLDER_ICON="$FOLDER_MAIN/usr/share/icons/hicolor/scalable/apps/"
echo "Icon dir: $FOLDER_ICON"
FOLDER_DESKTOP="$FOLDER_MAIN/usr/share/applications"
echo "Desktop dir: $FOLDER_DESKTOP"
FOLDER_DEBIAN="$FOLDER_MAIN/DEBIAN"
echo "Debian dir: $FOLDER_DEBIAN"
mkdir -p "$FOLDER_EXEC"
mkdir -p "$FOLDER_ICON"
mkdir -p "$FOLDER_DESKTOP"
mkdir -p "$FOLDER_DEBIAN"
echo "Extracting $FILE to $FOLDER_EXEC..."
tar -xzf ${FILE} -C ${FOLDER_EXEC}
if [ $? -ne 0 ]
then echo "Error extracting ${FILE}"
exit
fi
echo "Copying icon..."
cp "$FOLDER_EXEC/glass-with-glow_256.svg" "$FOLDER_ICON/libation.svg"
echo "Copying desktop file..."
cp "$FOLDER_EXEC/Libation.desktop" "$FOLDER_DESKTOP/Libation.desktop"
echo "Workaround for desktop file..."
sed -i '/^Exec=Libation/c\Exec=/usr/bin/libation' "$FOLDER_DESKTOP/Libation.desktop"
echo "Creating pre-install file..."
echo "#!/bin/bash
# Pre-install script, removes previous installation program files and sym links
echo \"Removing previously created symlinks...\"
rm /usr/bin/libation
rm /usr/bin/Libation
rm /usr/bin/hangover
rm /usr/bin/Hangover
rm /usr/bin/libationcli
rm /usr/bin/LibationCli
echo \"Removing previously installed Libation files...\"
rm -r /usr/lib/libation
rm -r /usr/lib/Libation
# making sure it won't stop installation
exit 0
" >> "$FOLDER_DEBIAN/preinst"
echo "Creating post-install file..."
echo "#!/bin/bash
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
# 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
" >> "$FOLDER_DEBIAN/postinst"
echo "Creating control file..."
echo "Package: Libation
Version: $VERSION
Architecture: all
Essential: no
Priority: optional
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
" >> "$FOLDER_DEBIAN/control"
echo "Changing permissions for pre- and post-install files..."
chmod +x "$FOLDER_DEBIAN/preinst"
chmod +x "$FOLDER_DEBIAN/postinst"
echo "Creating .deb file..."
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.5.12" />
<PackageReference Include="AAXClean.Codecs" Version="1.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -40,8 +40,14 @@ namespace AaxDecrypter
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
}
if (DownloadOptions.FixupFile && !string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
if (DownloadOptions.FixupFile)
{
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("&#169;", "©");
}
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)

View File

@@ -93,15 +93,13 @@ That naming may not be desirable for everyone, but it's an easy change to instea
? AaxFile.ConvertToMultiMp4aAsync
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.TrimOutputToChapterLength
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
)
: AaxFile.ConvertToMultiMp3Async
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.LameConfig,
DownloadOptions.TrimOutputToChapterLength
DownloadOptions.LameConfig
);
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)

View File

@@ -3,6 +3,7 @@ using AAXClean.Codecs;
using Dinah.Core.Net.Http;
using FileManager;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
@@ -14,12 +15,15 @@ namespace AaxDecrypter
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
{
var step = 1;
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;
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
}
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
@@ -31,18 +35,7 @@ namespace AaxDecrypter
try
{
await (AaxConversion = 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;
}
await (AaxConversion = decryptAsync(outputFile));
return AaxConversion.IsCompletedSuccessfully;
}
@@ -52,23 +45,30 @@ namespace AaxDecrypter
}
}
private async Task<bool> Step_MoveMoov()
{
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
return AaxConversion.IsCompletedSuccessfully;
}
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
var remainingTimeToProcess = (e.TotalDuration - e.ProcessPosition).TotalSeconds;
var remainingTimeToProcess = (e.EndTime - 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),
ProgressPercentage = 100 * e.FractionCompleted,
BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted),
TotalBytesToReceive = InputFileStream.Length
});
}
@@ -79,15 +79,13 @@ namespace AaxDecrypter
(
outputFile,
DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
DownloadOptions.ChapterInfo
)
: DownloadOptions.FixupFile
? AaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
DownloadOptions.ChapterInfo
)
: AaxFile.ConvertToMp4aAsync(outputFile);
}

View File

@@ -26,6 +26,7 @@ namespace AaxDecrypter
protected IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position;
private bool downloadFinished;
private readonly NetworkFileStreamPersister nfsPersister;
private readonly DownloadProgress zeroProgress;
@@ -58,7 +59,7 @@ namespace AaxDecrypter
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = InputFileStream.Length
TotalBytesToReceive = 0
};
OnDecryptProgressUpdate(zeroProgress);
@@ -66,6 +67,7 @@ namespace AaxDecrypter
public async Task<bool> RunAsync()
{
await InputFileStream.BeginDownloadingAsync();
var progressTask = Task.Run(reportProgress);
AsyncSteps[$"Cleanup"] = CleanupAsync;
@@ -82,7 +84,11 @@ namespace AaxDecrypter
{
AverageSpeed averageSpeed = new();
while (InputFileStream.CanRead && InputFileStream.Length > InputFilePosition && !InputFileStream.IsCancelled)
while (
InputFileStream.CanRead
&& InputFileStream.Length > InputFilePosition
&& !InputFileStream.IsCancelled
&& !downloadFinished)
{
averageSpeed.AddPosition(InputFilePosition);
@@ -136,8 +142,7 @@ namespace AaxDecrypter
protected virtual void FinalizeDownload()
{
nfsPersister?.Dispose();
OnDecryptTimeRemaining(TimeSpan.Zero);
OnDecryptProgressUpdate(zeroProgress);
downloadFinished = true;
}
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
@@ -219,6 +224,7 @@ namespace AaxDecrypter
}
finally
{
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
}

View File

@@ -1,5 +1,6 @@
using AAXClean;
using NAudio.Lame;
using System;
namespace AaxDecrypter
{
@@ -9,17 +10,26 @@ namespace AaxDecrypter
{
double bitrateMultiple = 1;
if (mp4File.TimeScale < lameConfig.OutputSampleRate)
{
lameConfig.OutputSampleRate = mp4File.TimeScale;
}
else if (mp4File.TimeScale > lameConfig.OutputSampleRate)
{
bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale;
}
if (mp4File.AudioChannels == 2)
{
if (downsample)
bitrateMultiple = 0.5;
bitrateMultiple /= 2;
else
lameConfig.Mode = MPEGMode.Stereo;
}
if (matchSourceBitrate)
{
int kbps = (int)(mp4File.AverageBitrate * bitrateMultiple / 1024);
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
if (lameConfig.VBR is null)
lameConfig.BitRate = kbps;

View File

@@ -14,6 +14,7 @@ namespace AaxDecrypter
public class NetworkFileStream : Stream, IUpdatable
{
public event EventHandler Updated;
public event EventHandler DownloadCompleted;
#region Public Properties
@@ -136,10 +137,13 @@ namespace AaxDecrypter
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
/// <returns>The downloader <see cref="Task"/></returns>
private Task BeginDownloading()
public async Task BeginDownloadingAsync()
{
if (ContentLength != 0 && WritePosition == ContentLength)
return Task.CompletedTask;
{
_backgroundDownloadTask = Task.CompletedTask;
return;
}
if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
@@ -149,7 +153,7 @@ namespace AaxDecrypter
foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value);
var response = new HttpClient().Send(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
var response = await new HttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
@@ -159,11 +163,11 @@ namespace AaxDecrypter
if (WritePosition == 0)
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
var networkStream = response.Content.ReadAsStream(_cancellationSource.Token);
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
return Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
_backgroundDownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
}
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
@@ -230,6 +234,7 @@ namespace AaxDecrypter
_writeFile.Close();
_downloadedPiece.Set();
OnUpdate();
DownloadCompleted?.Invoke(this, null);
}
}
@@ -251,7 +256,8 @@ namespace AaxDecrypter
{
get
{
_backgroundDownloadTask ??= BeginDownloading();
if (_backgroundDownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
return ContentLength;
}
}
@@ -274,7 +280,8 @@ namespace AaxDecrypter
public override int Read(byte[] buffer, int offset, int count)
{
_backgroundDownloadTask ??= BeginDownloading();
if (_backgroundDownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);

View File

@@ -26,9 +26,11 @@ namespace AaxDecrypter
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
// MUST put InputFileStream.Length first, because it starts background downloader.
while (InputFileStream.Length > InputFilePosition && !InputFileStream.IsCancelled)
await Task.Delay(200);
TaskCompletionSource completionSource = new();
InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
await completionSource.Task;
if (IsCanceled)
return false;

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>9.2.3.1</Version>
<Version>9.4.4.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="5.0.0" />

View File

@@ -9,7 +9,7 @@ using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using System.Runtime.InteropServices;
using Newtonsoft.Json.Linq;
using Serilog;
@@ -18,14 +18,22 @@ namespace AppScaffolding
public enum ReleaseIdentifier
{
None,
WindowsClassic,
WindowsAvalonia,
LinuxAvalonia,
MacOSAvalonia
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64
}
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
public enum VarietyType { None, Classic, Chardonnay }
[Flags]
public enum Variety
{
None,
Classic = 0x10000,
Chardonnay = 0x20000,
}
public static class LibationScaffolding
{
@@ -33,13 +41,22 @@ namespace AppScaffolding
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static VarietyType Variety
=> ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic
: ReleaseIdentifier.In(ReleaseIdentifier.WindowsAvalonia, ReleaseIdentifier.LinuxAvalonia, ReleaseIdentifier.MacOSAvalonia) ? VarietyType.Chardonnay
: VarietyType.None;
public static Variety Variety { get; private set; }
public static void SetReleaseIdentifier(ReleaseIdentifier releaseID)
=> ReleaseIdentifier = releaseID;
public static void SetReleaseIdentifier(Variety varietyType)
{
Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
if (Enum.IsDefined(releaseID))
ReleaseIdentifier = releaseID;
else
{
ReleaseIdentifier = ReleaseIdentifier.None;
Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
}
}
// AppScaffolding
private static Assembly _executingAssembly;
@@ -58,13 +75,15 @@ namespace AppScaffolding
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
.Max(a => a.Version);
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
{
// must occur before access to Configuration instance
// // outdated. kept here as an example of what belongs in this area
// // Migrations.migrate_to_v5_2_0__pre_config();
Configuration.SetLibationVersion(BuildVersion);
//***********************************************//
// //
// do not use Configuration before this line //
@@ -296,8 +315,8 @@ namespace AppScaffolding
}
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
{
var ownerAccount = "rmcrackan";
var repoName = "Libation";
const string ownerAccount = "rmcrackan";
const string repoName = "Libation";
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
@@ -305,12 +324,11 @@ namespace AppScaffolding
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
// https://octokitnet.readthedocs.io/en/latest/releases/
var releases = await gitHubClient.Repository.Release.GetAll(ownerAccount, repoName);
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var latestRelease = releases.FirstOrDefault(r => !r.Draft && !r.Prerelease && r.Assets.Any(a => regex.IsMatch(a.Name)));
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
}
}

View File

@@ -1,29 +0,0 @@
using System;
namespace AppScaffolding
{
public abstract class OSConfigBase
{
public abstract Type InteropFunctionsType { get; }
public virtual Type[] ReferencedTypes { get; } = new Type[0];
public void Run()
{
//Each of these types belongs to a different windows-only assembly that's needed by
//the WinInterop methods. By referencing these types in main we force the runtime to
//load their assemblies before execution reaches inside main. This allows the calling
//process to find these assemblies in its module list.
_ = ReferencedTypes;
_ = InteropFunctionsType;
//Wait for the calling process to be ready to read the WriteLine()
Console.ReadLine();
// Signal the calling process that execution has reached inside main, and that all referenced assemblies have been loaded.
Console.WriteLine();
// Wait for the calling process to finish reading the process module list, then exit.
Console.ReadLine();
}
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;
using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -25,9 +26,6 @@ namespace AppScaffolding
: value;
#region appsettings.json
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json");
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
public static bool APPSETTINGS_TryGet(string key, out string value)
{
@@ -61,11 +59,7 @@ namespace AppScaffolding
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!APPSETTINGS_Json_Exists)
return;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
JObject jObj;
try
@@ -88,7 +82,7 @@ namespace AppScaffolding
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(APPSETTINGS_JSON, endingContents_indented);
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion

View File

@@ -1,11 +1,11 @@
using System;
using NPOI.XWPF.UserModel;
using System;
using System.Text.RegularExpressions;
namespace AppScaffolding
{
public record UpgradeProperties
public partial record UpgradeProperties
{
private static readonly Regex linkstripper = new Regex(@"\[(.*)\]\(.*\)");
public string ZipUrl { get; }
public string HtmlUrl { get; }
public string ZipName { get; }
@@ -18,17 +18,10 @@ namespace AppScaffolding
HtmlUrl = htmlUrl;
ZipUrl = zipUrl;
LatestRelease = latestRelease;
Notes = stripMarkdownLinks(notes);
Notes = LinkStripRegex().Replace(notes, "$1");
}
private string stripMarkdownLinks(string body)
{
body = body.Replace(@"\", "");
var matches = linkstripper.Matches(body);
foreach (Match match in matches)
body = body.Replace(match.Groups[0].Value, match.Groups[1].Value);
return body;
}
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
private static partial Regex LinkStripRegex();
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AudibleApi;
using AudibleUtilities;
@@ -242,18 +243,16 @@ namespace ApplicationServices
#endregion
#region remove/restore books
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
private static int removeBooks(List<string> idsToRemove)
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
{
try
{
if (idsToRemove is null || !idsToRemove.Any())
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
@@ -275,7 +274,7 @@ namespace ApplicationServices
}
}
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
try
{
@@ -303,6 +302,31 @@ namespace ApplicationServices
throw;
}
}
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
context.LibraryBooks.RemoveRange(libraryBooks);
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
#endregion
// call this whenever books are added or removed from library
@@ -346,8 +370,10 @@ namespace ApplicationServices
if (rating is not null)
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
});
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus, Version libationVersion)
=> book.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
@@ -428,40 +454,74 @@ namespace ApplicationServices
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
}
public static LibraryStats GetCounts()
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError + booksUnavailable);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded + pdfsUnavailable);
public string StatusString => HasPdfResults ? $"{toBookStatusString()} | {toPdfStatusString()}" : toBookStatusString();
private string toBookStatusString()
{
if (!HasBookResults) return "No books. Begin by importing your library";
if (!HasPendingBooks && booksError + booksUnavailable == 0) return $"All {"book".PluralizeWithCount(booksFullyBackedUp)} backed up";
var sb = new StringBuilder($"BACKUPS: No progress: {booksNoProgress} In process: {booksDownloadedOnly} Fully backed up: {booksFullyBackedUp}");
if (booksError > 0)
sb.Append($" Errors: {booksError}");
if (booksUnavailable > 0)
sb.Append($" Unavailable: {booksUnavailable}");
return sb.ToString();
}
private string toPdfStatusString()
{
if (pdfsNotDownloaded + pdfsUnavailable == 0) return $"All {pdfsDownloaded} PDFs downloaded";
var sb = new StringBuilder($"PDFs: NOT d/l'ed: {pdfsNotDownloaded} Downloaded: {pdfsDownloaded}");
if (pdfsUnavailable > 0)
sb.Append($" Unavailable: {pdfsUnavailable}");
return sb.ToString();
}
}
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => !r.absent && r.status == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
var boolResults = libraryBooks
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
var pdfResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
}
}
}

View File

@@ -106,6 +106,12 @@ namespace ApplicationServices
[Name("Language")]
public string Language { get; set; }
[Name("LastDownloaded")]
public DateTime? LastDownloaded { get; set; }
[Name("LastDownloadedVersion")]
public string LastDownloadedVersion { get; set; }
}
public static class LibToDtos
{
@@ -140,7 +146,10 @@ namespace ApplicationServices
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
AudioFormat = a.Book.AudioFormat.ToString(),
Language = a.Book.Language
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
}).ToList();
}
public static class LibraryExporter
@@ -212,7 +221,9 @@ namespace ApplicationServices
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language)
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
};
var col = 0;
foreach (var c in columns)
@@ -238,9 +249,9 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.Account);
var dateAddedCell = row.CreateCell(col++);
dateAddedCell.CellStyle = dateStyle;
dateAddedCell.SetCellValue(dto.DateAdded);
var dateCell = row.CreateCell(col++);
dateCell.CellStyle = dateStyle;
dateCell.SetCellValue(dto.DateAdded);
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
row.CreateCell(col++).SetCellValue(dto.Locale);
@@ -281,6 +292,15 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language);
if (dto.LastDownloaded.HasValue)
{
dateCell = row.CreateCell(col);
dateCell.CellStyle = dateStyle;
dateCell.SetCellValue(dto.LastDownloaded.Value);
}
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
rowIndex++;
}

View File

@@ -2,8 +2,9 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Diagnostics;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
@@ -19,6 +20,9 @@ namespace AudibleUtilities
{
public Api Api { get; private set; }
private const int MaxConcurrency = 10;
private const int BatchSize = 50;
private ApiExtended(Api api) => Api = api;
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
@@ -39,42 +43,6 @@ namespace AudibleUtilities
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginCallback loginCallback)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginCallback),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginCallback,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with external browser</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginExternal loginExternal)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginExternal),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginExternal,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(Account account)
{
@@ -121,35 +89,51 @@ namespace AudibleUtilities
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
{
var items = new List<Item>();
Serilog.Log.Logger.Debug("Beginning library scan.");
List<Task<List<Item>>> getChildEpisodesTasks = new();
int count = 0;
List<Item> items = new();
List<Item> seriesItems = new();
int count = 0, maxConcurrentEpisodeScans = 5;
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
var sw = Stopwatch.StartNew();
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
//Scan the library for all added books, and add any episode-type items to seriesItems to be scanned for episodes/parents
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions, BatchSize, MaxConcurrency))
{
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
{
//Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
}
seriesItems.Add(item);
else if (!item.IsEpisodes && !item.IsSeriesParent)
items.Add(item);
count++;
}
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on series episode scans to complete.", count);
Serilog.Log.Logger.Debug("Beginning episode scan.");
//await and add all episodes from all parents
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
items.AddRange(epList);
count = 0;
Serilog.Log.Logger.Debug("Completed library scan.");
//'get' Tasks are activated when they are written to the channel. To avoid more concurrency than is desired, the
//channel is bounded with a capacity of 1. Channel write operations are blocked until the current item is read
var episodeChannel = Channel.CreateBounded<Task<List<Item>>>(new BoundedChannelOptions(1) { SingleReader = true });
//Start scanning for all episodes. Episode batch 'get' Tasks are written to the channel.
var scanAllSeriesTask = scanAllSeries(seriesItems, episodeChannel.Writer);
//Read all episodes from the channel and add them to the import items.
//This method blocks until episodeChannel.Writer is closed by scanAllSeries()
await foreach (var ep in getAllEpisodesAsync(episodeChannel.Reader))
{
items.AddRange(ep);
count += ep.Count;
}
//Be sure to await the scanAllSeries Task so that any exceptions are thrown
await scanAllSeriesTask;
sw.Stop();
Serilog.Log.Logger.Debug("Episode scan complete. Found {count} episodes and series.", count);
Serilog.Log.Logger.Debug($"Completed library scan in {sw.Elapsed.TotalMilliseconds:F0} ms.");
#if DEBUG
//// this will not work for multi accounts
@@ -182,165 +166,178 @@ namespace AudibleUtilities
#region episodes and podcasts
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
/// <summary>
/// Read get tasks from the <paramref name="channel"/> and await results. This method maintains
/// a list of up to <see cref="MaxConcurrency"/> get tasks. When any of the get tasks completes,
/// the Items are yielded, that task is removed from the list, and a new get task is read from
/// the channel.
/// </summary>
private async IAsyncEnumerable<List<Item>> getAllEpisodesAsync(ChannelReader<Task<List<Item>>> channel)
{
await concurrencySemaphore.WaitAsync();
List<Task<List<Item>>> concurentGets = new();
for (int i = 0; i < MaxConcurrency && await channel.WaitToReadAsync(); i++)
concurentGets.Add(await channel.ReadAsync());
while (concurentGets.Count > 0)
{
var completed = await Task.WhenAny(concurentGets);
concurentGets.Remove(completed);
if (await channel.WaitToReadAsync())
concurentGets.Add(await channel.ReadAsync());
yield return completed.Result;
}
}
/// <summary>
/// Gets all child episodes and episode parents belonging to <paramref name="seriesItems"/> in batches and
/// writes the get tasks to <paramref name="channel"/>.
/// </summary>
private async Task scanAllSeries(IEnumerable<Item> seriesItems, ChannelWriter<Task<List<Item>>> channel)
{
try
{
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
List<Task> episodeScanTasks = new();
List<Item> children;
if (parent.IsEpisodes)
foreach (var item in seriesItems)
{
//The 'parent' is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
if (item.IsEpisodes)
await channel.WriteAsync(getEpisodeParentAsync(item));
else if (item.IsSeriesParent)
episodeScanTasks.Add(getParentEpisodesAsync(item, channel));
}
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
//episodeScanTasks complete only after all episode batch 'gets' have been written to the channel
await Task.WhenAll(episodeScanTasks);
}
finally { channel.Complete(); }
}
children = new() { parent };
private async Task<List<Item>> getEpisodeParentAsync(Item episode)
{
//Item is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
var parentAsins = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(p => p.Asin);
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", episode);
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
List<Item> children = new() { episode };
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
if (numSeriesParents != 1)
{
//There should only ever be 1 top-level parent per episode. If not, log
//so we can figure out what to do about those special cases, and don't
//import the episode.
JsonSerializerSettings Settings = new()
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters =
{
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
},
};
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
return new List<Item>();
var parentAsins = episode.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(p => p.Asin);
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
if (numSeriesParents != 1)
{
//There should only ever be 1 top-level parent per episode. If not, log
//so we can figure out what to do about those special cases, and don't
//import the episode.
JsonSerializerSettings Settings = new()
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters = {
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
}
};
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {episode.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(episode, Formatting.None, Settings)}");
return new();
}
var realParent = seriesParents.Single(p => p.IsSeriesParent);
realParent.PurchaseDate = parent.PurchaseDate;
var parent = seriesParents.Single(p => p.IsSeriesParent);
parent.PurchaseDate = episode.PurchaseDate;
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
parent = realParent;
}
else
setSeries(parent, children);
children.Add(parent);
Serilog.Log.Logger.Debug("Completed parent scan for {episode}", episode);
return children;
}
/// <summary>
/// Gets all episodes belonging to <paramref name="parent"/> in batches of <see cref="BatchSize"/> and writes the batch get tasks to <paramref name="channel"/>
/// This method only completes after all episode batch 'gets' have been written to the channel
/// </summary>
private async Task getParentEpisodesAsync(Item parent, ChannelWriter<Task<List<Item>>> channel)
{
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
var episodeIds = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin);
for (int batchNum = 0; episodeIds.Any(); batchNum++)
{
var batch = episodeIds.Take(BatchSize);
await channel.WriteAsync(getEpisodeBatchAsync(batchNum, parent, batch));
episodeIds = episodeIds.Skip(BatchSize);
}
}
private async Task<List<Item>> getEpisodeBatchAsync(int batchNum, Item parent, IEnumerable<string> childrenIds)
{
try
{
List<Item> episodeBatch = await Api.GetCatalogProductsAsync(childrenIds, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
setSeries(parent, episodeBatch);
if (batchNum == 0)
episodeBatch.Add(parent);
Serilog.Log.Logger.Debug($"Batch {batchNum}: {episodeBatch.Count} results\t({{parent}})", parent);
return episodeBatch;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
{
children = await getEpisodeChildrenAsync(parent);
if (!children.Any())
return new();
}
ParentId = parent.Asin,
ParentTitle = parent.Title,
BatchNumber = batchNum,
ChildIdBatch = childrenIds
});
throw;
}
}
//A series parent will always have exactly 1 Series
parent.Series = new Series[]
private static void setSeries(Item parent, IEnumerable<Item> children)
{
//A series parent will always have exactly 1 Series
parent.Series = new[]
{
new Series
{
Asin = parent.Asin,
Sequence = "-1",
Title = parent.TitleWithSubtitle
}
};
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new[]
{
new Series
{
Asin = parent.Asin,
Sequence = "-1",
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new Series[]
{
new Series
{
Asin = parent.Asin,
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
}
children.Add(parent);
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return children;
}
finally
{
concurrencySemaphore.Release();
}
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
{
var childrenIds = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin)
.ToList();
// fetch children in batches
const int batchSize = 20;
var results = new List<Item>();
for (var i = 1; ; i++)
{
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
if (!idBatch.Any())
break;
List<Item> childrenBatch;
try
{
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
#if DEBUG
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
#endif
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
BatchNumber = i,
ChildIdBatch = idBatch
});
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
// the service returned no results. probably indicates an error. stop running batches
if (!childrenBatch.Any())
break;
results.AddRange(childrenBatch);
}
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
ChildCount = childrenIds.Count
});
if (childrenIds.Count != results.Count)
{
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
throw ex;
}
return results;
}
#endregion
}

View File

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

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -178,7 +179,7 @@ namespace AudibleUtilities
LocaleCode = account.Locale.CountryCode,
RefreshToken = account.IdentityTokens.RefreshToken.Value,
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()),
WebsiteCookies = new(account.IdentityTokens.Cookies),
};
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;
namespace DataLayer.Configurations
{
@@ -19,40 +20,45 @@ namespace DataLayer.Configurations
entity.Ignore(nameof(Book.Authors));
entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
//// these don't seem to matter
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));
//entity.Ignore(nameof(Book.HasPdfs));
//// these don't seem to matter
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));
//entity.Ignore(nameof(Book.HasPdfs));
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
entity
.OwnsMany(b => b.Supplements, b_s =>
{
b_s.WithOwner(s => s.Book)
.HasForeignKey(s => s.BookId);
b_s.HasKey(s => s.SupplementId);
});
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
entity
.OwnsMany(b => b.Supplements, b_s =>
{
b_s.WithOwner(s => s.Book)
.HasForeignKey(s => s.BookId);
b_s.HasKey(s => s.SupplementId);
});
// even though it's owned, we need to map its backing field
entity
.Metadata
.FindNavigation(nameof(Book.Supplements))
.SetPropertyAccessMode(PropertyAccessMode.Field);
// owns it 1:1, store in separate table
entity
.OwnsOne(b => b.UserDefinedItem, b_udi =>
{
b_udi.WithOwner(udi => udi.Book)
.HasForeignKey(udi => udi.BookId);
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
b_udi.ToTable(nameof(Book.UserDefinedItem));
// owns it 1:1, store in separate table
entity
.OwnsOne(b => b.UserDefinedItem, b_udi =>
{
b_udi.WithOwner(udi => udi.Book)
.HasForeignKey(udi => udi.BookId);
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
b_udi.ToTable(nameof(Book.UserDefinedItem));
// owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating);
});
b_udi.Property(udi => udi.LastDownloaded);
b_udi
.Property(udi => udi.LastDownloadedVersion)
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
entity
// owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating);
});
entity
.Metadata
.FindNavigation(nameof(Book.ContributorsLink))
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
@@ -68,6 +74,6 @@ namespace DataLayer.Configurations
.HasOne(b => b.Category)
.WithMany()
.HasForeignKey(b => b.CategoryId);
}
}
}
}

View File

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

View File

@@ -12,6 +12,7 @@ namespace DataLayer
public string Account { get; private set; }
public bool IsDeleted { get; set; }
public bool AbsentFromLastScan { get; set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)

View File

@@ -20,12 +20,31 @@ namespace DataLayer
PartialDownload = 0x1000
}
public class UserDefinedItem
public partial class UserDefinedItem
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
public DateTime? LastDownloaded { get; private set; }
public Version LastDownloadedVersion { get; private set; }
private UserDefinedItem() { }
public void SetLastDownloaded(Version version)
{
if (LastDownloadedVersion != version)
{
LastDownloadedVersion = version;
OnItemChanged(nameof(LastDownloadedVersion));
}
if (version is null)
LastDownloaded = null;
else
{
LastDownloaded = DateTime.Now;
OnItemChanged(nameof(LastDownloaded));
}
}
private UserDefinedItem() { }
internal UserDefinedItem(Book book)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
@@ -51,18 +70,23 @@ namespace DataLayer
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
private static string sanitize(string input)
/// <summary>
/// only legal chars are letters numbers underscores and separating whitespace
///
/// technically, the only char.s which aren't easily supported are \ [ ]
/// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
/// it's easy to expand whitelist as needed
/// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
///
/// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
/// full list of characters which must be escaped:
/// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
/// </summary>
[GeneratedRegex(@"[^\w\d\s_]")]
private static partial Regex IllegalCharacterRegex();
private static string sanitize(string input)
{
if (string.IsNullOrWhiteSpace(input))
return "";
@@ -73,9 +97,9 @@ namespace DataLayer
// assume a hyphen is supposed to be an underscore
.Replace("-", "_");
var unique = regex
// turn illegal characters into a space. this will also take care of turning new lines into spaces
.Replace(str, " ")
var unique = IllegalCharacterRegex()
// turn illegal characters into a space. this will also take care of turning new lines into spaces
.Replace(str, " ")
// split and remove excess spaces
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
// de-dup

View File

@@ -0,0 +1,410 @@
// <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("20230302220539_AddLastDownloadedInfo")]
partial class AddLastDownloadedInfo
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
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<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("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,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddLastDownloadedInfo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastDownloaded",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastDownloadedVersion",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastDownloaded",
table: "UserDefinedItem");
migrationBuilder.DropColumn(
name: "LastDownloadedVersion",
table: "UserDefinedItem");
}
}
}

View File

@@ -0,0 +1,413 @@
// <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("20230308013410_AddAbsentFromLastScan")]
partial class AddAbsentFromLastScan
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
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<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("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<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddAbsentFromLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AbsentFromLastScan",
table: "LibraryBooks",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AbsentFromLastScan",
table: "LibraryBooks");
}
}
}

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.2");
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
modelBuilder.Entity("DataLayer.Book", b =>
{
@@ -157,6 +157,9 @@ namespace DataLayer.Migrations
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
@@ -272,6 +275,12 @@ namespace DataLayer.Migrations
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");

View File

@@ -107,8 +107,9 @@ namespace DataLayer
=> bookList
.Where(
lb =>
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
!lb.AbsentFromLastScan &&
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
);
}
}

View File

@@ -8,5 +8,7 @@ namespace DtoImporterService
public Item DtoItem { get; set; }
public string AccountId { get; set; }
public string LocaleName { get; set; }
public override string ToString()
=> DtoItem is null ? base.ToString() : $"[{DtoItem.ProductId}] {DtoItem.Title}";
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
namespace DtoImporterService
@@ -40,9 +41,8 @@ namespace DtoImporterService
//
// CURRENT SOLUTION: don't re-insert
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
var newItems = importItems
.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId))
.ExceptBy(DbContext.LibraryBooks.Select(lb => lb.Book.AudibleProductId), imp => imp.DtoItem.ProductId)
.ToList();
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
@@ -55,7 +55,11 @@ namespace DtoImporterService
var libraryBook = new LibraryBook(
bookImporter.Cache[newItem.DtoItem.ProductId],
newItem.DtoItem.DateAdded,
newItem.AccountId);
newItem.AccountId)
{
AbsentFromLastScan = isPlusTitleUnavailable(newItem)
};
try
{
DbContext.LibraryBooks.Add(libraryBook);
@@ -66,8 +70,30 @@ namespace DtoImporterService
}
}
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null))
nullBook.AbsentFromLastScan = true;
//Join importItems on LibraryBooks before iterating over LibraryBooks to avoid
//quadratic complexity caused by searching all of importItems for each LibraryBook.
//Join uses hashing, so complexity should approach O(N) instead of O(N^2).
var items_lbs
= importItems
.Join(DbContext.LibraryBooks, o => (o.AccountId, o.DtoItem.ProductId), i => (i.Account, i.Book?.AudibleProductId), (o, i) => (o, i));
foreach ((ImportItem item, LibraryBook lb) in items_lbs)
lb.AbsentFromLastScan = isPlusTitleUnavailable(item);
var qtyNew = hash.Count;
return qtyNew;
}
//This SEEMS to work to detect plus titles which are no longer available.
//I have my doubts it won't yield false negatives, but I have more
//confidence that it won't yield many/any false positives.
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.IsAyce is true
&& item.DtoItem.Plans?.Any(p => p.PlanName.ContainsInsensitive("Minerva") || p.PlanName.ContainsInsensitive("Free")) is not true;
}
}

View File

@@ -17,10 +17,14 @@ namespace FileLiberator
protected LameConfig GetLameOptions(Configuration config)
{
LameConfig lameConfig = new();
lameConfig.Mode = MPEGMode.Mono;
LameConfig lameConfig = new()
{
Mode = MPEGMode.Mono,
Quality = config.LameEncoderQuality,
OutputSampleRate = (int)config.MaxSampleRate
};
if (config.LameTargetBitrate)
if (config.LameTargetBitrate)
{
if (config.LameConstantBitrate)
lameConfig.BitRate = config.LameBitrate;

View File

@@ -103,20 +103,20 @@ namespace FileLiberator
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
var remainingTimeToProcess = (e.TotalDuration - e.ProcessPosition).TotalSeconds;
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
if (double.IsNormal(estTimeRemaining))
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / e.TotalDuration.TotalSeconds;
double progressPercent = 100 * e.FractionCompleted;
OnStreamingProgressChanged(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)e.ProcessPosition.TotalSeconds,
TotalBytesToReceive = (long)e.TotalDuration.TotalSeconds
BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds,
TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds
});
}
}

View File

@@ -41,7 +41,7 @@ namespace FileLiberator
OnBegin(libraryBook);
try
try
{
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
@@ -61,31 +61,30 @@ namespace FileLiberator
}
// decrypt failed
if (!success)
if (!success || getFirstAudioFile(entries) == default)
{
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
FileUtility.SaferDelete(tmpFile.Path);
await Task.WhenAll(
entries
.Where(f => f.FileType != FileType.AAXC)
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
return abDownloader?.IsCanceled == true ?
new StatusHandler { "Cancelled" } :
new StatusHandler { "Decrypt failed" };
return
abDownloader?.IsCanceled is true
? new StatusHandler { "Cancelled" }
: new StatusHandler { "Decrypt failed" };
}
// moves new files from temp dir to final dest.
// This could take a few seconds if moving hundreds of files.
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
var finalStorageDir = getDestinationDirectory(libraryBook);
// decrypt failed
if (finalStorageDir is null)
return new StatusHandler { "Cannot find final audio file after decryption" };
Task[] finalTasks = new[]
{
Task.Run(() => downloadCoverArt(libraryBook)),
Task.Run(() => moveFilesToBooksDir(libraryBook, entries)),
Task.Run(() => libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)),
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
};
if (Configuration.Instance.DownloadCoverArt)
downloadCoverArt(libraryBook);
// contains logic to check for config setting and OS
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
await Task.WhenAll(finalTasks);
return new StatusHandler();
}
@@ -131,8 +130,8 @@ namespace FileLiberator
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
return await abDownloader.RunAsync();
// REAL WORK DONE HERE
return await abDownloader.RunAsync();
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
@@ -335,18 +334,12 @@ namespace FileLiberator
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
{
// create final directory. move each file into it
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
Directory.CreateDirectory(destinationDir);
var destinationDir = getDestinationDirectory(libraryBook);
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
if (getFirstAudio() == default)
return null;
for (var i = 0; i < entries.Count; i++)
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
@@ -357,22 +350,33 @@ namespace FileLiberator
entries[i] = entry with { Path = realDest };
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
AudibleFileStorage.Audio.Refresh();
return destinationDir;
}
private static void downloadCoverArt(LibraryBook libraryBook)
private static string getDestinationDirectory(LibraryBook libraryBook)
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
if (!Directory.Exists(destinationDir))
Directory.CreateDirectory(destinationDir);
return destinationDir;
}
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private static void downloadCoverArt(LibraryBook libraryBook)
{
if (!Configuration.Instance.DownloadCoverArt) return;
var coverPath = "[null]";
try
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
var destinationDir = getDestinationDirectory(libraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));

View File

@@ -22,7 +22,7 @@ namespace FileLiberator
public OutputFormat OutputFormat { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public NAudio.Lame.LameConfig LameConfig { get; init; }
public string UserAgent => AudibleApi.Resources.USER_AGENT;
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
public bool CreateCueSheet => config.CreateCueSheet;

View File

@@ -54,6 +54,8 @@ namespace FileLiberator
= (await ProcessAsync(libraryBook))
?? new StatusHandler { "Processable should never return a null status" };
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
return status;
}

View File

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

View File

@@ -21,13 +21,7 @@
</PropertyGroup>
<PropertyGroup>
<!--
HACK FOR COMPILER BUG 2021-09-14. Hopefully will be fixed in future versions
- Not using SatelliteResourceLanguages will load all language packs: works
- Specifying 'en' semicolon 1 more should load 1 language pack: works
- Specifying only 'en' should load no language packs: broken, still loads all
-->
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -17,7 +17,7 @@ namespace HangoverAvalonia.ViewModels
private void Load_databaseVM()
{
_tab = new(new(() => SqlQuery, s => SqlResults = s, s => SqlResults = s));
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)

View File

@@ -4,6 +4,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AssemblyName>Hangover</AssemblyName>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>hangover.ico</ApplicationIcon>
<ImplicitUsings>enable</ImplicitUsings>
@@ -15,13 +16,7 @@
</PropertyGroup>
<PropertyGroup>
<!--
HACK FOR COMPILER BUG 2021-09-14. Hopefully will be fixed in future versions
- Not using SatelliteResourceLanguages will load all language packs: works
- Specifying 'en' semicolon 1 more should load 1 language pack: works
- Specifying only 'en' should load no language packs: broken, still loads all
-->
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<!--

View File

@@ -11,12 +11,13 @@ using System.Threading.Tasks;
using System.Collections.Generic;
using System.IO;
using ApplicationServices;
using Dinah.Core;
using Avalonia.Controls;
namespace LibationAvalonia
{
public class App : Application
{
public static Window MainWindow { get;private set; }
public static IBrush ProcessQueueBookFailedBrush { get; private set; }
public static IBrush ProcessQueueBookCompletedBrush { get; private set; }
public static IBrush ProcessQueueBookCancelledBrush { get; private set; }
@@ -53,7 +54,7 @@ namespace LibationAvalonia
// check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
if (Configuration.SettingsFileIsValid(defaultSettingsFile))
config.SetLibationFiles(defaultLibationFilesDir);
Configuration.SetLibationFiles(defaultLibationFilesDir);
if (config.LibationSettingsAreValid)
{
@@ -86,7 +87,7 @@ namespace LibationAvalonia
// - error message, Exit()
if (setupDialog.IsNewUser)
{
setupDialog.Config.SetLibationFiles(Configuration.UserProfile);
Configuration.SetLibationFiles(Configuration.UserProfile);
ShowSettingsWindow(desktop, setupDialog.Config, OnSettingsCompleted);
}
else if (setupDialog.IsReturningUser)
@@ -178,7 +179,7 @@ namespace LibationAvalonia
private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config)
{
config.SetLibationFiles(libationFilesDialog.SelectedDirectory);
Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);
@@ -214,7 +215,7 @@ namespace LibationAvalonia
private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
{
var mainWindow = new MainWindow();
desktop.MainWindow = mainWindow;
desktop.MainWindow = MainWindow = mainWindow;
mainWindow.RestoreSizeAndLocation(Configuration.Instance);
mainWindow.OnLoad();
mainWindow.OnLibraryLoaded(LibraryTask.GetAwaiter().GetResult());

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

View File

@@ -8,6 +8,8 @@
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="{StaticResource SystemAltHighColor}" />
<SolidColorBrush x:Key="ProcessQueueBookBorderBrush" Color="Gray" />
<SolidColorBrush x:Key="DisabledGrayBrush" Color="#60D3D3D3" />
</Styles.Resources>
<Style Selector="TextBox[IsReadOnly=true]">
<Setter Property="Background" Value="LightGray" />

View File

@@ -1,8 +1,6 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using System;
using System.Threading;
using LibationAvalonia.Dialogs;
using System.Threading.Tasks;
namespace LibationAvalonia
@@ -18,6 +16,9 @@ namespace LibationAvalonia
return defaultBrush;
}
public static Task<DialogResult> ShowDialogAsync(this DialogWindow dialogWindow, Window owner = null)
=> dialogWindow.ShowDialog<DialogResult>(owner ?? App.MainWindow);
public static Window GetParentWindow(this IControl control) => control.VisualRoot as Window;
}
}

View File

@@ -0,0 +1,30 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LibationAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
Items="{Binding CheckboxItems}"
ItemTemplate="{StaticResource elementFactory}" />
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,46 @@
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationAvalonia.Controls
{
public partial class CheckedListBox : UserControl
{
public static readonly StyledProperty<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.CheckboxItems = Items;
base.OnPropertyChanged(change);
}
private class CheckedListBoxViewModel : ViewModelBase
{
private AvaloniaList<CheckBoxViewModel> _checkboxItems;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get => _checkboxItems; set => this.RaiseAndSetIfChanged(ref _checkboxItems, value); }
}
}
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}
}

View File

@@ -133,7 +133,7 @@ namespace LibationAvalonia.Controls
: Configuration.GetKnownDirectoryPath(directorySelectControl.SelectedDirectory);
selectedDir ??= string.Empty;
Directory = customStates.CustomChecked ? selectedDir : System.IO.Path.Combine(selectedDir, SubDirectory);
Directory = customStates.CustomChecked ? selectedDir : System.IO.Path.Combine(selectedDir, SubDirectory ?? "");
}
private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)

View File

@@ -36,6 +36,7 @@
Width="60"
Height="30"
Content="X"
HorizontalContentAlignment="Center"
IsEnabled="{Binding !IsDefault}"
Click="DeleteButton_Clicked" />

View File

@@ -117,10 +117,13 @@ namespace LibationAvalonia.Dialogs
{
Title = $"Select the audible-cli [account].json file",
AllowMultiple = false,
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" },
AppleUniformTypeIdentifiers = new[] { "public.json" }
}
}
};
@@ -274,13 +277,16 @@ namespace LibationAvalonia.Dialogs
var options = new FilePickerSaveOptions
{
Title = $"Save Sover Image",
SuggestedStartLocation = new BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)),
SuggestedFileName = $"{acc.AccountId}.json",
DefaultExtension = "json",
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("JSON files (*.json)") { Patterns = new[] { "*.json" } },
new("JSON files (*.json)")
{
Patterns = new[] { "*.json" },
AppleUniformTypeIdentifiers = new[] { "public.json" }
}
}
};

View File

@@ -153,10 +153,22 @@ namespace LibationAvalonia.Dialogs
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("Excel Workbook (*.xlsx)") { Patterns = new[] { "*.xlsx" } },
new("CSV files (*.csv)") { Patterns = new[] { "*.csv" } },
new("JSON files (*.json)") { Patterns = new[] { "*.json" } },
new("All files (*.*)") { Patterns = new[] { "*" } }
new("Excel Workbook (*.xlsx)")
{
Patterns = new[] { "*.xlsx" },
AppleUniformTypeIdentifiers = new[] { "org.openxmlformats.spreadsheetml.sheet" }
},
new("CSV files (*.csv)")
{
Patterns = new[] { "*.csv" },
AppleUniformTypeIdentifiers = new[] { "public.comma-separated-values-text" }
},
new("JSON files (*.json)")
{
Patterns = new[] { "*.json" },
AppleUniformTypeIdentifiers = new[] { "public.json" }
},
new("All files (*.*)") { Patterns = new[] { "*" } },
}
});

View File

@@ -1,8 +1,5 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using System;
using System.ComponentModel;
using System.IO;
@@ -16,23 +13,8 @@ namespace LibationAvalonia.Dialogs
public string PictureFileName { get; set; }
public string BookSaveDirectory { get; set; }
private byte[] _coverBytes;
public byte[] CoverBytes
{
get => _coverBytes;
set
{
_coverBytes = value;
var ms = new MemoryStream(_coverBytes);
ms.Position = 0;
_bitmapHolder.CoverImage = new Bitmap(ms);
}
}
private readonly BitmapHolder _bitmapHolder = new BitmapHolder();
public ImageDisplayDialog()
{
InitializeComponent();
@@ -45,6 +27,21 @@ namespace LibationAvalonia.Dialogs
AvaloniaXamlLoader.Load(this);
}
public void SetCoverBytes(byte[] cover)
{
try
{
var ms = new MemoryStream(cover);
_bitmapHolder.CoverImage = new Bitmap(ms);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {file}", PictureFileName);
using var ms = App.OpenAsset("img-coverart-prod-unavailable_500x500.jpg");
_bitmapHolder.CoverImage = new Bitmap(ms);
}
}
public async void SaveImage_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var options = new FilePickerSaveOptions
@@ -56,7 +53,11 @@ namespace LibationAvalonia.Dialogs
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("Jpeg (*.jpg)") { Patterns = new[] { "jpg" } }
new("Jpeg (*.jpg)")
{
Patterns = new[] { "jpg" },
AppleUniformTypeIdentifiers = new[] { "public.jpeg" }
}
}
};
@@ -66,7 +67,7 @@ namespace LibationAvalonia.Dialogs
try
{
File.WriteAllBytes(uri.LocalPath, CoverBytes);
_bitmapHolder.CoverImage.Save(uri.LocalPath);
}
catch (Exception ex)
{

View File

@@ -0,0 +1,30 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
Width="600" Height="450"
x:Class="LibationAvalonia.Dialogs.LocateAudiobooksDialog"
Title="Locate Audiobooks"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
<Grid Margin="5" ColumnDefinitions="*,Auto" RowDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="Found Audiobooks" />
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock Text="IDs Found: " />
<TextBlock Text="{Binding FoundAsins}" />
</StackPanel>
<ListBox Margin="0,5,0,0" Grid.Row="1" Grid.ColumnSpan="2" Name="foundAudiobooksLB" Items="{Binding FoundFiles}" AutoScrollToSelectedItem="true">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Margin="0,0,10,0" Text="{Binding Item1}" />
<TextBlock Grid.Column="1" Text="{Binding Item2}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Window>

View File

@@ -0,0 +1,115 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using DataLayer;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
{
public partial class LocateAudiobooksDialog : DialogWindow
{
private event EventHandler<FilePathCache.CacheEntry> FileFound;
private readonly CancellationTokenSource tokenSource = new();
private readonly List<string> foundAsins = new();
private readonly LocatedAudiobooksViewModel _viewModel;
public LocateAudiobooksDialog()
{
InitializeComponent();
DataContext = _viewModel = new();
this.RestoreSizeAndLocation(Configuration.Instance);
if (Design.IsDesignMode)
{
_viewModel.FoundFiles.Add(new("[0000001]", "Filename 1.m4b"));
_viewModel.FoundFiles.Add(new("[0000002]", "Filename 2.m4b"));
}
else
{
Opened += LocateAudiobooksDialog_Opened;
FileFound += LocateAudiobooks_FileFound;
Closing += LocateAudiobooksDialog_Closing;
}
}
private void LocateAudiobooksDialog_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
tokenSource.Cancel();
//If this dialog is closed before it's completed, Closing is fired
//once for the form closing and again for the MessageBox closing.
Closing -= LocateAudiobooksDialog_Closing;
this.SaveSizeAndLocation(Configuration.Instance);
}
private void LocateAudiobooks_FileFound(object sender, FilePathCache.CacheEntry e)
{
var newItem = new Tuple<string,string>($"[{e.Id}]", Path.GetFileName(e.Path));
_viewModel.FoundFiles.Add(newItem);
foundAudiobooksLB.SelectedItem = newItem;
if (!foundAsins.Any(asin => asin == e.Id))
{
foundAsins.Add(e.Id);
_viewModel.FoundAsins = foundAsins.Count;
}
}
private async void LocateAudiobooksDialog_Opened(object sender, EventArgs e)
{
var folderPicker = new FolderPickerOpenOptions
{
Title = "Select the folder to search for audiobooks",
AllowMultiple = false,
SuggestedStartLocation = new BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix)
};
var selectedFolder = await StorageProvider.OpenFolderPickerAsync(folderPicker);
if (selectedFolder.FirstOrDefault().TryGetUri(out var uri) is not true || !Directory.Exists(uri.LocalPath))
{
await CancelAndCloseAsync();
return;
}
using var context = DbContexts.GetContext();
await foreach (var book in AudioFileStorage.FindAudiobooksAsync(uri.LocalPath, tokenSource.Token))
{
try
{
FilePathCache.Insert(book);
var lb = context.GetLibraryBook_Flat_NoTracking(book.Id);
if (lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated));
FileFound?.Invoke(this, book);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book);
}
}
await MessageBox.Show(this, $"Libation has found {foundAsins.Count} unique audiobooks and added them to its database. ", $"Found {foundAsins.Count} Audiobooks");
await SaveAndCloseAsync();
}
}
public class LocatedAudiobooksViewModel : ViewModelBase
{
private int _foundAsins = 0;
public AvaloniaList<Tuple<string, string>> FoundFiles { get; } = new();
public int FoundAsins { get => _foundAsins; set => this.RaiseAndSetIfChanged(ref _foundAsins, value); }
}
}

View File

@@ -1,22 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using System;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs.Login
{
public abstract class AvaloniaLoginBase
{
/// <returns>True if ShowDialog's DialogResult == OK</returns>
protected static async Task<bool> ShowDialog(DialogWindow dialog)
{
if (Application.Current.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return false;
var result = await dialog.ShowDialog<DialogResult>(desktop.MainWindow);
Serilog.Log.Logger.Debug("{@DebugInfo}", new { DialogResult = result });
return result == DialogResult.OK;
}
}
}

View File

@@ -5,36 +5,38 @@ using AudibleUtilities;
namespace LibationAvalonia.Dialogs.Login
{
public class AvaloniaLoginCallback : AvaloniaLoginBase, ILoginCallback
public class AvaloniaLoginCallback : ILoginCallback
{
private Account _account { get; }
public string DeviceName { get; } = "Libation";
public AvaloniaLoginCallback(Account account)
{
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
}
public async Task<string> Get2faCodeAsync()
public async Task<string> Get2faCodeAsync(string prompt)
{
var dialog = new _2faCodeDialog();
if (await ShowDialog(dialog))
var dialog = new _2faCodeDialog(prompt);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return dialog.Code;
return null;
}
public async Task<string> GetCaptchaAnswerAsync(byte[] captchaImage)
public async Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
{
var dialog = new CaptchaDialog(captchaImage);
if (await ShowDialog(dialog))
return dialog.Answer;
return null;
var dialog = new CaptchaDialog(password, captchaImage);
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (dialog.Password, dialog.Answer);
return (null, null);
}
public async Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
{
var dialog = new MfaDialog(mfaConfig);
if (await ShowDialog(dialog))
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (dialog.SelectedName, dialog.SelectedValue);
return (null, null);
}
@@ -42,7 +44,7 @@ namespace LibationAvalonia.Dialogs.Login
public async Task<(string email, string password)> GetLoginAsync()
{
var dialog = new LoginCallbackDialog(_account);
if (await ShowDialog(dialog))
if (await dialog.ShowDialogAsync() is DialogResult.OK)
return (_account.AccountId, dialog.Password);
return (null, null);
}
@@ -50,7 +52,7 @@ namespace LibationAvalonia.Dialogs.Login
public async Task ShowApprovalNeededAsync()
{
var dialog = new ApprovalNeededDialog();
await ShowDialog(dialog);
await dialog.ShowDialogAsync();
}
}
}

View File

@@ -5,14 +5,15 @@ using AudibleUtilities;
namespace LibationAvalonia.Dialogs.Login
{
public class AvaloniaLoginChoiceEager : AvaloniaLoginBase, ILoginChoiceEager
public class AvaloniaLoginChoiceEager : ILoginChoiceEager
{
/// <summary>Convenience method. Recommended when wiring up Winforms to <see cref="ApplicationServices.LibraryCommands.ImportAccountAsync"/></summary>
public static async Task<ApiExtended> ApiExtendedFunc(Account account) => await ApiExtended.CreateAsync(account, new AvaloniaLoginChoiceEager(account));
public static async Task<ApiExtended> ApiExtendedFunc(Account account)
=> await ApiExtended.CreateAsync(account, new AvaloniaLoginChoiceEager(account));
public ILoginCallback LoginCallback { get; private set; }
public ILoginCallback LoginCallback { get; }
private Account _account { get; }
private readonly Account _account;
public AvaloniaLoginChoiceEager(Account account)
{
@@ -24,10 +25,9 @@ namespace LibationAvalonia.Dialogs.Login
{
var dialog = new LoginChoiceEagerDialog(_account);
if (!await ShowDialog(dialog))
if (await dialog.ShowDialogAsync() is not DialogResult.OK)
return null;
switch (dialog.LoginMethod)
{
case LoginMethod.Api:
@@ -35,7 +35,7 @@ namespace LibationAvalonia.Dialogs.Login
case LoginMethod.External:
{
var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl);
return await ShowDialog(externalDialog)
return await externalDialog.ShowDialogAsync() is DialogResult.OK
? ChoiceOut.External(externalDialog.ResponseUrl)
: null;
}

View File

@@ -2,22 +2,22 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="220" d:DesignHeight="180"
MinWidth="220" MinHeight="180"
MaxWidth="220" MaxHeight="180"
mc:Ignorable="d" d:DesignWidth="220" d:DesignHeight="250"
MinWidth="220" MinHeight="250"
MaxWidth="220" MaxHeight="250"
x:Class="LibationAvalonia.Dialogs.Login.CaptchaDialog"
Title="CAPTCHA"
Icon="/Assets/libation.ico">
<Grid
RowDefinitions="Auto,Auto,*"
ColumnDefinitions="Auto,*">
RowDefinitions="Auto,Auto,Auto,Auto,*"
ColumnDefinitions="Auto,*"
Margin="10">
<Panel
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="10"
MinWidth="200"
MinHeight="70"
Background="LightGray">
@@ -30,23 +30,40 @@
<TextBlock
Grid.Row="1"
Margin="0,10,0,0"
VerticalAlignment="Center"
Text="Password:" />
<TextBox
Name="passwordBox"
Grid.Row="2"
Grid.Column="0"
Margin="10,0,10,0"
Grid.ColumnSpan="2"
Margin="0,10,0,0"
PasswordChar="*"
Text="{Binding Password, Mode=TwoWay}" />
<TextBlock
Grid.Row="3"
Grid.Column="0"
Margin="0,10,10,0"
VerticalAlignment="Center"
Text="CAPTCHA&#xa;answer:" />
<TextBox
Grid.Row="1"
Name="captchaBox"
Grid.Row="3"
Grid.Column="1"
Margin="10,0,10,0" Text="{Binding Answer}" />
Margin="0,10,0,0"
Text="{Binding Answer, Mode=TwoWay}" />
<Button
Grid.Row="2"
Grid.Row="4"
Grid.Column="1"
Margin="10"
Padding="0,5,0,5"
VerticalAlignment="Bottom"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Content="Submit"
Click="Submit_Click" />

View File

@@ -1,5 +1,8 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using LibationAvalonia.ViewModels;
using ReactiveUI;
using System.IO;
using System.Threading.Tasks;
@@ -7,18 +10,43 @@ namespace LibationAvalonia.Dialogs.Login
{
public partial class CaptchaDialog : DialogWindow
{
public string Answer { get; set; }
public Bitmap CaptchaImage { get; }
public string Password => _viewModel.Password;
public string Answer => _viewModel.Answer;
private readonly CaptchaDialogViewModel _viewModel;
public CaptchaDialog()
{
InitializeComponent();
passwordBox = this.FindControl<TextBox>(nameof(passwordBox));
captchaBox = this.FindControl<TextBox>(nameof(captchaBox));
}
public CaptchaDialog(byte[] captchaImage) :this()
public CaptchaDialog(string password, byte[] captchaImage) :this()
{
using var ms = new MemoryStream(captchaImage);
CaptchaImage = new Bitmap(ms);
DataContext = this;
//Avalonia doesn't support animated gifs.
//Deconstruct gifs into frames and manually switch them.
using var gif = SixLabors.ImageSharp.Image.Load(captchaImage);
var gifEncoder = new SixLabors.ImageSharp.Formats.Gif.GifEncoder();
var gifFrames = new Bitmap[gif.Frames.Count];
var frameDelayMs = new int[gif.Frames.Count];
for (int i = 0; i < gif.Frames.Count; i++)
{
var frameMetadata = gif.Frames[i].Metadata.GetFormatMetadata(SixLabors.ImageSharp.Formats.Gif.GifFormat.Instance);
using var clonedFrame = gif.Frames.CloneFrame(i);
using var framems = new MemoryStream();
clonedFrame.Save(framems, gifEncoder);
framems.Position = 0;
gifFrames[i] = new Bitmap(framems);
frameDelayMs[i] = frameMetadata.FrameDelay * 10;
}
DataContext = _viewModel = new(password, gifFrames, frameDelayMs);
Opened += (_, _) => (string.IsNullOrEmpty(password) ? passwordBox : captchaBox).Focus();
}
private void InitializeComponent()
@@ -26,15 +54,73 @@ namespace LibationAvalonia.Dialogs.Login
AvaloniaXamlLoader.Load(this);
}
protected override Task SaveAndCloseAsync()
protected override async Task SaveAndCloseAsync()
{
Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { Answer });
if (string.IsNullOrWhiteSpace(_viewModel.Password))
{
await MessageBox.Show(this, "Please re-enter your password");
return;
}
return base.SaveAndCloseAsync();
Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { _viewModel.Answer });
await _viewModel.StopAsync();
await base.SaveAndCloseAsync();
}
protected override async Task CancelAndCloseAsync()
{
await _viewModel.StopAsync();
await base.CancelAndCloseAsync();
}
public async void Submit_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
}
public class CaptchaDialogViewModel : ViewModelBase
{
public string Answer { get; set; }
public string Password { get; set; }
public Bitmap CaptchaImage { get => _captchaImage; private set => this.RaiseAndSetIfChanged(ref _captchaImage, value); }
private Bitmap _captchaImage;
private bool keepSwitching = true;
private readonly Task FrameSwitch;
public CaptchaDialogViewModel(string password, Bitmap[] gifFrames, int[] frameDelayMs)
{
Password = password;
if (gifFrames.Length == 1)
{
FrameSwitch = Task.CompletedTask;
CaptchaImage = gifFrames[0];
}
else
{
FrameSwitch = SwitchFramesAsync(gifFrames, frameDelayMs);
}
}
public async Task StopAsync()
{
keepSwitching = false;
await FrameSwitch;
}
private async Task SwitchFramesAsync(Bitmap[] gifFrames, int[] frameDelayMs)
{
int index = 0;
while(keepSwitching)
{
CaptchaImage = gifFrames[index];
await Task.Delay(frameDelayMs[index++]);
index %= gifFrames.Length;
}
foreach (var frame in gifFrames)
frame.Dispose();
}
}
}

View File

@@ -1,4 +1,3 @@
using AudibleApi;
using AudibleUtilities;
using Avalonia;
using Avalonia.Controls;
@@ -49,7 +48,7 @@ namespace LibationAvalonia.Dialogs.Login
protected override async Task SaveAndCloseAsync()
{
Serilog.Log.Logger.Information("Submit button clicked: {@DebugInfo}", new { ResponseUrl });
if (!Uri.TryCreate(ResponseUrl, UriKind.Absolute, out var result))
if (!Uri.TryCreate(ResponseUrl, UriKind.Absolute, out _))
{
await MessageBox.Show("Invalid response URL");
return;

View File

@@ -2,9 +2,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="160"
MinWidth="400" MinHeight="160"
MaxWidth="400" MaxHeight="160"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="200"
MinWidth="400" MinHeight="200"
MaxWidth="400" MaxHeight="200"
x:Class="LibationAvalonia.Dialogs.Login.MfaDialog"
Title="Two-Step Verification"
Icon="/Assets/libation.ico">

View File

@@ -2,30 +2,41 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="140" d:DesignHeight="100"
MinWidth="140" MinHeight="100"
MaxWidth="140" MaxHeight="100"
mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="200"
MinWidth="200" MinHeight="200"
MaxWidth="200" MaxHeight="200"
x:Class="LibationAvalonia.Dialogs.Login._2faCodeDialog"
Title="2FA Code"
Icon="/Assets/libation.ico">
<Grid RowDefinitions="Auto,Auto,*">
<Grid
VerticalAlignment="Stretch"
ColumnDefinitions="*" Margin="5"
RowDefinitions="*,Auto,Auto,Auto">
<TextBlock
TextAlignment="Center"
TextWrapping="Wrap"
Text="{Binding Prompt}" />
<TextBlock
Margin="5"
Grid.Row="1"
TextAlignment="Center"
Text="Enter 2FA Code" />
<TextBox
Name="_2FABox"
Margin="5,0,5,0"
Grid.Row="1"
Grid.Row="2"
HorizontalContentAlignment="Center"
Text="{Binding Code, Mode=TwoWay}" />
<Button
Margin="5"
Grid.Row="2"
VerticalAlignment="Bottom"
Grid.Row="3"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Content="Submit"
Click="Submit_Click" />
</Grid>

View File

@@ -1,4 +1,3 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using System.Threading.Tasks;
@@ -8,16 +7,20 @@ namespace LibationAvalonia.Dialogs.Login
public partial class _2faCodeDialog : DialogWindow
{
public string Code { get; set; }
public string Prompt { get; } = "For added security, please enter the One Time Password (OTP) generated by your Authenticator App";
public _2faCodeDialog()
{
InitializeComponent();
DataContext = this;
AvaloniaXamlLoader.Load(this);
_2FABox = this.FindControl<TextBox>(nameof(_2FABox));
}
private void InitializeComponent()
public _2faCodeDialog(string prompt) : this()
{
AvaloniaXamlLoader.Load(this);
Prompt = prompt;
DataContext = this;
Opened += (_, _) => _2FABox.Focus();
}
protected override Task SaveAndCloseAsync()

View File

@@ -35,13 +35,13 @@
</DockPanel.Styles>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button1Text}"/>
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button1Text}"/>
</Button>
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button2Text}"/>
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button2Text}"/>
</Button>
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Content="Cancel" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button3Text}"/>
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button3Text}"/>
</Button>
</StackPanel>
</DockPanel>

View File

@@ -2,8 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="850" d:DesignHeight="620"
MinWidth="800" MinHeight="620"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="750"
MinWidth="900" MinHeight="700"
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
Title="Edit Settings"
@@ -365,7 +365,6 @@
Text="{Binding DownloadDecryptSettings.InProgressDescriptionText}" />
<controls:DirectoryOrCustomSelectControl
SubDirectory="Libation"
Directory="{Binding DownloadDecryptSettings.InProgressDirectory, Mode=TwoWay}"
KnownDirectories="{Binding DownloadDecryptSettings.KnownDirectories}" />
@@ -377,7 +376,7 @@
Grid.Row="3"
Margin="5"
VerticalAlignment="Top"
IsVisible="{Binding IsWindows}"
IsVisible="{Binding !IsLinux}"
IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}">
<TextBlock
@@ -604,6 +603,25 @@
</CheckBox>
</Grid>
<Grid Margin="5,5,5,0" RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
<TextBlock Margin="0,0,0,5" Text="Max audio sample rate:" />
<controls:WheelComboBox
Grid.Row="1"
HorizontalAlignment="Stretch"
Items="{Binding AudioSettings.SampleRates}"
SelectedItem="{Binding AudioSettings.SelectedSampleRate, Mode=TwoWay}"/>
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
<controls:WheelComboBox
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Stretch"
Items="{Binding AudioSettings.EncoderQualities}"
SelectedItem="{Binding AudioSettings.SelectedEncoderQuality, Mode=TwoWay}"/>
</Grid>
<controls:GroupBox
Margin="5,5,5,0"
BorderWidth="1"

View File

@@ -11,6 +11,7 @@ using System.Linq;
using FileManager;
using System.IO;
using Avalonia.Collections;
using LibationUiBase;
namespace LibationAvalonia.Dialogs
{
@@ -107,7 +108,8 @@ namespace LibationAvalonia.Dialogs
LoadSettings(config);
}
public bool IsWindows => AppScaffolding.LibationScaffolding.ReleaseIdentifier is AppScaffolding.ReleaseIdentifier.WindowsAvalonia;
public bool IsLinux => Configuration.IsLinux;
public bool IsWindows => Configuration.IsWindows;
public ImportantSettings ImportantSettings { get; private set; }
public ImportSettings ImportSettings { get; private set; }
public DownloadDecryptSettings DownloadDecryptSettings { get; private set; }
@@ -141,7 +143,7 @@ namespace LibationAvalonia.Dialogs
public void LoadSettings(Configuration config)
{
BooksDirectory = config.Books;
BooksDirectory = config.Books.PathWithoutPrefix;
SavePodcastsToParentFolder = config.SavePodcastsToParentFolder;
LoggingLevel = config.LogLevel;
BetaOptIn = config.BetaOptIn;
@@ -372,6 +374,31 @@ namespace LibationAvalonia.Dialogs
private int _lameBitrate;
private int _lameVBRQuality;
private string _chapterTitleTemplate;
public SampleRateSelection SelectedSampleRate { get; set; }
public NAudio.Lame.EncoderQuality SelectedEncoderQuality { get; set; }
public AvaloniaList<SampleRateSelection> SampleRates { get; }
= new(
new []
{
AAXClean.SampleRate.Hz_44100,
AAXClean.SampleRate.Hz_32000,
AAXClean.SampleRate.Hz_24000,
AAXClean.SampleRate.Hz_22050,
AAXClean.SampleRate.Hz_16000,
AAXClean.SampleRate.Hz_12000,
}
.Select(s => new SampleRateSelection(s)));
public AvaloniaList<NAudio.Lame.EncoderQuality> EncoderQualities { get; }
= new(
new[]
{
NAudio.Lame.EncoderQuality.High,
NAudio.Lame.EncoderQuality.Standard,
NAudio.Lame.EncoderQuality.Fast,
});
public AudioSettings(Configuration config)
{
@@ -398,6 +425,9 @@ namespace LibationAvalonia.Dialogs
LameMatchSource = config.LameMatchSourceBR;
LameBitrate = config.LameBitrate;
LameVBRQuality = config.LameVBRQuality;
SelectedSampleRate = SampleRates.FirstOrDefault(s => s.SampleRate == config.MaxSampleRate);
SelectedEncoderQuality = config.LameEncoderQuality;
}
public Task<bool> SaveSettingsAsync(Configuration config)
@@ -422,6 +452,9 @@ namespace LibationAvalonia.Dialogs
config.LameBitrate = LameBitrate;
config.LameVBRQuality = LameVBRQuality;
config.LameEncoderQuality = SelectedEncoderQuality;
config.MaxSampleRate = SelectedSampleRate?.SampleRate ?? config.MaxSampleRate;
return Task.FromResult(true);
}

View File

@@ -0,0 +1,66 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="630" d:DesignHeight="480"
x:Class="LibationAvalonia.Dialogs.TrashBinDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
MinWidth="630" MinHeight="480"
Title="Trash Bin"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
<Grid
RowDefinitions="Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="Check books you want to permanently delete from or restore to Libation" />
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,*,Auto">
<CheckBox
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,20,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Content="Restore"
Click="Restore_Click" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Click="EmptyTrash_Click" >
<TextBlock
TextAlignment="Center"
Text="Permanently Delete&#xa;from Libation" />
</Button>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,145 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using LibationAvalonia.Controls;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
{
public partial class TrashBinDialog : Window
{
TrashBinViewModel _viewModel;
public TrashBinDialog()
{
InitializeComponent();
this.RestoreSizeAndLocation(Configuration.Instance);
DataContext = _viewModel = new();
this.Closing += (_,_) => this.SaveSizeAndLocation(Configuration.Instance);
}
public async void EmptyTrash_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.PermanentlyDeleteCheckedAsync();
public async void Restore_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.RestoreCheckedAsync();
}
public class TrashBinViewModel : ViewModelBase, IDisposable
{
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
private bool _controlsEnabled = true;
public bool ControlsEnabled { get => _controlsEnabled; set=> this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
private bool? everythingChecked = false;
public bool? EverythingChecked
{
get => everythingChecked;
set
{
everythingChecked = value ?? false;
if (everythingChecked is true)
CheckAll();
else if (everythingChecked is false)
UncheckAll();
}
}
private int _totalBooksCount = 0;
private int _checkedBooksCount = -1;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
everythingChecked
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
: _checkedBooksCount == _totalBooksCount ? true
: null;
this.RaisePropertyChanged(nameof(EverythingChecked));
}
}
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
public TrashBinViewModel()
{
DeletedBooks = new()
{
ResetBehavior = ResetBehavior.Remove
};
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
Reload();
}
public void CheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = true;
}
public void UncheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = false;
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
private void Reload()
{
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
_totalBooksCount = DeletedBooks.Count;
CheckedBooksCount = 0;
}
private IDisposable tracker;
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
{
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
}
public void Dispose() => tracker?.Dispose();
}
}

View File

@@ -4,8 +4,6 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
<TrimMode>copyused</TrimMode>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationIcon>libation.ico</ApplicationIcon>
<AssemblyName>Libation</AssemblyName>
@@ -17,13 +15,7 @@
</PropertyGroup>
<PropertyGroup>
<!--
HACK FOR COMPILER BUG 2021-09-14. Hopefully will be fixed in future versions
- Not using SatelliteResourceLanguages will load all language packs: works
- Specifying 'en' semicolon 1 more should load 1 language pack: works
- Specifying only 'en' should load no language packs: broken, still loads all
-->
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -39,6 +31,8 @@
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<None Remove=".gitignore" />
<None Remove="Assets\Arrows_left.png" />
<None Remove="Assets\Arrows_right.png" />
<None Remove="Assets\Asterisk.png" />
<None Remove="Assets\cancel.png" />
<None Remove="Assets\completed.png" />
@@ -109,27 +103,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="XamlNameReferenceGenerator" Version="1.5.1" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.6.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibationUiBase\LibationUiBase.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="glass-with-glow_256.svg">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation.desktop">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="ZipExtractor.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />

View File

@@ -154,6 +154,7 @@ Libation.
private static async Task<DialogResult> ShowCoreAsync(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
{
owner = owner?.IsLoaded is true ? owner : null;
var dialog = await Dispatcher.UIThread.InvokeAsync(() => CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition));
return await DisplayWindow(dialog, owner);

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AppScaffolding;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.ReactiveUI;
@@ -13,15 +14,34 @@ namespace LibationAvalonia
{
static class Program
{
static void Main()
static void Main(string[] args)
{
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "hangover")
{
//Launch the Hangover app within the sandbox
//We can do this because we're already executing inside the sandbox.
//Any process created in the sandbox executes in the same sandbox.
//Unfortunately, all sandbox files are read/execute, so no writing!
Process.Start("Hangover");
return;
}
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "cli")
{
//Open a new Terminal in the sandbox
Process.Start(
"/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal",
$"\"{Configuration.ProcessDirectory}\"");
return;
}
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
// Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration
var config = AppScaffolding.LibationScaffolding.RunPreConfigMigrations();
var config = LibationScaffolding.RunPreConfigMigrations();
App.SetupRequired = !config.LibationSettingsAreValid;
@@ -29,13 +49,10 @@ namespace LibationAvalonia
var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime());
var appBuilderTask = Task.Run(BuildAvaloniaApp);
if (Configuration.IsWindows)
AppScaffolding.LibationScaffolding.SetReleaseIdentifier(AppScaffolding.ReleaseIdentifier.WindowsAvalonia);
else if (Configuration.IsLinux)
AppScaffolding.LibationScaffolding.SetReleaseIdentifier(AppScaffolding.ReleaseIdentifier.LinuxAvalonia);
else if (Configuration.IsMacOs)
AppScaffolding.LibationScaffolding.SetReleaseIdentifier(AppScaffolding.ReleaseIdentifier.MacOSAvalonia);
else return;
LibationScaffolding.SetReleaseIdentifier(Variety.Chardonnay);
if (LibationScaffolding.ReleaseIdentifier is ReleaseIdentifier.None)
return;
if (!App.SetupRequired)
@@ -62,8 +79,8 @@ namespace LibationAvalonia
try
{
// most migrations go in here
AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config);
AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(config);
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
return true;
}

View File

@@ -1,5 +1,6 @@
using ApplicationServices;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using DataLayer;
using Dinah.Core;
using FileLiberator;
@@ -35,18 +36,31 @@ namespace LibationAvalonia.ViewModels
#region Model properties exposed to the view
private Avalonia.Media.Imaging.Bitmap _cover;
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } }
public string PurchaseDate { get; protected set; }
public string Series { get; protected set; }
public string Title { get; protected set; }
public string Length { get; protected set; }
public string Authors { get; protected set; }
public string Narrators { get; protected set; }
public string Category { get; protected set; }
public string Misc { get; protected set; }
public string Description { get; protected set; }
public Rating ProductRating { get; protected set; }
private string _purchasedate;
private string _series;
private string _title;
private string _length;
private string _authors;
private string _narrators;
private string _category;
private string _misc;
private LastDownloadStatus _lastDownload;
private string _description;
private Rating _productrating;
protected Rating _myRating;
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set => this.RaiseAndSetIfChanged(ref _cover, value); }
public string PurchaseDate { get => _purchasedate; protected set => this.RaiseAndSetIfChanged(ref _purchasedate, value); }
public string Series { get => _series; protected set => this.RaiseAndSetIfChanged(ref _series, value); }
public string Title { get => _title; protected set => this.RaiseAndSetIfChanged(ref _title, value); }
public string Length { get => _length; protected set => this.RaiseAndSetIfChanged(ref _length, value); }
public string Authors { get => _authors; protected set => this.RaiseAndSetIfChanged(ref _authors, value); }
public string Narrators { get => _narrators; protected set => this.RaiseAndSetIfChanged(ref _narrators, value); }
public string Category { get => _category; protected set => this.RaiseAndSetIfChanged(ref _category, value); }
public LastDownloadStatus LastDownload { get => _lastDownload; protected set => this.RaiseAndSetIfChanged(ref _lastDownload, value); }
public string Misc { get => _misc; protected set => this.RaiseAndSetIfChanged(ref _misc, value); }
public string Description { get => _description; protected set => this.RaiseAndSetIfChanged(ref _description, value); }
public Rating ProductRating { get => _productrating; protected set => this.RaiseAndSetIfChanged(ref _productrating, value); }
public Rating MyRating
{
get => _myRating;
@@ -65,7 +79,6 @@ namespace LibationAvalonia.ViewModels
public abstract bool? Remove { get; set; }
public abstract LiberateButtonStatus Liberate { get; }
public abstract BookTags BookTags { get; }
public abstract bool IsSeries { get; }
public abstract bool IsEpisode { get; }
public abstract bool IsBook { get; }
public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
@@ -111,6 +124,7 @@ namespace LibationAvalonia.ViewModels
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
};
#endregion
@@ -126,8 +140,7 @@ namespace LibationAvalonia.ViewModels
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
using var ms = new System.IO.MemoryStream(picture);
_cover = new Avalonia.Media.Imaging.Bitmap(ms);
_cover = loadImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
@@ -143,12 +156,28 @@ namespace LibationAvalonia.ViewModels
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
using var ms = new System.IO.MemoryStream(e.Picture);
Cover = new Avalonia.Media.Imaging.Bitmap(ms);
Cover = loadImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
private Bitmap loadImage(byte[] picture)
{
try
{
using var ms = new System.IO.MemoryStream(picture);
return new Bitmap(ms);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
return DefaultImage;
}
}
private static Bitmap _defaultImage;
private static Bitmap DefaultImage => _defaultImage ??= new Bitmap(App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg"));
#endregion
#region Static library display functions

View File

@@ -8,9 +8,10 @@ namespace LibationAvalonia.ViewModels
{
public class LiberateButtonStatus : ViewModelBase, IComparable
{
public LiberateButtonStatus(bool isSeries)
public LiberateButtonStatus(bool isSeries, bool isAbsent)
{
IsSeries = isSeries;
IsAbsent = isAbsent;
}
public LiberatedStatus BookStatus { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
@@ -26,7 +27,10 @@ namespace LibationAvalonia.ViewModels
this.RaisePropertyChanged(nameof(ToolTip));
}
}
private bool IsSeries { get; }
private bool IsAbsent { get; }
public bool IsSeries { get; }
public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public Bitmap Image => GetLiberateIcon();
public string ToolTip => GetTooltip();
@@ -40,6 +44,8 @@ namespace LibationAvalonia.ViewModels
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
else if (IsUnavailable && !second.IsUnavailable) return 1;
else if (!IsUnavailable && second.IsUnavailable) return -1;
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
else return BookStatus.CompareTo(second.BookStatus);
@@ -72,11 +78,15 @@ namespace LibationAvalonia.ViewModels
return GetFromResources($"liberate_{image_lib}{image_pdf}");
}
private string GetTooltip()
{
if (IsSeries)
return Expanded ? "Click to Collpase" : "Click to Expand";
if (IsUnavailable)
return "This book cannot be downloaded\nbecause it wasn't found during\nthe most recent library scan";
if (BookStatus == LiberatedStatus.Error)
return "Book downloaded ERROR";

View File

@@ -44,13 +44,12 @@ namespace LibationAvalonia.ViewModels
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return new LiberateButtonStatus(IsSeries) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
}
}
public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) };
public override bool IsSeries => false;
public override bool IsEpisode => Parent is not null;
public override bool IsBook => Parent is null;
@@ -58,8 +57,22 @@ namespace LibationAvalonia.ViewModels
public LibraryBookEntry(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
setLibraryBook(libraryBook);
LoadCover();
}
public void UpdateLibraryBook(LibraryBook libraryBook)
{
if (AudibleProductId != libraryBook.Book.AudibleProductId)
throw new Exception("Invalid grid entry update. IDs must match");
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
setLibraryBook(libraryBook);
}
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Title = Book.Title;
Series = Book.SeriesNames();
@@ -73,10 +86,13 @@ namespace LibationAvalonia.ViewModels
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LastDownload = new(Book.UserDefinedItem);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
this.RaisePropertyChanged(nameof(MyRating));
this.RaisePropertyChanged(nameof(Liberate));
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
@@ -112,6 +128,10 @@ namespace LibationAvalonia.ViewModels
_pdfStatus = udi.PdfStatus;
this.RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.LastDownloaded):
LastDownload = new(udi);
this.RaisePropertyChanged(nameof(LastDownload));
break;
}
}
@@ -134,6 +154,7 @@ namespace LibationAvalonia.ViewModels
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },

View File

@@ -1,5 +1,4 @@
using ApplicationServices;
using Dinah.Core;
using LibationFileManager;
using ReactiveUI;
@@ -25,6 +24,9 @@ namespace LibationAvalonia.ViewModels
public ProcessQueueViewModel ProcessQueue { get; } = new ProcessQueueViewModel();
public ProductsDisplayViewModel ProductsDisplay { get; } = new ProductsDisplayViewModel();
private double? _downloadProgress = null;
public double? DownloadProgress { get => _downloadProgress; set => this.RaiseAndSetIfChanged(ref _downloadProgress, value); }
/// <summary> Library filterting query </summary>
public string FilterString { get => _filterString; set => this.RaiseAndSetIfChanged(ref _filterString, value); }
@@ -132,15 +134,9 @@ namespace LibationAvalonia.ViewModels
set
{
this.RaiseAndSetIfChanged(ref _queueOpen, value);
QueueHideButtonText = _queueOpen? "❱❱❱" : "❰❰❰";
this.RaisePropertyChanged(nameof(QueueHideButtonText));
}
}
/// <summary> The Process Queue's Expand/Collapse button display text </summary>
public string QueueHideButtonText { get; private set; }
/// <summary> The number of books visible in the Product Display </summary>
public int VisibleCount
@@ -169,18 +165,6 @@ namespace LibationAvalonia.ViewModels
{
this.RaiseAndSetIfChanged(ref _libraryStats, value);
var backupsCountText
= !LibraryStats.HasBookResults ? "No books. Begin by importing your library"
: !LibraryStats.HasPendingBooks ? $"All {"book".PluralizeWithCount(LibraryStats.booksFullyBackedUp)} backed up"
: $"BACKUPS: No progress: {LibraryStats.booksNoProgress} In process: {LibraryStats.booksDownloadedOnly} Fully backed up: {LibraryStats.booksFullyBackedUp} {(LibraryStats.booksError > 0 ? $" Errors : {LibraryStats.booksError}" : "")}";
var pdfCountText
= !LibraryStats.HasPdfResults ? ""
: LibraryStats.pdfsNotDownloaded == 0 ? $" | All {LibraryStats.pdfsDownloaded} PDFs downloaded"
: $" | PDFs: NOT d/l'ed: {LibraryStats.pdfsNotDownloaded} Downloaded: {LibraryStats.pdfsDownloaded}";
StatusCountText = backupsCountText + pdfCountText;
BookBackupsToolStripText
= LibraryStats.HasPendingBooks
? $"Begin _Book and PDF Backups: {LibraryStats.PendingBooks} remaining"
@@ -191,21 +175,17 @@ namespace LibationAvalonia.ViewModels
? $"Begin _PDF Only Backups: {LibraryStats.pdfsNotDownloaded} remaining"
: "All PDFs have been downloaded";
this.RaisePropertyChanged(nameof(StatusCountText));
this.RaisePropertyChanged(nameof(BookBackupsToolStripText));
this.RaisePropertyChanged(nameof(PdfBackupsToolStripText));
}
}
/// <summary> Bottom-left library statistics display text </summary>
public string StatusCountText { get; private set; } = "[Calculating backed up book quantities] | [Calculating backed up PDFs]";
/// <summary> The "Begin Book and PDF Backup" menu item header text </summary>
public string BookBackupsToolStripText { get; private set; } = "Begin _Book and PDF Backups: 0";
/// <summary> The "Begin PDF Only Backup" menu item header text </summary>
public string PdfBackupsToolStripText { get; private set; } = "Begin _PDF Only Backups: 0";
/// <summary> The number of books visible in the Products Display that have not yet been liberated </summary>
public int VisibleNotLiberated
{

View File

@@ -7,6 +7,7 @@ using AudibleApi;
using AudibleApi.Common;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
@@ -60,12 +61,12 @@ namespace LibationAvalonia.ViewModels
#region Properties exposed to the view
public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } }
public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(BackgroundColor)); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } }
public string Narrator { get => _narrator; set { this.RaiseAndSetIfChanged(ref _narrator, value); } }
public string Author { get => _author; set { this.RaiseAndSetIfChanged(ref _author, value); } }
public string Title { get => _title; set { this.RaiseAndSetIfChanged(ref _title, value); } }
public int Progress { get => _progress; private set { this.RaiseAndSetIfChanged(ref _progress, value); } }
public string ETA { get => _eta; private set { this.RaiseAndSetIfChanged(ref _eta, value); } }
public Bitmap Cover { get => _cover; private set { this.RaiseAndSetIfChanged(ref _cover, value); } }
public string Narrator { get => _narrator; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
public string Author { get => _author; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _author, value)); }
public string Title { get => _title; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _title, value)); }
public int Progress { get => _progress; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _progress, value)); }
public string ETA { get => _eta; private set => Dispatcher.UIThread.Post(() =>this.RaiseAndSetIfChanged(ref _eta, value)); }
public Bitmap Cover { get => _cover; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
public bool IsDownloading => Status is ProcessBookStatus.Working;
public bool Queued => Status is ProcessBookStatus.Queued;
@@ -131,6 +132,7 @@ namespace LibationAvalonia.ViewModels
public async Task<ProcessBookResult> ProcessOneAsync()
{
string procName = CurrentProcessable.Name;
ProcessBookResult result = ProcessBookResult.None;
try
{
LinkProcessable(CurrentProcessable);
@@ -138,32 +140,34 @@ namespace LibationAvalonia.ViewModels
var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true);
if (statusHandler.IsSuccess)
return Result = ProcessBookResult.Success;
result = ProcessBookResult.Success;
else if (statusHandler.Errors.Contains("Cancelled"))
{
Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}");
return Result = ProcessBookResult.Cancelled;
result = ProcessBookResult.Cancelled;
}
else if (statusHandler.Errors.Contains("Validation failed"))
{
Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}");
return Result = ProcessBookResult.ValidationFail;
result = ProcessBookResult.ValidationFail;
}
else
{
foreach (var errorMessage in statusHandler.Errors)
Logger.Error($"{procName}: {errorMessage}");
}
foreach (var errorMessage in statusHandler.Errors)
Logger.Error($"{procName}: {errorMessage}");
}
catch (ContentLicenseDeniedException ldex)
{
if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
{
Logger.Info($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");
return Result = ProcessBookResult.LicenseDeniedPossibleOutage;
result = ProcessBookResult.LicenseDeniedPossibleOutage;
}
else
{
Logger.Info($"{procName}: Content license denied. Check your Audible account to see if you have access to this title. - {LibraryBook.Book}");
return Result = ProcessBookResult.LicenseDenied;
result = ProcessBookResult.LicenseDenied;
}
}
catch (Exception ex)
@@ -172,18 +176,21 @@ namespace LibationAvalonia.ViewModels
}
finally
{
if (Result == ProcessBookResult.None)
Result = await showRetry(LibraryBook);
if (result == ProcessBookResult.None)
result = await showRetry(LibraryBook);
Status = Result switch
var status = result switch
{
ProcessBookResult.Success => ProcessBookStatus.Completed,
ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled,
_ => ProcessBookStatus.Failed,
};
await Dispatcher.UIThread.InvokeAsync(() => Status = status);
}
return Result;
await Dispatcher.UIThread.InvokeAsync(() => Result = result);
return result;
}
public async Task CancelAsync()
@@ -294,9 +301,9 @@ namespace LibationAvalonia.ViewModels
#region Processable event handlers
private void Processable_Begin(object sender, LibraryBook libraryBook)
private async void Processable_Begin(object sender, LibraryBook libraryBook)
{
Status = ProcessBookStatus.Working;
await Dispatcher.UIThread.InvokeAsync(() => Status = ProcessBookStatus.Working);
Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}");

View File

@@ -1,4 +1,5 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
@@ -16,9 +17,8 @@ namespace LibationAvalonia.ViewModels
public class ProcessQueueViewModel : ViewModelBase, ILogForm
{
public ObservableCollection<LogEntry> LogEntries { get; } = new();
public TrackedQueue<ProcessBookViewModel> Items { get; } = new();
private TrackedQueue<ProcessBookViewModel> Queue => Items;
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
public TrackedQueue<ProcessBookViewModel> Queue { get; }
public ProcessBookViewModel SelectedItem { get; set; }
public Task QueueRunner { get; private set; }
public bool Running => !QueueRunner?.IsCompleted ?? false;
@@ -28,6 +28,7 @@ namespace LibationAvalonia.ViewModels
public ProcessQueueViewModel()
{
Logger = LogMe.RegisterForm(this);
Queue = new(Items);
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
@@ -44,11 +45,11 @@ namespace LibationAvalonia.ViewModels
private bool _progressBarVisible;
private decimal _speedLimit;
public int CompletedCount { get => _completedCount; private set { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); } }
public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); } }
public int ErrorCount { get => _errorCount; private set { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); } }
public string RunningTime { get => _runningTime; set { this.RaiseAndSetIfChanged(ref _runningTime, value); } }
public bool ProgressBarVisible { get => _progressBarVisible; set { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); } }
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
public bool AnyCompleted => CompletedCount > 0;
public bool AnyQueued => QueuedCount > 0;
public bool AnyErrors => ErrorCount > 0;
@@ -78,26 +79,29 @@ namespace LibationAvalonia.ViewModels
: _speedLimit > 1 ? 0.1m
: 0.01m;
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
this.RaisePropertyChanged();
Dispatcher.UIThread.Post(() =>
{
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
this.RaisePropertyChanged();
});
}
}
public decimal SpeedLimitIncrement { get; private set; }
private void Queue_CompletedCountChanged(object sender, int e)
private async void Queue_CompletedCountChanged(object sender, int e)
{
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
ErrorCount = errCount;
CompletedCount = completeCount;
this.RaisePropertyChanged(nameof(Progress));
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
}
private void Queue_QueuededCountChanged(object sender, int cueCount)
private async void Queue_QueuededCountChanged(object sender, int cueCount)
{
QueuedCount = cueCount;
this.RaisePropertyChanged(nameof(Progress));
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
}
public void WriteLine(string text)

View File

@@ -10,8 +10,6 @@ using ApplicationServices;
using AudibleUtilities;
using LibationAvalonia.Dialogs.Login;
using Avalonia.Collections;
using LibationSearchEngine;
using Octokit.Internal;
namespace LibationAvalonia.ViewModels
{
@@ -22,11 +20,11 @@ namespace LibationAvalonia.ViewModels
public event EventHandler<int> RemovableCountChanged;
/// <summary>Backing list of all grid entries</summary>
private readonly List<GridEntry> SOURCE = new();
private readonly AvaloniaList<GridEntry> SOURCE = new();
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private List<GridEntry> FilteredInGridEntries;
public string FilterString { get; private set; }
public DataGridCollectionView GridEntries { get; }
public DataGridCollectionView GridEntries { get; private set; }
private bool _removeColumnVisivle;
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); }
@@ -44,58 +42,60 @@ namespace LibationAvalonia.ViewModels
public ProductsDisplayViewModel()
{
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
GridEntries = new(SOURCE);
GridEntries.Filter = CollectionFilter;
VisibleCountChanged?.Invoke(this, 0);
}
GridEntries.CollectionChanged += (s, e)
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
private static readonly System.Reflection.MethodInfo SetFlagsMethod;
/// <summary>
/// Tells the <see cref="DataGridCollectionView"/> whether it should process changes to the underlying collection
/// </summary>
/// <remarks> DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4</remarks>
private void SetShouldProcessCollectionChanged(bool flagSet)
=> SetFlagsMethod.Invoke(GridEntries, new object[] { 4, flagSet });
static ProductsDisplayViewModel()
{
/*
* When a book is removed from the library, SearchEngineUpdated is fired before LibrarySizeChanged, so
* the book is removed from the filtered set and the grid is refreshed before RemoveBooks() is ever
* called.
*
* To remove an item from DataGridCollectionView, it must be be in the current filtered view. If it's
* not and you try to remove the book from the source list, the source will fire NotifyCollectionChanged
* on an invalid item and the DataGridCollectionView will throw an exception. There are two ways to
* remove an item that is filtered out of the DataGridCollectionView:
*
* (1) Re-add the item to the filtered-in list and refresh the grid so DataGridCollectionView knows
* that the item is present. This causes the whole grid to flicker to refresh twice in rapid
* succession, which is undesirable.
*
* (2) Remove it from the underlying collection and suppress NotifyCollectionChanged. This is the
* method used. Steps to complete a removal using this method:
*
* (a) Set DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged to false.
* (b) Remove the item from the source list. The source will fire NotifyCollectionChanged, but the
* DataGridCollectionView will ignore it.
* (c) Reset the flag to true.
*/
SetFlagsMethod =
typeof(DataGridCollectionView)
.GetMethod("SetFlag", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
}
#region Display Functions
/// <summary>
/// Call when there's been a change to the library
/// </summary>
public async Task DisplayBooksAsync(List<LibraryBook> dbBooks)
internal void BindToGrid(List<LibraryBook> dbBooks)
{
try
GridEntries = new(SOURCE)
{
var existingSeriesEntries = SOURCE.SeriesEntries().ToList();
Filter = CollectionFilter
};
SOURCE.Clear();
SOURCE.AddRange(CreateGridEntries(dbBooks));
GridEntries.CollectionChanged += (_, _)
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
//If replacing the list, preserve user's existing collapse/expand
//state. When resetting a list, default state is cosed.
foreach (var series in existingSeriesEntries)
{
var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId);
if (sEntry is SeriesEntry se)
se.Liberate.Expanded = series.Liberate.Expanded;
}
//Run query on new list
FilteredInGridEntries = QueryResults(SOURCE, FilterString);
await refreshGrid();
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel));
}
}
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
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry(b))
@@ -104,26 +104,159 @@ namespace LibationAvalonia.ViewModels
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent()))
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
foreach (var parent in seriesBooks)
{
var seriesEpisodes = episodes.FindChildren(parent);
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
seriesEntry.Liberate.Expanded = false;
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
}
var bookList = geList.OrderByDescending(e => e.DateAdded).ToList();
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = QueryResults(geList, FilterString);
SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded));
}
//ListIndex is used by RowComparer to make column sort stable
int index = 0;
foreach (GridEntry di in bookList)
di.ListIndex = index++;
/// <summary>
/// Call when there's been a change to the library
/// </summary>
internal async Task UpdateGridAsync(List<LibraryBook> dbBooks)
{
#region Add new or update existing grid entries
return bookList;
//Add absent entries to grid, or update existing entry
var allEntries = SOURCE.BookEntries();
var seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes();
await Dispatcher.UIThread.InvokeAsync(() =>
{
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
if (libraryBook.Book.IsProduct())
UpsertBook(libraryBook, existingEntry);
else if (parentedEpisodes.Any(lb => lb == libraryBook))
//Only try to add or update is this LibraryBook is a know child of a parent
UpsertEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
});
#endregion
#region Remove entries no longer in the library
//Rapid successive book removals will cause changes to SOURCE after the update has
//begun but before it has completed, so perform all updates on a copy of the list.
var sourceSnapshot = SOURCE.ToList();
// remove deleted from grid.
// note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this
var removedBooks =
sourceSnapshot
.BookEntries()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
removed.Parent.RemoveChild(removed);
//Remove series that have no children
var removedSeries = sourceSnapshot.EmptySeries();
await Dispatcher.UIThread.InvokeAsync(() => RemoveBooks(removedBooks, removedSeries));
#endregion
await Filter(FilterString);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
private void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks, IEnumerable<SeriesEntry> removedSeries)
{
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
{
if (GridEntries.PassesFilter(removed))
GridEntries.Remove(removed);
else
{
SetShouldProcessCollectionChanged(false);
SOURCE.Remove(removed);
SetShouldProcessCollectionChanged(true);
}
}
}
private void UpsertBook(LibraryBook book, LibraryBookEntry existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
SOURCE.Insert(0, new LibraryBookEntry(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
if (seriesEntry is null)
{
//Series doesn't exist yet, so create and add it
var seriesBook = dbBooks.FindSeriesParent(episodeBook);
if (seriesBook is null)
{
//This is only possible if the user's db has some malformed
//entries from earlier Libation releases that could not be
//automatically fixed. Log, but don't throw.
Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames());
return;
}
seriesEntry = new SeriesEntry(seriesBook, new[] { episodeBook });
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
seriesEntry.Liberate.Expanded = true;
SOURCE.Insert(0, seriesEntry);
}
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new(episodeBook) { Parent = seriesEntry };
seriesEntry.Children.Add(episodeEntry);
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//Add episode to the grid beneath the parent
int seriesIndex = SOURCE.IndexOf(seriesEntry);
SOURCE.Insert(seriesIndex + 1, episodeEntry);
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
}
private async Task refreshGrid()
{
if (GridEntries.IsEditingItem)
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
}
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
@@ -139,9 +272,6 @@ namespace LibationAvalonia.ViewModels
public async Task Filter(string searchString)
{
if (searchString == FilterString)
return;
FilterString = searchString;
if (SOURCE.Count == 0)
@@ -164,7 +294,7 @@ namespace LibationAvalonia.ViewModels
return FilteredInGridEntries.Contains(item);
}
private static List<GridEntry> QueryResults(List<GridEntry> entries, string searchString)
private static List<GridEntry> QueryResults(IEnumerable<GridEntry> entries, string searchString)
{
if (string.IsNullOrEmpty(searchString)) return null;
@@ -207,10 +337,10 @@ namespace LibationAvalonia.ViewModels
if (selectedBooks.Count == 0)
return;
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
var result = await MessageBox.ShowConfirmationDialog(
null,
libraryBooks,
booksToRemove,
// do not use `$` string interpolation. See impl.
"Are you sure you want to remove {0} from Libation's library?",
"Remove books from Libation?");
@@ -221,8 +351,6 @@ namespace LibationAvalonia.ViewModels
foreach (var book in selectedBooks)
book.PropertyChanged -= GridEntry_PropertyChanged;
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
@@ -241,7 +369,7 @@ namespace LibationAvalonia.ViewModels
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
//so there's no need to remove books from the grid display here.
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
await booksToRemove.RemoveBooksAsync();
RemovableCountChanged?.Invoke(this, 0);
}

View File

@@ -14,6 +14,12 @@ namespace LibationAvalonia.ViewModels
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;

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