mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-08-07 04:42:17 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d6c742ae9 | ||
|
|
933f663d22 | ||
|
|
0c55f278a4 | ||
|
|
3f567ee82e | ||
|
|
8dc912c11d | ||
|
|
f1b4e2a17d | ||
|
|
630cfdeab3 | ||
|
|
7029409792 | ||
|
|
d0727b5a85 | ||
|
|
9f52ad5e0a | ||
|
|
501ae643f7 | ||
|
|
400074170e | ||
|
|
17103ed066 | ||
|
|
b6b29309c9 | ||
|
|
a04538710f | ||
|
|
01f6f5c137 | ||
|
|
b1a37cbd8c | ||
|
|
8c59e1280b | ||
|
|
00339127aa | ||
|
|
5935b40b60 | ||
|
|
6cfd2dea96 | ||
|
|
7d3a39c693 | ||
|
|
6e7a4ea475 | ||
|
|
3479dbc3f0 | ||
|
|
9309aea6d9 | ||
|
|
f72551fa9a | ||
|
|
e3b237b75f | ||
|
|
b1ddf18f73 | ||
|
|
13f522abb8 | ||
|
|
3c3d956bf3 | ||
|
|
8160547c11 | ||
|
|
ef71d36dee | ||
|
|
b0d8434455 | ||
|
|
9be0d58461 | ||
|
|
1addcc8211 | ||
|
|
38c75dc8c5 | ||
|
|
89c3ea8311 | ||
|
|
18ff799fb1 | ||
|
|
67b6aaed99 | ||
|
|
08bb463560 | ||
|
|
97767dcabb | ||
|
|
fb18940a5c | ||
|
|
b823f5fa00 | ||
|
|
d64fb081a0 | ||
|
|
09118b1ddf | ||
|
|
de20590fd5 | ||
|
|
1050ffdb24 | ||
|
|
2e49c7f697 | ||
|
|
708cdcc24c | ||
|
|
c89eafd568 | ||
|
|
10de241d53 | ||
|
|
e58952035f | ||
|
|
50a8c7508a | ||
|
|
2b243a6934 | ||
|
|
ece93cb4d7 | ||
|
|
5b3ca0ed32 | ||
|
|
7474f1221a | ||
|
|
d023a943c1 | ||
|
|
4e80af5c53 | ||
|
|
eee785377f | ||
|
|
915906e6ed | ||
|
|
358c8b577e | ||
|
|
5c450a01a4 | ||
|
|
36264c6c6e | ||
|
|
fca946bf15 | ||
|
|
452ceef285 | ||
|
|
7fafee804d | ||
|
|
3a48479435 | ||
|
|
cab8555ab5 | ||
|
|
e3b7cbcc2a | ||
|
|
ed15614288 | ||
|
|
acb6d1b335 | ||
|
|
fe804796ab | ||
|
|
4725fe36d1 | ||
|
|
5c73beff4b | ||
|
|
1f7000c2c9 | ||
|
|
f09baa1318 | ||
|
|
7eaa03e43c | ||
|
|
26099303fa | ||
|
|
6417aee780 | ||
|
|
f9deaba4c5 | ||
|
|
ddd6a3b279 | ||
|
|
9359950666 | ||
|
|
d31b2a1b65 | ||
|
|
b89b4e0af4 | ||
|
|
cbcde027b3 | ||
|
|
d306e6bd22 | ||
|
|
9ec877999e | ||
|
|
f4189bf409 | ||
|
|
0ed5062683 | ||
|
|
7ef666dc91 | ||
|
|
1ac825919a | ||
|
|
a7bf30954d | ||
|
|
613cfdd903 | ||
|
|
28802c8279 | ||
|
|
6d7b3bd5f0 | ||
|
|
b97d8e9403 | ||
|
|
b4838d364e | ||
|
|
05ac5c63e1 | ||
|
|
874bf9e7c0 | ||
|
|
c9497ef39e | ||
|
|
496830d01d | ||
|
|
ccebcdd4c7 | ||
|
|
c900fe8461 | ||
|
|
a0158db37e | ||
|
|
b8c26b01ad | ||
|
|
3a44bef0d9 | ||
|
|
57a4ee781b | ||
|
|
e12f475850 | ||
|
|
f822a23daa | ||
|
|
6901b8be35 | ||
|
|
83fb2cd1d0 | ||
|
|
c98664d584 | ||
|
|
d098be8b03 | ||
|
|
3f6689d032 | ||
|
|
b4206fc203 | ||
|
|
cfa4a0c07f | ||
|
|
357b220ace | ||
|
|
47968304c9 | ||
|
|
2024d5e116 | ||
|
|
5ae2a99c14 | ||
|
|
7fd002d2c9 | ||
|
|
b7b7038244 | ||
|
|
b5519c4875 | ||
|
|
44feab9eb2 | ||
|
|
96c45c33e5 | ||
|
|
36efbcb812 | ||
|
|
03f44b4e9c | ||
|
|
19860e9f09 | ||
|
|
0701cb3970 | ||
|
|
7d6000e3b6 | ||
|
|
ef973ac56a | ||
|
|
91a1033c52 | ||
|
|
4197db6af9 | ||
|
|
210ab065c2 | ||
|
|
9cd10eca58 | ||
|
|
ba676be46d | ||
|
|
665a2e1866 | ||
|
|
94469cae3d | ||
|
|
a0dd2ccad6 | ||
|
|
b2cf837de7 | ||
|
|
80bcf60b5b | ||
|
|
7ad0ab566a | ||
|
|
2b16e86c7b | ||
|
|
f2ea02ae0b | ||
|
|
f65cd39040 | ||
|
|
5ca0d2a399 | ||
|
|
d1528a095b | ||
|
|
749173a463 | ||
|
|
6fbd90a6b3 | ||
|
|
f39d272e6a | ||
|
|
bb3854f512 | ||
|
|
e40daecfb8 | ||
|
|
3716ab9cb5 | ||
|
|
0cc6d6337a | ||
|
|
ce711a36ba | ||
|
|
451af7bea9 | ||
|
|
63200592bf | ||
|
|
d165dfbeb5 | ||
|
|
eed3d84517 | ||
|
|
ba7d890966 | ||
|
|
5140fc63d9 | ||
|
|
78509c07e0 | ||
|
|
5084141215 | ||
|
|
3f2ac83474 | ||
|
|
58a0468728 | ||
|
|
8e13aa7513 | ||
|
|
48e2d91fc8 | ||
|
|
a7f119217f | ||
|
|
865f2261fe | ||
|
|
dfedb23efd | ||
|
|
c01e1c3e4b | ||
|
|
ad8dac5fb0 | ||
|
|
84e81b6218 | ||
|
|
86efe631fe | ||
|
|
f5f1dc483b | ||
|
|
8aa4328c6c | ||
|
|
a01a8c4b19 | ||
|
|
4b2387b621 | ||
|
|
74d16d8ef9 | ||
|
|
b1ea8f9fa7 | ||
|
|
c666fdeaff | ||
|
|
7068782975 | ||
|
|
c4cebbebe7 | ||
|
|
53d43d9fa9 | ||
|
|
11d59beeed | ||
|
|
ef71e297f4 | ||
|
|
1e4d1d1973 | ||
|
|
893d99854b | ||
|
|
db93980cd5 | ||
|
|
34fac30b2b | ||
|
|
2fa0bcb765 | ||
|
|
78fd09aa91 | ||
|
|
a54516b4f5 | ||
|
|
f193d6f376 | ||
|
|
8a82c294a1 | ||
|
|
9392cf4bf0 | ||
|
|
ec4deb9099 | ||
|
|
cf0548aab9 | ||
|
|
064801380b | ||
|
|
9dc2a7424a | ||
|
|
4c8a56a5b9 | ||
|
|
9aad263996 | ||
|
|
ce1ab7c20d | ||
|
|
c9217990cd | ||
|
|
90cbf3b7a6 | ||
|
|
c4f1b22ddf | ||
|
|
fb612ea6ab | ||
|
|
bce44b6f6d | ||
|
|
7575736991 | ||
|
|
06f8d055fc | ||
|
|
d64e043fe8 | ||
|
|
99564d9c25 | ||
|
|
29bccd3e33 | ||
|
|
20f65f6534 | ||
|
|
8ca72b2e2d | ||
|
|
75429f288f | ||
|
|
d1bb921346 | ||
|
|
b979b6ddad | ||
|
|
4eba41ddbb | ||
|
|
418f5062ff | ||
|
|
f736f7f909 | ||
|
|
96ead28246 | ||
|
|
34bad7a53d | ||
|
|
7ac1fff3a0 | ||
|
|
a4c5c53df3 | ||
|
|
87db5cfd94 | ||
|
|
85e7bbf366 | ||
|
|
c55c5fac23 | ||
|
|
e25e2f7211 | ||
|
|
f310d583d8 | ||
|
|
f05465b29b | ||
|
|
959e31972e | ||
|
|
17181811f0 | ||
|
|
6d2624d52b | ||
|
|
9dd5940c8c | ||
|
|
1927d19961 | ||
|
|
09cc838bb4 | ||
|
|
8af4c71101 | ||
|
|
7ffdf45164 | ||
|
|
e0999dc9ae | ||
|
|
a0f3d44e97 | ||
|
|
1510a86579 | ||
|
|
b3581455d2 | ||
|
|
8ee1019fa5 | ||
|
|
285b10a95f | ||
|
|
0ca33f864b | ||
|
|
a0823fa26c | ||
|
|
aa9040da5d | ||
|
|
222031ecc5 | ||
|
|
dda8f5a974 | ||
|
|
e9b484df04 | ||
|
|
d505264e86 | ||
|
|
c0b1f1dc0a | ||
|
|
1524d558a4 | ||
|
|
aea8c11dc4 | ||
|
|
86c7f89788 | ||
|
|
3272541e81 | ||
|
|
3b3d40e4e6 | ||
|
|
a47866b6f7 | ||
|
|
0df4dfdef5 | ||
|
|
fe2de6ecf7 | ||
|
|
fc25e73b1a | ||
|
|
a3df85c87e | ||
|
|
553a936e7e | ||
|
|
635764625e | ||
|
|
f5599f7c57 | ||
|
|
dc6aaf2dd6 | ||
|
|
f1ba2b4ae8 | ||
|
|
742310b8d6 | ||
|
|
073787173d | ||
|
|
66679ace2f | ||
|
|
3982537d46 | ||
|
|
7cf4c63d79 | ||
|
|
7c4575cf66 | ||
|
|
f4749d703f | ||
|
|
f2f562619b | ||
|
|
16c019a9c6 | ||
|
|
644dcbdd4d | ||
|
|
6b112f5248 | ||
|
|
0bfa609058 | ||
|
|
8020ded642 | ||
|
|
c4cd6b16fc | ||
|
|
310012fd17 | ||
|
|
06163db6ff | ||
|
|
7689eed711 | ||
|
|
d396d697d7 | ||
|
|
27ed11d904 | ||
|
|
9e7670b918 | ||
|
|
31e97defd1 | ||
|
|
1a447627c7 | ||
|
|
962b386d07 | ||
|
|
d69ff24c2d | ||
|
|
070ed1d373 | ||
|
|
47729bf7b0 | ||
|
|
ed0ce2976b | ||
|
|
2224f46ed5 | ||
|
|
433974323c | ||
|
|
7525d318c0 | ||
|
|
92327dcc0d | ||
|
|
aeaf234edd | ||
|
|
a99b644917 | ||
|
|
d79a55e5c9 | ||
|
|
16b0feeb82 | ||
|
|
7b3a25e45a | ||
|
|
8effdcb92d | ||
|
|
b12bef81bd | ||
|
|
f04a5e0168 | ||
|
|
e093729707 | ||
|
|
369151ada2 | ||
|
|
1f685ae8a0 | ||
|
|
bbe91099cb | ||
|
|
92015ba4c2 | ||
|
|
3bcacabadc | ||
|
|
f5736d9151 | ||
|
|
59015f438e | ||
|
|
3af47ab395 | ||
|
|
308619b01a | ||
|
|
4efce57488 | ||
|
|
c8ee950f7d | ||
|
|
0bba0f9256 | ||
|
|
05bdff5123 | ||
|
|
e58e6cfb9f | ||
|
|
b052871004 | ||
|
|
d738f4f35f | ||
|
|
7286aee9dd | ||
|
|
ca455978a5 | ||
|
|
9c38bea5b7 | ||
|
|
fbec1bc569 | ||
|
|
6dd885f0b2 | ||
|
|
ab38eb5571 | ||
|
|
0e4b9ab396 | ||
|
|
7dfedbc73b | ||
|
|
625ae1d63c | ||
|
|
71098ef02f | ||
|
|
d63a6de543 | ||
|
|
2a71a85306 | ||
|
|
6de3a8a2bf | ||
|
|
3fc1da66de | ||
|
|
683c221ca8 | ||
|
|
fe6cfc899b | ||
|
|
ffd947eb2e | ||
|
|
8dd59cb08a | ||
|
|
1e4c489983 | ||
|
|
17b0da358f | ||
|
|
6aa0a1f8b9 | ||
|
|
ab731a63af | ||
|
|
07d2c656fc | ||
|
|
9ecb32c3d2 | ||
|
|
503e1e143e | ||
|
|
e34ce67a2c | ||
|
|
a0fd0a3de6 | ||
|
|
7f3cbc454f | ||
|
|
30eb117fa1 | ||
|
|
63877160aa | ||
|
|
77e61479cf | ||
|
|
ca71283108 | ||
|
|
285563af5e | ||
|
|
62cbad0d8f | ||
|
|
2cb2479d63 | ||
|
|
e7c5b1d8dc | ||
|
|
7f086aeaac | ||
|
|
78186d4973 | ||
|
|
4d84174ba6 | ||
|
|
579536f65a | ||
|
|
a4ff739684 | ||
|
|
9e06c70319 | ||
|
|
0c98ce000b | ||
|
|
230b23dc80 | ||
|
|
d55b8eeeba | ||
|
|
decf75411f | ||
|
|
c69f14dac5 | ||
|
|
10359aa5e8 | ||
|
|
72e030faaf | ||
|
|
b21055d0ea | ||
|
|
720fd64c97 | ||
|
|
e9a331292a | ||
|
|
51fee4ae24 | ||
|
|
4cfe72a63b | ||
|
|
6a8476c976 | ||
|
|
8bb17d09c3 | ||
|
|
ad6b86fcb4 | ||
|
|
1578be2520 | ||
|
|
82d8d954ef | ||
|
|
eff9c2b35d | ||
|
|
ccdd1dc9f3 | ||
|
|
952173d450 | ||
|
|
35f677a0fa | ||
|
|
51d0645699 | ||
|
|
0189a197a8 | ||
|
|
1ce5fedc8c | ||
|
|
d336848ed0 | ||
|
|
8cd6219bd9 | ||
|
|
c2a2e51bde | ||
|
|
d62821cd60 | ||
|
|
180d591b0a | ||
|
|
7b7e1d8574 | ||
|
|
efd6156fa8 | ||
|
|
428ea5e864 | ||
|
|
2b6d1201b6 | ||
|
|
de3524d688 | ||
|
|
61a529e62b | ||
|
|
a5d225dc44 | ||
|
|
7b28a274a8 | ||
|
|
26508e6a8a | ||
|
|
c8d91032c0 | ||
|
|
7a8e910697 | ||
|
|
31d6fc8197 | ||
|
|
e23e267d17 | ||
|
|
c727286d22 | ||
|
|
3a61c32881 | ||
|
|
e33fd6ea1b | ||
|
|
aa8e3ac09b | ||
|
|
eb49dcfc54 | ||
|
|
6182b2bcee | ||
|
|
6e091230cf | ||
|
|
5f45d28b9f | ||
|
|
f8e9c16bc1 | ||
|
|
a66b7a6eab | ||
|
|
3b42b52ff4 | ||
|
|
df5293ce1e | ||
|
|
664ff6aabd | ||
|
|
0de62ce010 | ||
|
|
9eafbacad9 | ||
|
|
058eb31110 | ||
|
|
29de8f5706 | ||
|
|
ef869dbe09 | ||
|
|
9f8b320493 | ||
|
|
ef72e04be3 | ||
|
|
38d280b7f4 | ||
|
|
468356d676 | ||
|
|
7364700899 | ||
|
|
e65f19cf24 | ||
|
|
4272dfe03d | ||
|
|
3b739328fb |
No files matched your search
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve Libation
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Platform**
|
||||||
|
|
||||||
|
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||||
|
|
||||||
|
**Log Files**
|
||||||
|
Attach your Libation log file here.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# build-linux.yml
|
||||||
|
# Reusable workflow that builds the Linux and MacOS versions of Libation.
|
||||||
|
---
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version_override:
|
||||||
|
type: string
|
||||||
|
description: 'Version number override'
|
||||||
|
required: false
|
||||||
|
run_unit_tests:
|
||||||
|
type: boolean
|
||||||
|
description: 'Skip running unit tests'
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_CONFIGURATION: 'Release'
|
||||||
|
DOTNET_VERSION: '7.0.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [Linux, MacOS]
|
||||||
|
ui: [Avalonia]
|
||||||
|
release_name: [chardonnay]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v3
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
env:
|
||||||
|
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
inputVersion="${{ inputs.version_override }}"
|
||||||
|
if [[ "${#inputVersion}" -gt 0 ]]
|
||||||
|
then
|
||||||
|
version="${inputVersion}"
|
||||||
|
else
|
||||||
|
version="$(grep -oP '(?<=<Version>).*(?=</Version)' ./Source/AppScaffolding/AppScaffolding.csproj)"
|
||||||
|
fi
|
||||||
|
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Unit test
|
||||||
|
if: ${{ inputs.run_unit_tests }}
|
||||||
|
working-directory: ./Source
|
||||||
|
run: dotnet test
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Zip artifact
|
||||||
|
id: zip
|
||||||
|
working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}
|
||||||
|
run: |
|
||||||
|
delfiles=("libmp3lame.x86.dll" "libmp3lame.x64.dll" "ffmpegaac.x86.dll" "ffmpegaac.x64.dll" "ZipExtractor.exe")
|
||||||
|
for n in "${delfiles[@]}"; do rm "$n"; done
|
||||||
|
osbuild="$(echo '${{ matrix.os }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
artifact="Libation.${{ steps.get_version.outputs.version }}-${osbuild}-${{ matrix.release_name }}"
|
||||||
|
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||||
|
tar -zcvf "../${artifact}.tar.gz" .
|
||||||
|
|
||||||
|
- name: Publish artifact
|
||||||
|
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
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# build-windows.yml
|
||||||
|
# Reusable workflow that builds the Windows versions of Libation.
|
||||||
|
---
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version_override:
|
||||||
|
type: string
|
||||||
|
description: 'Version number override'
|
||||||
|
required: false
|
||||||
|
run_unit_tests:
|
||||||
|
type: boolean
|
||||||
|
description: 'Skip running unit tests'
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_CONFIGURATION: 'Release'
|
||||||
|
DOTNET_VERSION: '7.0.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: windows-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [Windows]
|
||||||
|
ui: [Avalonia]
|
||||||
|
release_name: [chardonnay]
|
||||||
|
include:
|
||||||
|
- os: Windows
|
||||||
|
ui: WinForms
|
||||||
|
release_name: classic
|
||||||
|
prefix: Classic-
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v3
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
env:
|
||||||
|
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Get version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
if ("${{ inputs.version_override }}".length -gt 0) {
|
||||||
|
$version = "${{ inputs.version_override }}"
|
||||||
|
} else {
|
||||||
|
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
|
||||||
|
}
|
||||||
|
"version=$version" >> $env:GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Unit test
|
||||||
|
if: ${{ inputs.run_unit_tests }}
|
||||||
|
working-directory: ./Source
|
||||||
|
run: dotnet test
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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 } }
|
||||||
|
$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"
|
||||||
|
|
||||||
|
- name: Publish artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ steps.zip.outputs.artifact }}.zip
|
||||||
|
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# build.yml
|
||||||
|
# Reusable workflow that builds Libation for all platforms.
|
||||||
|
---
|
||||||
|
name: build
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version_override:
|
||||||
|
type: string
|
||||||
|
description: 'Version number override'
|
||||||
|
required: false
|
||||||
|
run_unit_tests:
|
||||||
|
type: boolean
|
||||||
|
description: 'Skip running unit tests'
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
windows:
|
||||||
|
uses: ./.github/workflows/build-windows.yml
|
||||||
|
with:
|
||||||
|
version_override: ${{ inputs.version_override }}
|
||||||
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
|
|
||||||
|
linux:
|
||||||
|
uses: ./.github/workflows/build-linux.yml
|
||||||
|
with:
|
||||||
|
version_override: ${{ inputs.version_override }}
|
||||||
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# deb.yml
|
||||||
|
# Reusable workflow that builds the Linux Debian package.
|
||||||
|
---
|
||||||
|
name: deb
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: 'Version number'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
FILE_NAME: "Libation.${{ inputs.version }}-linux-chardonnay"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_deb:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Download Artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: "${{ env.FILE_NAME }}.tar.gz"
|
||||||
|
|
||||||
|
- name: Build .deb
|
||||||
|
id: deb
|
||||||
|
run: |
|
||||||
|
./Scripts/targz2deb.sh "${{ env.FILE_NAME }}.tar.gz" ${{ inputs.version }}
|
||||||
|
|
||||||
|
- name: Publish .deb
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ env.FILE_NAME }}.deb
|
||||||
|
path: ${{ env.FILE_NAME }}.deb
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# docker.yml
|
||||||
|
# Reusable workflow that builds a docker image for Libation.
|
||||||
|
---
|
||||||
|
name: docker
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: 'Version number'
|
||||||
|
required: true
|
||||||
|
secrets:
|
||||||
|
docker_username:
|
||||||
|
required: true
|
||||||
|
docker_token:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOCKER_IMAGE: ${{ secrets.docker_username }}/libation
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.docker_username }}
|
||||||
|
password: ${{ secrets.docker_token }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v3
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
||||||
|
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# release.yml
|
||||||
|
# Builds and creates the release on any tags starting with a `v`
|
||||||
|
---
|
||||||
|
name: release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
jobs:
|
||||||
|
prerelease:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Get tag version
|
||||||
|
id: get_version
|
||||||
|
run: |
|
||||||
|
export TAG='${{ github.ref_name }}'
|
||||||
|
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: [prerelease]
|
||||||
|
uses: ./.github/workflows/docker.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.prerelease.outputs.version }}
|
||||||
|
secrets:
|
||||||
|
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: [prerelease]
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
|
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]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
id: release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||||
|
body: <Put a body here>
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload release assets
|
||||||
|
uses: dwenegar/upload-release-assets@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
|
with:
|
||||||
|
release_id: '${{ steps.release.outputs.id }}'
|
||||||
|
assets_path: ./artifacts
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# validate.yml
|
||||||
|
# Validates that Libation will build on a pull request or push to master.
|
||||||
|
---
|
||||||
|
name: validate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
uses: ./.github/workflows/build.yml
|
||||||
@@ -370,3 +370,4 @@ FodyWeavers.xsd
|
|||||||
|
|
||||||
/__TODO.txt
|
/__TODO.txt
|
||||||
/DataLayer/LibationContext.db
|
/DataLayer/LibationContext.db
|
||||||
|
*/bin-Avalonia
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Rewire echo to print date time
|
||||||
|
echo() {
|
||||||
|
if [[ -n $1 ]]; then
|
||||||
|
printf "$(date '+%F %T'): %s\n" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ################################
|
||||||
|
# Setup
|
||||||
|
# ################################
|
||||||
|
echo "Starting"
|
||||||
|
if [[ -z "${SLEEP_TIME}" ]]; then
|
||||||
|
echo "No sleep time passed in. Will run once and exit."
|
||||||
|
else
|
||||||
|
echo "Sleep time is set to ${SLEEP_TIME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if the config directory is passed in, and there is no link to it then create the link.
|
||||||
|
if [ -d "/config" ] && [ ! -d "/root/Libation" ]; then
|
||||||
|
echo "Linking config directory to the Libation config directory"
|
||||||
|
ln -s /config/ /root/Libation
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If no config error and exit
|
||||||
|
if [ ! -d "/config" ]; then
|
||||||
|
echo "ERROR: No /config directory. You must pass in a volume containing your config mapped to /config"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If user passes in db from a /db/ folder and a db does not already exist / is not already linked
|
||||||
|
FILE=/db/LibationContext.db
|
||||||
|
if [ -f "${FILE}" ] && [ ! -f "/config/LibationContext.db" ]; then
|
||||||
|
echo "Linking passed in Libation database from /db/ to the Libation config directory"
|
||||||
|
ln -s $FILE /config/LibationContext.db
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Confirm we have a db in the config direcotry.
|
||||||
|
if [ ! -f "/config/LibationContext.db" ]; then
|
||||||
|
echo "ERROR: No Libation database detected, exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ################################
|
||||||
|
# Loop and liberate
|
||||||
|
# ################################
|
||||||
|
while true
|
||||||
|
do
|
||||||
|
echo ""
|
||||||
|
echo "Scanning accounts"
|
||||||
|
/libation/LibationCli scan
|
||||||
|
echo "Liberating books"
|
||||||
|
/libation/LibationCli liberate
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Liberate only once if SLEEP_TIME was set to -1
|
||||||
|
if [ "${SLEEP_TIME}" = -1 ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Sleeping for ${SLEEP_TIME}"
|
||||||
|
sleep "${SLEEP_TIME}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Exiting"
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Dockerfile
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
||||||
|
|
||||||
|
COPY Source /Source
|
||||||
|
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
||||||
|
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
|
||||||
|
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:7.0
|
||||||
|
|
||||||
|
ENV SLEEP_TIME "30m"
|
||||||
|
|
||||||
|
# Sets the character set that will be used for folder and filenames when liberating
|
||||||
|
ENV LANG C.UTF-8
|
||||||
|
ENV LC_ALL C.UTF-8
|
||||||
|
|
||||||
|
RUN mkdir /db /config /data
|
||||||
|
|
||||||
|
COPY --from=build-env /Source/bin/Publish/Linux-chardonnay /libation
|
||||||
|
|
||||||
|
|
||||||
|
CMD ["./libation/liberate.sh"]
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
# Advanced: Table of Contents
|
# Advanced: Table of Contents
|
||||||
|
|
||||||
- [Files and folders](#files-and-folders)
|
- [Files and folders](#files-and-folders)
|
||||||
- [Linux and Mac (unofficial)](#linux-and-mac)
|
|
||||||
- [Settings](#settings)
|
- [Settings](#settings)
|
||||||
- [Custom File Naming](#custom-file-naming)
|
- [Custom File Naming](#custom-file-naming)
|
||||||
- [Command Line Interface](#command-line-interface)
|
- [Command Line Interface](#command-line-interface)
|
||||||
@@ -25,10 +24,6 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
|||||||
|
|
||||||
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||||
|
|
||||||
### Linux and Mac
|
|
||||||
|
|
||||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592), [Linux and WINE](https://github.com/rmcrackan/Libation/issues/28#issuecomment-1161111014))
|
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
||||||
@@ -76,4 +71,15 @@ export library to file
|
|||||||
libationcli export -p "C:\foo\bar\my.csv" -c
|
libationcli export -p "C:\foo\bar\my.csv" -c
|
||||||
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
||||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
||||||
|
|
||||||
|
Set download statuses throughout library based on whether each book's audio file can be found.
|
||||||
|
Must include at least one flag: --downloaded , --not-downloaded.
|
||||||
|
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
|
||||||
|
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
|
||||||
|
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
|
||||||
|
CLI: Full library. No prompt
|
||||||
|
|
||||||
|
libationcli set-status -d
|
||||||
|
libationcli set-status -n
|
||||||
|
libationcli set-status -d -n
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
|
||||||
|
|
||||||
|
In Settings.json, make the following changes:
|
||||||
|
* Change `Books` to `/data`
|
||||||
|
* Change `InProgress` to `/tmp`
|
||||||
|
|
||||||
|
### Running
|
||||||
|
Once the configuration files are copied and edited, the docker image can be run with the following command.
|
||||||
|
```
|
||||||
|
sudo docker run -d \
|
||||||
|
-v /opt/libation/config:/config \
|
||||||
|
-v /opt/libation/books:/data \
|
||||||
|
--name libation \
|
||||||
|
--restart=always \
|
||||||
|
rmcrackan/libation
|
||||||
|
```
|
||||||
|
|
||||||
|
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo docker run -d \
|
||||||
|
-v /opt/libation/config:/config \
|
||||||
|
-v /opt/libation/books:/data \
|
||||||
|
-e SLEEP_TIME='10m' \
|
||||||
|
--name libation \
|
||||||
|
--restart=always \
|
||||||
|
rmcrackan/libation
|
||||||
|
```
|
||||||
|
|
||||||
@@ -22,7 +22,12 @@
|
|||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
|
* Windows
|
||||||
|
|
||||||
|
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
|
||||||
|
|
||||||
|
* [Ubuntu Linux](InstallOnLinux.md)
|
||||||
|
* [MacOS](InstallOnMac.md)
|
||||||
|
|
||||||
### Create Accounts
|
### Create Accounts
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
### Install and Run Libation on Ubuntu
|
||||||
|
|
||||||
|
New Libation releases are automatically packed into a debian package and are available from the Libation repository's releases page.
|
||||||
|
|
||||||
|
Run this command in your terminal to dowbnload and install Libation, replacing the url with the Latest Libation .deb package url:
|
||||||
|
|
||||||
|
```Console
|
||||||
|
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
|
||||||
|
sudo apt install ./libation.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
You should now see Libation among your applications.
|
||||||
|
|
||||||
|
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
|
||||||
|
|
||||||
|
Report bugs to https://github.com/rmcrackan/Libation/issues
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```console
|
||||||
|
chmod +x ./Libation
|
||||||
|
sudo spctl --add --label "Libation" ./Libation
|
||||||
|
./Libation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trouble with Gatekeeper?
|
||||||
|
|
||||||
|
If Gatekeeper is giving you trouble with Libation:
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Get Libation running on Mac
|
||||||
|
|
||||||
|
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/213933357-983d8ede-2738-4b32-9c6e-40de21ff09c2.mp4)
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
- [Filters](Documentation/SearchingAndFiltering.md#filters)
|
- [Filters](Documentation/SearchingAndFiltering.md#filters)
|
||||||
- [Advanced](Documentation/Advanced.md)
|
- [Advanced](Documentation/Advanced.md)
|
||||||
- [Files and folders](Documentation/Advanced.md#files-and-folders)
|
- [Files and folders](Documentation/Advanced.md#files-and-folders)
|
||||||
- [Linux and Mac (unofficial)](Documentation/Advanced.md#linux-and-mac)
|
|
||||||
- [Settings](Documentation/Advanced.md#settings)
|
- [Settings](Documentation/Advanced.md#settings)
|
||||||
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
|
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
|
||||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||||
|
- [Docker](Documentation/Docker.md)
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
|
|
||||||
### The bad
|
### The bad
|
||||||
|
|
||||||
* Windows only
|
* Only fully supported in Windows. (Mac and Linux are in beta)
|
||||||
* Large file size
|
* Large file size
|
||||||
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
|
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
|
||||||
|
|
||||||
|
|||||||
Executable
+136
@@ -0,0 +1,136 @@
|
|||||||
|
#!/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 --build $FOLDER_MAIN
|
||||||
|
|
||||||
|
rm -r "$FOLDER_MAIN"
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"isRoot": true,
|
|
||||||
"tools": {
|
|
||||||
"frogvall.dotnetbumpversion": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"commands": [
|
|
||||||
"bump-version"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,9 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.10" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -16,6 +12,9 @@
|
|||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AAXClean.Codecs" Version="0.5.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ namespace AaxDecrypter
|
|||||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||||
|
|
||||||
protected AaxFile AaxFile;
|
protected AaxFile AaxFile;
|
||||||
|
protected Mp4Operation aaxConversion;
|
||||||
|
|
||||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||||
@@ -32,29 +33,17 @@ namespace AaxDecrypter
|
|||||||
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (DownloadOptions.FixupFile && !string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
|
||||||
|
|
||||||
//Finishing configuring lame encoder.
|
//Finishing configuring lame encoder.
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||||
{
|
MpegUtil.ConfigureLameOptions(
|
||||||
double bitrateMultiple = 1;
|
AaxFile,
|
||||||
|
DownloadOptions.LameConfig,
|
||||||
|
DownloadOptions.Downsample,
|
||||||
|
DownloadOptions.MatchSourceBitrate);
|
||||||
|
|
||||||
if (AaxFile.AudioChannels == 2)
|
|
||||||
{
|
|
||||||
if (DownloadOptions.Downsample)
|
|
||||||
bitrateMultiple = 0.5;
|
|
||||||
else
|
|
||||||
DownloadOptions.LameConfig.Mode = NAudio.Lame.MPEGMode.Stereo;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DownloadOptions.MatchSourceBitrate)
|
|
||||||
{
|
|
||||||
int kbps = (int)(AaxFile.AverageBitrate * bitrateMultiple / 1024);
|
|
||||||
|
|
||||||
if (DownloadOptions.LameConfig.VBR is null)
|
|
||||||
DownloadOptions.LameConfig.BitRate = kbps;
|
|
||||||
else if (DownloadOptions.LameConfig.VBR == NAudio.Lame.VBRMode.ABR)
|
|
||||||
DownloadOptions.LameConfig.ABRRateKbps = kbps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||||
@@ -113,9 +102,9 @@ namespace AaxDecrypter
|
|||||||
public override async Task CancelAsync()
|
public override async Task CancelAsync()
|
||||||
{
|
{
|
||||||
IsCanceled = true;
|
IsCanceled = true;
|
||||||
if (AaxFile != null)
|
if (aaxConversion != null)
|
||||||
await AaxFile.CancelAsync();
|
await aaxConversion.CancelAsync();
|
||||||
AaxFile?.Dispose();
|
AaxFile?.Close();
|
||||||
CloseInputFileStream();
|
CloseInputFileStream();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
private List<string> multiPartFilePaths { get; } = new List<string>();
|
||||||
|
private FileStream workingFileStream;
|
||||||
|
|
||||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||||
@@ -43,7 +44,21 @@ namespace AaxDecrypter
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//Step 3
|
//Step 3
|
||||||
|
if (DownloadOptions.DownloadClipsBookmarks)
|
||||||
|
{
|
||||||
|
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
|
||||||
|
if (await Task.Run(Step_DownloadClipsBookmarks))
|
||||||
|
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Step 4
|
||||||
Serilog.Log.Information("Begin Cleanup");
|
Serilog.Log.Information("Begin Cleanup");
|
||||||
if (await Task.Run(Step_Cleanup))
|
if (await Task.Run(Step_Cleanup))
|
||||||
Serilog.Log.Information("Completed Cleanup");
|
Serilog.Log.Information("Completed Cleanup");
|
||||||
@@ -116,21 +131,39 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
// reset, just in case
|
// reset, just in case
|
||||||
multiPartFilePaths.Clear();
|
multiPartFilePaths.Clear();
|
||||||
|
|
||||||
ConversionResult result;
|
try
|
||||||
|
{
|
||||||
|
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
||||||
|
aaxConversion = ConvertToMultiMp4a(splitChapters);
|
||||||
|
else
|
||||||
|
aaxConversion = ConvertToMultiMp3(splitChapters);
|
||||||
|
|
||||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
await aaxConversion;
|
||||||
result = await ConvertToMultiMp4a(splitChapters);
|
|
||||||
else
|
|
||||||
result = await ConvertToMultiMp3(splitChapters);
|
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
if (aaxConversion.IsCompletedSuccessfully)
|
||||||
|
moveMoovToBeginning(workingFileStream?.Name);
|
||||||
|
|
||||||
return result == ConversionResult.NoErrorsDetected;
|
return aaxConversion.IsCompletedSuccessfully;
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "AAXClean Error");
|
||||||
|
workingFileStream?.Close();
|
||||||
|
if (workingFileStream?.Name is not null)
|
||||||
|
FileUtility.SaferDelete(workingFileStream.Name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (aaxConversion is not null)
|
||||||
|
aaxConversion.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||||
|
|
||||||
|
Step_DownloadAudiobook_End(zeroProgress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
|
private Mp4Operation ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||||
{
|
{
|
||||||
var chapterCount = 0;
|
var chapterCount = 0;
|
||||||
return AaxFile.ConvertToMultiMp4aAsync
|
return AaxFile.ConvertToMultiMp4aAsync
|
||||||
@@ -141,7 +174,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
|
private Mp4Operation ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||||
{
|
{
|
||||||
var chapterCount = 0;
|
var chapterCount = 0;
|
||||||
return AaxFile.ConvertToMultiMp3Async
|
return AaxFile.ConvertToMultiMp3Async
|
||||||
@@ -166,24 +199,39 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
PartsTotal = splitChapters.Count,
|
PartsTotal = splitChapters.Count,
|
||||||
Title = newSplitCallback?.Chapter?.Title,
|
Title = newSplitCallback?.Chapter?.Title,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
moveMoovToBeginning(workingFileStream?.Name);
|
||||||
|
|
||||||
newSplitCallback.OutputFile = createOutputFileStream(props);
|
newSplitCallback.OutputFile = createOutputFileStream(props);
|
||||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
|
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
|
||||||
newSplitCallback.TrackNumber = currentChapter;
|
newSplitCallback.TrackNumber = currentChapter;
|
||||||
newSplitCallback.TrackCount = splitChapters.Count;
|
newSplitCallback.TrackCount = splitChapters.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void moveMoovToBeginning(string filename)
|
||||||
|
{
|
||||||
|
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||||
|
&& DownloadOptions.MoveMoovToBeginning
|
||||||
|
&& filename is not null
|
||||||
|
&& File.Exists(filename))
|
||||||
|
{
|
||||||
|
Mp4File.RelocateMoovAsync(filename).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||||
{
|
{
|
||||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||||
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters);
|
var extension = Path.GetExtension(fileName);
|
||||||
|
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters, extension);
|
||||||
|
|
||||||
multiPartFilePaths.Add(fileName);
|
multiPartFilePaths.Add(fileName);
|
||||||
|
|
||||||
FileUtility.SaferDelete(fileName);
|
FileUtility.SaferDelete(fileName);
|
||||||
|
|
||||||
var file = File.Open(fileName, FileMode.OpenOrCreate);
|
workingFileStream = File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
OnFileCreated(fileName);
|
OnFileCreated(fileName);
|
||||||
return file;
|
return workingFileStream;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
using AAXClean.Codecs;
|
using AAXClean.Codecs;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using Mpeg4Lib.Util;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -49,6 +50,19 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Step 4
|
//Step 4
|
||||||
|
if (DownloadOptions.DownloadClipsBookmarks)
|
||||||
|
{
|
||||||
|
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
|
||||||
|
if (await Task.Run(Step_DownloadClipsBookmarks))
|
||||||
|
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Step 5
|
||||||
Serilog.Log.Information("Begin Step 4: Cleanup");
|
Serilog.Log.Information("Begin Step 4: Cleanup");
|
||||||
if (await Task.Run(Step_Cleanup))
|
if (await Task.Run(Step_Cleanup))
|
||||||
Serilog.Log.Information("Completed Step 4: Cleanup");
|
Serilog.Log.Information("Completed Step 4: Cleanup");
|
||||||
@@ -77,22 +91,63 @@ namespace AaxDecrypter
|
|||||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
OnFileCreated(OutputFileName);
|
OnFileCreated(OutputFileName);
|
||||||
|
|
||||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
|
||||||
var decryptionResult
|
|
||||||
= DownloadOptions.OutputFormat == OutputFormat.M4b
|
|
||||||
? await AaxFile.ConvertToMp4aAsync(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
|
|
||||||
: await AaxFile.ConvertToMp3Async(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
|
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
DownloadOptions.ChapterInfo = AaxFile.Chapters;
|
try
|
||||||
|
{
|
||||||
|
aaxConversion = decryptAsync(outputFile);
|
||||||
|
aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||||
|
await aaxConversion;
|
||||||
|
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
outputFile.Close();
|
||||||
|
|
||||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
if (aaxConversion.IsCompletedSuccessfully
|
||||||
if (success)
|
&& DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||||
base.OnFileCreated(OutputFileName);
|
&& DownloadOptions.MoveMoovToBeginning)
|
||||||
|
{
|
||||||
|
aaxConversion.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||||
|
aaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
|
||||||
|
aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||||
|
await aaxConversion;
|
||||||
|
}
|
||||||
|
|
||||||
return success;
|
if (aaxConversion.IsCompletedSuccessfully)
|
||||||
|
base.OnFileCreated(OutputFileName);
|
||||||
|
|
||||||
|
return aaxConversion.IsCompletedSuccessfully;
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "AAXClean Error");
|
||||||
|
FileUtility.SaferDelete(OutputFileName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
outputFile.Close();
|
||||||
|
|
||||||
|
if (aaxConversion is not null)
|
||||||
|
aaxConversion.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||||
|
|
||||||
|
Step_DownloadAudiobook_End(zeroProgress);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mp4Operation decryptAsync(Stream outputFile)
|
||||||
|
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 ?
|
||||||
|
AaxFile.ConvertToMp3Async
|
||||||
|
(
|
||||||
|
outputFile,
|
||||||
|
DownloadOptions.LameConfig,
|
||||||
|
DownloadOptions.ChapterInfo,
|
||||||
|
DownloadOptions.TrimOutputToChapterLength
|
||||||
|
)
|
||||||
|
: DownloadOptions.FixupFile ?
|
||||||
|
AaxFile.ConvertToMp4aAsync
|
||||||
|
(
|
||||||
|
outputFile,
|
||||||
|
DownloadOptions.ChapterInfo,
|
||||||
|
DownloadOptions.TrimOutputToChapterLength
|
||||||
|
)
|
||||||
|
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
@@ -49,10 +48,11 @@ namespace AaxDecrypter
|
|||||||
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||||
|
|
||||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
||||||
|
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
|
||||||
|
|
||||||
// delete file after validation is complete
|
// delete file after validation is complete
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
FileUtility.SaferDelete(OutputFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task CancelAsync();
|
public abstract Task CancelAsync();
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ namespace AaxDecrypter
|
|||||||
=> RetrievedNarrators?.Invoke(this, narrators);
|
=> RetrievedNarrators?.Invoke(this, narrators);
|
||||||
protected void OnRetrievedCoverArt(byte[] coverArt)
|
protected void OnRetrievedCoverArt(byte[] coverArt)
|
||||||
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
=> RetrievedCoverArt?.Invoke(this, coverArt);
|
||||||
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
|
||||||
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
|
||||||
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
|
||||||
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
|
||||||
@@ -93,7 +93,7 @@ namespace AaxDecrypter
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||||
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
|
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters, ".cue");
|
||||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||||
OnFileCreated(path);
|
OnFileCreated(path);
|
||||||
}
|
}
|
||||||
@@ -111,8 +111,8 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
|
|
||||||
if (DownloadOptions.AudibleKey is not null &&
|
if (DownloadOptions.AudibleKey is not null &&
|
||||||
DownloadOptions.AudibleIV is not null &&
|
DownloadOptions.AudibleIV is not null &&
|
||||||
DownloadOptions.RetainEncryptedFile)
|
DownloadOptions.RetainEncryptedFile)
|
||||||
{
|
{
|
||||||
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
|
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
|
||||||
@@ -133,14 +133,27 @@ namespace AaxDecrypter
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async Task<bool> Step_DownloadClipsBookmarks()
|
||||||
|
{
|
||||||
|
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
|
||||||
|
{
|
||||||
|
var recordsFile = await DownloadOptions.SaveClipsAndBookmarks(OutputFileName);
|
||||||
|
|
||||||
|
if (File.Exists(recordsFile))
|
||||||
|
OnFileCreated(recordsFile);
|
||||||
|
}
|
||||||
|
return !IsCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
private NetworkFileStreamPersister OpenNetworkFileStream()
|
private NetworkFileStreamPersister OpenNetworkFileStream()
|
||||||
{
|
{
|
||||||
if (!File.Exists(jsonDownloadState))
|
NetworkFileStreamPersister nfsp = default;
|
||||||
return NewNetworkFilePersister();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
if (!File.Exists(jsonDownloadState))
|
||||||
|
return nfsp = NewNetworkFilePersister();
|
||||||
|
|
||||||
|
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
||||||
// If More than ~1 hour has elapsed since getting the download url, it will expire.
|
// If More than ~1 hour has elapsed since getting the download url, it will expire.
|
||||||
// The new url will be to the same file.
|
// The new url will be to the same file.
|
||||||
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
||||||
@@ -150,18 +163,18 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
FileUtility.SaferDelete(TempFilePath);
|
FileUtility.SaferDelete(TempFilePath);
|
||||||
return NewNetworkFilePersister();
|
return nfsp = NewNetworkFilePersister();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (nfsp?.NetworkFileStream is not null)
|
||||||
|
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private NetworkFileStreamPersister NewNetworkFilePersister()
|
private NetworkFileStreamPersister NewNetworkFilePersister()
|
||||||
{
|
{
|
||||||
var headers = new System.Net.WebHeaderCollection
|
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
|
||||||
{
|
|
||||||
{ "User-Agent", DownloadOptions.UserAgent }
|
|
||||||
};
|
|
||||||
|
|
||||||
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, headers);
|
|
||||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public interface IDownloadOptions
|
public interface IDownloadOptions
|
||||||
{
|
{
|
||||||
|
event EventHandler<long> DownloadSpeedChanged;
|
||||||
FileManager.ReplacementCharacters ReplacementCharacters { get; }
|
FileManager.ReplacementCharacters ReplacementCharacters { get; }
|
||||||
string DownloadUrl { get; }
|
string DownloadUrl { get; }
|
||||||
string UserAgent { get; }
|
string UserAgent { get; }
|
||||||
@@ -14,11 +17,16 @@ namespace AaxDecrypter
|
|||||||
bool RetainEncryptedFile { get; }
|
bool RetainEncryptedFile { get; }
|
||||||
bool StripUnabridged { get; }
|
bool StripUnabridged { get; }
|
||||||
bool CreateCueSheet { get; }
|
bool CreateCueSheet { get; }
|
||||||
ChapterInfo ChapterInfo { get; set; }
|
bool DownloadClipsBookmarks { get; }
|
||||||
NAudio.Lame.LameConfig LameConfig { get; set; }
|
long DownloadSpeedBps { get; }
|
||||||
bool Downsample { get; set; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool MatchSourceBitrate { get; set; }
|
bool FixupFile { get; }
|
||||||
|
NAudio.Lame.LameConfig LameConfig { get; }
|
||||||
|
bool Downsample { get; }
|
||||||
|
bool MatchSourceBitrate { get; }
|
||||||
|
bool MoveMoovToBeginning { get; }
|
||||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||||
string GetMultipartTitleName(MultiConvertFileProperties props);
|
string GetMultipartTitleName(MultiConvertFileProperties props);
|
||||||
}
|
Task<string> SaveClipsAndBookmarks(string fileName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using AAXClean;
|
||||||
|
using NAudio.Lame;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AaxDecrypter
|
||||||
|
{
|
||||||
|
public static class MpegUtil
|
||||||
|
{
|
||||||
|
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
|
||||||
|
{
|
||||||
|
double bitrateMultiple = 1;
|
||||||
|
|
||||||
|
if (mp4File.AudioChannels == 2)
|
||||||
|
{
|
||||||
|
if (downsample)
|
||||||
|
bitrateMultiple = 0.5;
|
||||||
|
else
|
||||||
|
lameConfig.Mode = MPEGMode.Stereo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchSourceBitrate)
|
||||||
|
{
|
||||||
|
int kbps = (int)(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
||||||
|
|
||||||
|
if (lameConfig.VBR is null)
|
||||||
|
lameConfig.BitRate = kbps;
|
||||||
|
else if (lameConfig.VBR == VBRMode.ABR)
|
||||||
|
lameConfig.ABRRateKbps = kbps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,6 @@ namespace AaxDecrypter
|
|||||||
public int PartsPosition { get; set; }
|
public int PartsPosition { get; set; }
|
||||||
public int PartsTotal { get; set; }
|
public int PartsTotal { get; set; }
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
public DateTime FileDate { get; } = DateTime.Now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,91 +1,58 @@
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>A resumable, simultaneous file downloader and reader. </summary>
|
||||||
/// A <see cref="CookieContainer"/> for a single Uri.
|
|
||||||
/// </summary>
|
|
||||||
public class SingleUriCookieContainer : CookieContainer
|
|
||||||
{
|
|
||||||
private Uri baseAddress;
|
|
||||||
public Uri Uri
|
|
||||||
{
|
|
||||||
get => baseAddress;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
baseAddress = new UriBuilder(value.Scheme, value.Host).Uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public CookieCollection GetCookies()
|
|
||||||
{
|
|
||||||
return GetCookies(Uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A resumable, simultaneous file downloader and reader.
|
|
||||||
/// </summary>
|
|
||||||
public class NetworkFileStream : Stream, IUpdatable
|
public class NetworkFileStream : Stream, IUpdatable
|
||||||
{
|
{
|
||||||
public event EventHandler Updated;
|
public event EventHandler Updated;
|
||||||
|
|
||||||
#region Public Properties
|
#region Public Properties
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Location to save the downloaded data. </summary>
|
||||||
/// Location to save the downloaded data.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public string SaveFilePath { get; }
|
public string SaveFilePath { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Http(s) address of the file to download. </summary>
|
||||||
/// Http(s) address of the file to download.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public Uri Uri { get; private set; }
|
public Uri Uri { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Http headers to be sent to the server with the request. </summary>
|
||||||
/// All cookies set by caller or by the remote server.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public SingleUriCookieContainer CookieContainer { get; }
|
public Dictionary<string, string> RequestHeaders { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> The position in <see cref="SaveFilePath"/> that has been written and flushed to disk. </summary>
|
||||||
/// Http headers to be sent to the server with the request.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
|
||||||
public WebHeaderCollection RequestHeaders { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The position in <see cref="SaveFilePath"/> that has been written and flushed to disk.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public long WritePosition { get; private set; }
|
public long WritePosition { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> The total length of the <see cref="Uri"/> file to download. </summary>
|
||||||
/// The total length of the <see cref="Uri"/> file to download.
|
|
||||||
/// </summary>
|
|
||||||
[JsonProperty(Required = Required.Always)]
|
[JsonProperty(Required = Required.Always)]
|
||||||
public long ContentLength { get; private set; }
|
public long ContentLength { get; private set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
|
||||||
|
|
||||||
|
private long _speedLimit = 0;
|
||||||
|
/// <summary>bytes per second</summary>
|
||||||
|
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Private Properties
|
#region Private Properties
|
||||||
private HttpWebRequest HttpRequest { get; set; }
|
|
||||||
private FileStream _writeFile { get; }
|
private FileStream _writeFile { get; }
|
||||||
private FileStream _readFile { get; }
|
private FileStream _readFile { get; }
|
||||||
private Stream _networkStream { get; set; }
|
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||||
private bool hasBegunDownloading { get; set; }
|
private EventWaitHandle _downloadedPiece { get; set; }
|
||||||
public bool IsCancelled { get; private set; }
|
private Task _backgroundDownloadTask { get; set; }
|
||||||
private EventWaitHandle downloadEnded { get; set; }
|
|
||||||
private EventWaitHandle downloadedPiece { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -98,19 +65,23 @@ namespace AaxDecrypter
|
|||||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||||
|
|
||||||
|
//Number of times per second the download rate is checkd and throttled
|
||||||
|
private const int THROTTLE_FREQUENCY = 8;
|
||||||
|
|
||||||
|
//Minimum throttle rate. The minimum amount of data that can be throttled
|
||||||
|
//on each iteration of the download loop is DOWNLOAD_BUFF_SZ.
|
||||||
|
public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constructor
|
#region Constructor
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> A resumable, simultaneous file downloader and reader. </summary>
|
||||||
/// A resumable, simultaneous file downloader and reader.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
|
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
|
||||||
/// <param name="uri">Http(s) address of the file to download.</param>
|
/// <param name="uri">Http(s) address of the file to download.</param>
|
||||||
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
|
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
|
||||||
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
|
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
|
||||||
/// <param name="cookies">A <see cref="SingleUriCookieContainer"/> with cookies to send with the <see cref="HttpWebRequest"/>. It will also be populated with any cookies set by the server. </param>
|
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string> requestHeaders = null)
|
||||||
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null)
|
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
|
||||||
@@ -122,8 +93,7 @@ namespace AaxDecrypter
|
|||||||
SaveFilePath = saveFilePath;
|
SaveFilePath = saveFilePath;
|
||||||
Uri = uri;
|
Uri = uri;
|
||||||
WritePosition = writePosition;
|
WritePosition = writePosition;
|
||||||
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
|
RequestHeaders = requestHeaders ?? new();
|
||||||
CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri };
|
|
||||||
|
|
||||||
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
||||||
{
|
{
|
||||||
@@ -139,12 +109,10 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
#region Downloader
|
#region Downloader
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Update the <see cref="JsonFilePersister"/>. </summary>
|
||||||
/// Update the <see cref="JsonFilePersister"/>.
|
|
||||||
/// </summary>
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
RequestHeaders = HttpRequest.Headers;
|
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Updated?.Invoke(this, EventArgs.Empty);
|
Updated?.Invoke(this, EventArgs.Empty);
|
||||||
@@ -155,9 +123,7 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/> </summary>
|
||||||
/// Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
|
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
|
||||||
public void SetUriForSameFile(Uri uriToSameFile)
|
public void SetUriForSameFile(Uri uriToSameFile)
|
||||||
{
|
{
|
||||||
@@ -165,37 +131,29 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
if (uriToSameFile.Host != Uri.Host)
|
if (uriToSameFile.Host != Uri.Host)
|
||||||
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
|
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
|
||||||
if (hasBegunDownloading)
|
if (_backgroundDownloadTask is not null)
|
||||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||||
|
|
||||||
Uri = uriToSameFile;
|
Uri = uriToSameFile;
|
||||||
HttpRequest = WebRequest.CreateHttp(Uri);
|
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||||
|
|
||||||
HttpRequest.CookieContainer = CookieContainer;
|
|
||||||
HttpRequest.Headers = RequestHeaders;
|
|
||||||
//If NetworkFileStream is resuming, Header will already contain a range.
|
|
||||||
HttpRequest.Headers.Remove("Range");
|
|
||||||
HttpRequest.AddRange(WritePosition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||||
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
|
/// <returns>The downloader <see cref="Task"/></returns>
|
||||||
/// </summary>
|
private Task BeginDownloading()
|
||||||
private void BeginDownloading()
|
|
||||||
{
|
{
|
||||||
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
|
|
||||||
|
|
||||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||||
{
|
return Task.CompletedTask;
|
||||||
hasBegunDownloading = true;
|
|
||||||
downloadEnded.Set();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
var response = HttpRequest.GetResponse() as HttpWebResponse;
|
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||||
|
|
||||||
|
foreach (var header in RequestHeaders)
|
||||||
|
request.Headers.Add(header.Key, header.Value);
|
||||||
|
|
||||||
|
var response = new HttpClient().Send(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||||
|
|
||||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||||
@@ -203,24 +161,17 @@ namespace AaxDecrypter
|
|||||||
//Content length is the length of the range request, and it is only equal
|
//Content length is the length of the range request, and it is only equal
|
||||||
//to the complete file length if requesting Range: bytes=0-
|
//to the complete file length if requesting Range: bytes=0-
|
||||||
if (WritePosition == 0)
|
if (WritePosition == 0)
|
||||||
ContentLength = response.ContentLength;
|
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
|
||||||
|
|
||||||
_networkStream = response.GetResponseStream();
|
var networkStream = response.Content.ReadAsStream(_cancellationSource.Token);
|
||||||
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||||
|
|
||||||
//Download the file in the background.
|
//Download the file in the background.
|
||||||
new Thread(() => DownloadFile())
|
return Task.Run(async () => await DownloadFile(networkStream), _cancellationSource.Token);
|
||||||
{ IsBackground = true }
|
|
||||||
.Start();
|
|
||||||
|
|
||||||
hasBegunDownloading = true;
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||||
/// Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.
|
private async Task DownloadFile(Stream networkStream)
|
||||||
/// </summary>
|
|
||||||
private void DownloadFile()
|
|
||||||
{
|
{
|
||||||
var downloadPosition = WritePosition;
|
var downloadPosition = WritePosition;
|
||||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||||
@@ -228,29 +179,44 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
DateTime startTime = DateTime.Now;
|
||||||
|
long bytesReadSinceThrottle = 0;
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
|
bytesRead = await networkStream.ReadAsync(buff, 0, DOWNLOAD_BUFF_SZ, _cancellationSource.Token);
|
||||||
_writeFile.Write(buff, 0, bytesRead);
|
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
|
||||||
|
|
||||||
downloadPosition += bytesRead;
|
downloadPosition += bytesRead;
|
||||||
|
|
||||||
if (downloadPosition > nextFlush)
|
if (downloadPosition > nextFlush)
|
||||||
{
|
{
|
||||||
_writeFile.Flush();
|
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||||
WritePosition = downloadPosition;
|
WritePosition = downloadPosition;
|
||||||
Update();
|
Update();
|
||||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||||
downloadedPiece.Set();
|
_downloadedPiece.Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region throttle
|
||||||
|
|
||||||
|
bytesReadSinceThrottle += bytesRead;
|
||||||
|
|
||||||
|
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
|
||||||
|
{
|
||||||
|
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
|
||||||
|
if (delayMS > 0)
|
||||||
|
await Task.Delay(delayMS, _cancellationSource.Token);
|
||||||
|
|
||||||
|
startTime = DateTime.Now;
|
||||||
|
bytesReadSinceThrottle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
||||||
|
|
||||||
_writeFile.Close();
|
|
||||||
_networkStream.Close();
|
|
||||||
WritePosition = downloadPosition;
|
WritePosition = downloadPosition;
|
||||||
Update();
|
|
||||||
|
|
||||||
if (!IsCancelled && WritePosition < ContentLength)
|
if (!IsCancelled && WritePosition < ContentLength)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
@@ -258,14 +224,16 @@ namespace AaxDecrypter
|
|||||||
if (WritePosition > ContentLength)
|
if (WritePosition > ContentLength)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (TaskCanceledException)
|
||||||
{
|
{
|
||||||
Serilog.Log.Error(ex, "An error was encountered while downloading {Uri}", Uri);
|
Serilog.Log.Information("Download was cancelled");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
downloadedPiece.Set();
|
networkStream.Close();
|
||||||
downloadEnded.Set();
|
_writeFile.Close();
|
||||||
|
_downloadedPiece.Set();
|
||||||
|
Update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,96 +242,7 @@ namespace AaxDecrypter
|
|||||||
#region Json Connverters
|
#region Json Connverters
|
||||||
|
|
||||||
public static JsonSerializerSettings GetJsonSerializerSettings()
|
public static JsonSerializerSettings GetJsonSerializerSettings()
|
||||||
{
|
=> new JsonSerializerSettings();
|
||||||
var settings = new JsonSerializerSettings();
|
|
||||||
settings.Converters.Add(new CookieContainerConverter());
|
|
||||||
settings.Converters.Add(new WebHeaderCollectionConverter());
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class CookieContainerConverter : JsonConverter
|
|
||||||
{
|
|
||||||
public override bool CanConvert(Type objectType)
|
|
||||||
=> objectType == typeof(SingleUriCookieContainer);
|
|
||||||
|
|
||||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var jObj = JObject.Load(reader);
|
|
||||||
|
|
||||||
var result = new SingleUriCookieContainer()
|
|
||||||
{
|
|
||||||
Uri = new Uri(jObj["Uri"].Value<string>()),
|
|
||||||
Capacity = jObj["Capacity"].Value<int>(),
|
|
||||||
MaxCookieSize = jObj["MaxCookieSize"].Value<int>(),
|
|
||||||
PerDomainCapacity = jObj["PerDomainCapacity"].Value<int>()
|
|
||||||
};
|
|
||||||
|
|
||||||
var cookieList = jObj["Cookies"].ToList();
|
|
||||||
|
|
||||||
foreach (var cookie in cookieList)
|
|
||||||
{
|
|
||||||
result.Add(
|
|
||||||
new Cookie
|
|
||||||
{
|
|
||||||
Comment = cookie["Comment"].Value<string>(),
|
|
||||||
HttpOnly = cookie["HttpOnly"].Value<bool>(),
|
|
||||||
Discard = cookie["Discard"].Value<bool>(),
|
|
||||||
Domain = cookie["Domain"].Value<string>(),
|
|
||||||
Expired = cookie["Expired"].Value<bool>(),
|
|
||||||
Expires = cookie["Expires"].Value<DateTime>(),
|
|
||||||
Name = cookie["Name"].Value<string>(),
|
|
||||||
Path = cookie["Path"].Value<string>(),
|
|
||||||
Port = cookie["Port"].Value<string>(),
|
|
||||||
Secure = cookie["Secure"].Value<bool>(),
|
|
||||||
Value = cookie["Value"].Value<string>(),
|
|
||||||
Version = cookie["Version"].Value<int>(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanWrite => true;
|
|
||||||
|
|
||||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var cookies = value as SingleUriCookieContainer;
|
|
||||||
var obj = (JObject)JToken.FromObject(value);
|
|
||||||
var container = cookies.GetCookies();
|
|
||||||
var propertyNames = container.Select(c => JToken.FromObject(c));
|
|
||||||
obj.AddFirst(new JProperty("Cookies", new JArray(propertyNames)));
|
|
||||||
obj.WriteTo(writer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class WebHeaderCollectionConverter : JsonConverter
|
|
||||||
{
|
|
||||||
public override bool CanConvert(Type objectType)
|
|
||||||
=> objectType == typeof(WebHeaderCollection);
|
|
||||||
|
|
||||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var jObj = JObject.Load(reader);
|
|
||||||
var result = new WebHeaderCollection();
|
|
||||||
|
|
||||||
foreach (var kvp in jObj)
|
|
||||||
result.Add(kvp.Key, kvp.Value.Value<string>());
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool CanWrite => true;
|
|
||||||
|
|
||||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
|
||||||
{
|
|
||||||
var jObj = new JObject();
|
|
||||||
var type = value.GetType();
|
|
||||||
var headers = value as WebHeaderCollection;
|
|
||||||
var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k]));
|
|
||||||
jObj.Add(jHeaders);
|
|
||||||
jObj.WriteTo(writer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -383,8 +262,7 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!hasBegunDownloading)
|
_backgroundDownloadTask ??= BeginDownloading();
|
||||||
BeginDownloading();
|
|
||||||
return ContentLength;
|
return ContentLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,18 +279,17 @@ namespace AaxDecrypter
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
|
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
|
||||||
|
|
||||||
public override void Flush() => throw new NotImplementedException();
|
public override void Flush() => throw new InvalidOperationException();
|
||||||
public override void SetLength(long value) => throw new NotImplementedException();
|
public override void SetLength(long value) => throw new InvalidOperationException();
|
||||||
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
|
public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException();
|
||||||
|
|
||||||
public override int Read(byte[] buffer, int offset, int count)
|
public override int Read(byte[] buffer, int offset, int count)
|
||||||
{
|
{
|
||||||
if (!hasBegunDownloading)
|
_backgroundDownloadTask ??= BeginDownloading();
|
||||||
BeginDownloading();
|
|
||||||
|
|
||||||
var toRead = Math.Min(count, Length - Position);
|
var toRead = Math.Min(count, Length - Position);
|
||||||
WaitToPosition(Position + toRead);
|
WaitToPosition(Position + toRead);
|
||||||
return _readFile.Read(buffer, offset, count);
|
return IsCancelled ? 0: _readFile.Read(buffer, offset, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override long Seek(long offset, SeekOrigin origin)
|
public override long Seek(long offset, SeekOrigin origin)
|
||||||
@@ -428,38 +305,32 @@ namespace AaxDecrypter
|
|||||||
return _readFile.Position = newPosition;
|
return _readFile.Position = newPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
|
||||||
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
||||||
private void WaitToPosition(long requiredPosition)
|
private void WaitToPosition(long requiredPosition)
|
||||||
{
|
{
|
||||||
while (WritePosition < requiredPosition
|
while (WritePosition < requiredPosition
|
||||||
&& hasBegunDownloading
|
&& _backgroundDownloadTask?.IsCompleted is false
|
||||||
&& !IsCancelled
|
&& !IsCancelled)
|
||||||
&& !downloadEnded.WaitOne(0))
|
|
||||||
{
|
{
|
||||||
downloadedPiece.WaitOne(100);
|
_downloadedPiece.WaitOne(50);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Close()
|
public override void Close()
|
||||||
{
|
{
|
||||||
IsCancelled = true;
|
_cancellationSource.Cancel();
|
||||||
|
_backgroundDownloadTask?.Wait();
|
||||||
while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ;
|
|
||||||
|
|
||||||
_readFile.Close();
|
_readFile.Close();
|
||||||
_writeFile.Close();
|
_writeFile.Close();
|
||||||
_networkStream?.Close();
|
|
||||||
Update();
|
Update();
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
~NetworkFileStream()
|
~NetworkFileStream()
|
||||||
{
|
{
|
||||||
downloadEnded?.Close();
|
_downloadedPiece?.Close();
|
||||||
downloadedPiece?.Close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
Serilog.Log.Information("Begin downloading unencrypted audiobook.");
|
||||||
|
|
||||||
//Step 1
|
//Step 1
|
||||||
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
|
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
|
||||||
@@ -39,6 +39,19 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Step 3
|
//Step 3
|
||||||
|
if (DownloadOptions.DownloadClipsBookmarks)
|
||||||
|
{
|
||||||
|
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
|
||||||
|
if (await Task.Run(Step_DownloadClipsBookmarks))
|
||||||
|
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//Step 4
|
||||||
Serilog.Log.Information("Begin Step 3: Cleanup");
|
Serilog.Log.Information("Begin Step 3: Cleanup");
|
||||||
if (await Task.Run(Step_Cleanup))
|
if (await Task.Run(Step_Cleanup))
|
||||||
Serilog.Log.Information("Completed Step 3: Cleanup");
|
Serilog.Log.Information("Completed Step 3: Cleanup");
|
||||||
@@ -58,7 +71,6 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public override Task CancelAsync()
|
public override Task CancelAsync()
|
||||||
{
|
{
|
||||||
IsCanceled = true;
|
IsCanceled = true;
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<Version>8.1.5.1</Version>
|
<Version>9.1.0.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Octokit" Version="0.51.0" />
|
<PackageReference Include="Octokit" Version="4.0.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
|
|
||||||
<Exec Command="if $(Configuration) == Debug (
dotnet bump-version revision AppScaffolding.csproj
)" />
|
|
||||||
</Target>
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -5,6 +5,7 @@ using System.Linq;
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
|
using Dinah.Core;
|
||||||
using Dinah.Core.IO;
|
using Dinah.Core.IO;
|
||||||
using Dinah.Core.Logging;
|
using Dinah.Core.Logging;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
@@ -14,8 +15,32 @@ using Serilog;
|
|||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
{
|
{
|
||||||
|
public enum ReleaseIdentifier
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
WindowsClassic,
|
||||||
|
WindowsAvalonia,
|
||||||
|
LinuxAvalonia,
|
||||||
|
MacOSAvalonia
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
public static class LibationScaffolding
|
public static class LibationScaffolding
|
||||||
{
|
{
|
||||||
|
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
||||||
|
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 void SetReleaseIdentifier(ReleaseIdentifier releaseID)
|
||||||
|
=> ReleaseIdentifier = releaseID;
|
||||||
|
|
||||||
// AppScaffolding
|
// AppScaffolding
|
||||||
private static Assembly _executingAssembly;
|
private static Assembly _executingAssembly;
|
||||||
private static Assembly ExecutingAssembly
|
private static Assembly ExecutingAssembly
|
||||||
@@ -33,8 +58,8 @@ namespace AppScaffolding
|
|||||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||||
.Max(a => a.Version);
|
.Max(a => a.Version);
|
||||||
|
|
||||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||||
public static Configuration RunPreConfigMigrations()
|
public static Configuration RunPreConfigMigrations()
|
||||||
{
|
{
|
||||||
// must occur before access to Configuration instance
|
// must occur before access to Configuration instance
|
||||||
// // outdated. kept here as an example of what belongs in this area
|
// // outdated. kept here as an example of what belongs in this area
|
||||||
@@ -52,103 +77,12 @@ namespace AppScaffolding
|
|||||||
public static void RunPostConfigMigrations(Configuration config)
|
public static void RunPostConfigMigrations(Configuration config)
|
||||||
{
|
{
|
||||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||||
PopulateMissingConfigValues(config);
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// migrations go below here
|
// migrations go below here
|
||||||
//
|
//
|
||||||
|
|
||||||
Migrations.migrate_to_v6_6_9(config);
|
Migrations.migrate_to_v6_6_9(config);
|
||||||
Migrations.migrate_from_7_10_1(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void PopulateMissingConfigValues(Configuration config)
|
|
||||||
{
|
|
||||||
config.InProgress ??= Configuration.WinTemp;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
|
||||||
config.AllowLibationFixup = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.CreateCueSheet)))
|
|
||||||
config.CreateCueSheet = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.RetainAaxFile)))
|
|
||||||
config.RetainAaxFile = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
|
||||||
config.SplitFilesByChapter = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.StripUnabridged)))
|
|
||||||
config.StripUnabridged = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.StripAudibleBrandAudio)))
|
|
||||||
config.StripAudibleBrandAudio = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DecryptToLossy)))
|
|
||||||
config.DecryptToLossy = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameTargetBitrate)))
|
|
||||||
config.LameTargetBitrate = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameDownsampleMono)))
|
|
||||||
config.LameDownsampleMono = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameBitrate)))
|
|
||||||
config.LameBitrate = 64;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameConstantBitrate)))
|
|
||||||
config.LameConstantBitrate = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameMatchSourceBR)))
|
|
||||||
config.LameMatchSourceBR = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameVBRQuality)))
|
|
||||||
config.LameVBRQuality = 2;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.BadBook)))
|
|
||||||
config.BadBook = Configuration.BadBookAction.Ask;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ShowImportedStats)))
|
|
||||||
config.ShowImportedStats = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ImportEpisodes)))
|
|
||||||
config.ImportEpisodes = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
|
||||||
config.DownloadEpisodes = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ReplacementCharacters)))
|
|
||||||
config.ReplacementCharacters = FileManager.ReplacementCharacters.Default;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.FolderTemplate)))
|
|
||||||
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.FileTemplate)))
|
|
||||||
config.FileTemplate = Templates.File.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
|
||||||
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ChapterTitleTemplate)))
|
|
||||||
config.ChapterTitleTemplate = Templates.ChapterTitle.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AutoScan)))
|
|
||||||
config.AutoScan = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsVisibilities)))
|
|
||||||
config.GridColumnsVisibilities = new Dictionary<string, bool>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsDisplayIndices)))
|
|
||||||
config.GridColumnsDisplayIndices = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsWidths)))
|
|
||||||
config.GridColumnsWidths = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DownloadCoverArt)))
|
|
||||||
config.DownloadCoverArt = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
|
|
||||||
config.AutoDownloadEpisodes = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||||
@@ -158,6 +92,8 @@ namespace AppScaffolding
|
|||||||
configureLogging(config);
|
configureLogging(config);
|
||||||
logStartupState(config);
|
logStartupState(config);
|
||||||
|
|
||||||
|
// all else should occur after logging
|
||||||
|
|
||||||
wireUpSystemEvents(config);
|
wireUpSystemEvents(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +133,7 @@ namespace AppScaffolding
|
|||||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||||
};
|
};
|
||||||
config.SetObject("Serilog", serilogObj);
|
config.SetNonString(serilogObj, "Serilog");
|
||||||
}
|
}
|
||||||
|
|
||||||
// to restore original: Console.SetOut(origOut);
|
// to restore original: Console.SetOut(origOut);
|
||||||
@@ -215,8 +151,8 @@ namespace AppScaffolding
|
|||||||
// However, empirical testing so far has shown no issues.
|
// However, empirical testing so far has shown no issues.
|
||||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||||
|
|
||||||
#region Console => Serilog tests
|
#region Console => Serilog tests
|
||||||
/*
|
/*
|
||||||
// all below apply to "Console." and "Console.Out."
|
// all below apply to "Console." and "Console.Out."
|
||||||
|
|
||||||
// captured
|
// captured
|
||||||
@@ -255,12 +191,12 @@ namespace AppScaffolding
|
|||||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||||
Console.Write("{0}", new object[] { "arr" });
|
Console.Write("{0}", new object[] { "arr" });
|
||||||
*/
|
*/
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void logStartupState(Configuration config)
|
private static void logStartupState(Configuration config)
|
||||||
{
|
{
|
||||||
@@ -278,7 +214,10 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
AppName = EntryAssembly.GetName().Name,
|
AppName = EntryAssembly.GetName().Name,
|
||||||
Version = BuildVersion.ToString(),
|
Version = BuildVersion.ToString(),
|
||||||
Mode = mode,
|
ReleaseIdentifier,
|
||||||
|
Configuration.OS,
|
||||||
|
InteropFactory.InteropFunctionsType,
|
||||||
|
Mode = mode,
|
||||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||||
@@ -286,7 +225,9 @@ namespace AppScaffolding
|
|||||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||||
|
|
||||||
config.LibationFiles,
|
config.BetaOptIn,
|
||||||
|
config.UseCoverAsFolderIcon,
|
||||||
|
config.LibationFiles,
|
||||||
AudibleFileStorage.BooksDirectory,
|
AudibleFileStorage.BooksDirectory,
|
||||||
|
|
||||||
config.InProgress,
|
config.InProgress,
|
||||||
@@ -297,9 +238,12 @@ namespace AppScaffolding
|
|||||||
AudibleFileStorage.DecryptInProgressDirectory,
|
AudibleFileStorage.DecryptInProgressDirectory,
|
||||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
private static void wireUpSystemEvents(Configuration configuration)
|
if (InteropFactory.InteropFunctionsType is null)
|
||||||
|
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void wireUpSystemEvents(Configuration configuration)
|
||||||
{
|
{
|
||||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
||||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
||||||
@@ -308,8 +252,9 @@ namespace AppScaffolding
|
|||||||
public static UpgradeProperties GetLatestRelease()
|
public static UpgradeProperties GetLatestRelease()
|
||||||
{
|
{
|
||||||
// timed out
|
// timed out
|
||||||
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
|
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||||
if (latest is null)
|
|
||||||
|
if (latest is null || zip is null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var latestVersionString = latest.TagName.Trim('v');
|
var latestVersionString = latest.TagName.Trim('v');
|
||||||
@@ -321,7 +266,7 @@ namespace AppScaffolding
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
// we have an update
|
// we have an update
|
||||||
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
|
|
||||||
var zipUrl = zip?.BrowserDownloadUrl;
|
var zipUrl = zip?.BrowserDownloadUrl;
|
||||||
|
|
||||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||||
@@ -331,13 +276,13 @@ namespace AppScaffolding
|
|||||||
zipUrl
|
zipUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
|
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
|
||||||
}
|
}
|
||||||
private static Octokit.Release getLatestRelease(TimeSpan timeout)
|
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
|
var task = getLatestRelease();
|
||||||
if (task.Wait(timeout))
|
if (task.Wait(timeout))
|
||||||
return task.Result;
|
return task.Result;
|
||||||
|
|
||||||
@@ -347,16 +292,26 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||||
}
|
}
|
||||||
return null;
|
return (null, null);
|
||||||
}
|
}
|
||||||
private static Octokit.Release getLatestRelease()
|
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||||
{
|
{
|
||||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
|
var ownerAccount = "rmcrackan";
|
||||||
|
var repoName = "Libation";
|
||||||
|
|
||||||
|
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||||
|
|
||||||
|
//Download the release index
|
||||||
|
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||||
|
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||||
|
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||||
|
|
||||||
// https://octokitnet.readthedocs.io/en/latest/releases/
|
// https://octokitnet.readthedocs.io/en/latest/releases/
|
||||||
var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult();
|
var releases = await gitHubClient.Repository.Release.GetAll(ownerAccount, repoName);
|
||||||
var latest = releases.First(r => !r.Draft && !r.Prerelease);
|
|
||||||
return latest;
|
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)));
|
||||||
|
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,74 +363,5 @@ namespace AppScaffolding
|
|||||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void migrate_from_7_10_1(Configuration config)
|
|
||||||
{
|
|
||||||
var lastNigrationThres = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
|
|
||||||
|
|
||||||
if (lastNigrationThres) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
|
|
||||||
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
|
|
||||||
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
|
|
||||||
//and those with improperly identified or missing series. This does not solve cases
|
|
||||||
//where individual episodes are in the db with a valid series link, but said series'
|
|
||||||
//parents have not been imported into the database. For those cases, Libation will
|
|
||||||
//attempt fixup by retrieving parents from the catalog endpoint
|
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
|
||||||
|
|
||||||
//This migration removes books and series with SERIES_ prefix that were created
|
|
||||||
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
|
|
||||||
string removeHackSeries = "delete " +
|
|
||||||
"from series " +
|
|
||||||
"where AudibleSeriesId like 'SERIES%'";
|
|
||||||
|
|
||||||
string removeHackBooks = "delete " +
|
|
||||||
"from books " +
|
|
||||||
"where AudibleProductId like 'SERIES%'";
|
|
||||||
|
|
||||||
//Detect series parents that were added to the database as books with ContentType.Episode,
|
|
||||||
//and change them to ContentType.Parent
|
|
||||||
string updateContentType =
|
|
||||||
"UPDATE books " +
|
|
||||||
"SET contenttype = 4 " +
|
|
||||||
"WHERE audibleproductid IN (SELECT books.audibleproductid " +
|
|
||||||
"FROM books " +
|
|
||||||
"INNER JOIN series " +
|
|
||||||
"ON ( books.audibleproductid = " +
|
|
||||||
"series.audibleseriesid) " +
|
|
||||||
"WHERE books.contenttype = 2)";
|
|
||||||
|
|
||||||
//Then detect series parents that were added to the database as books with ContentType.Parent
|
|
||||||
//but are missing a series link, and add the link (don't know how this happened)
|
|
||||||
string addMissingSeriesLink =
|
|
||||||
"INSERT INTO seriesbook " +
|
|
||||||
"SELECT series.seriesid, " +
|
|
||||||
"books.bookid, " +
|
|
||||||
"'- 1' " +
|
|
||||||
"FROM books " +
|
|
||||||
"LEFT OUTER JOIN seriesbook " +
|
|
||||||
"ON books.bookid = seriesbook.bookid " +
|
|
||||||
"INNER JOIN series " +
|
|
||||||
"ON books.audibleproductid = series.audibleseriesid " +
|
|
||||||
"WHERE books.contenttype = 4 " +
|
|
||||||
"AND seriesbook.seriesid IS NULL";
|
|
||||||
|
|
||||||
context.Database.ExecuteSqlRaw(removeHackSeries);
|
|
||||||
context.Database.ExecuteSqlRaw(removeHackBooks);
|
|
||||||
context.Database.ExecuteSqlRaw(updateContentType);
|
|
||||||
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
|
|
||||||
|
|
||||||
LibraryCommands.SaveContext(context);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "An error occurred while running database migrations in {0}", nameof(migrate_from_7_10_1));
|
|
||||||
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ namespace AppScaffolding
|
|||||||
: value;
|
: value;
|
||||||
|
|
||||||
#region appsettings.json
|
#region appsettings.json
|
||||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName), "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_Json_Exists => File.Exists(APPSETTINGS_JSON);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
{
|
{
|
||||||
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
|
public record UpgradeProperties
|
||||||
|
{
|
||||||
|
private static readonly Regex linkstripper = new Regex(@"\[(.*)\]\(.*\)");
|
||||||
|
public string ZipUrl { get; }
|
||||||
|
public string HtmlUrl { get; }
|
||||||
|
public string ZipName { get; }
|
||||||
|
public Version LatestRelease { get; }
|
||||||
|
public string Notes { get; }
|
||||||
|
|
||||||
|
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
|
||||||
|
{
|
||||||
|
ZipName = zipName;
|
||||||
|
HtmlUrl = htmlUrl;
|
||||||
|
ZipUrl = zipUrl;
|
||||||
|
LatestRelease = latestRelease;
|
||||||
|
Notes = stripMarkdownLinks(notes);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="27.2.1" />
|
<PackageReference Include="CsvHelper" Version="30.0.1" />
|
||||||
<PackageReference Include="NPOI" Version="2.5.6" />
|
<PackageReference Include="NPOI" Version="2.6.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DataLayer;
|
||||||
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
|
|
||||||
|
namespace ApplicationServices
|
||||||
|
{
|
||||||
|
public class BulkSetDownloadStatus
|
||||||
|
{
|
||||||
|
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
|
||||||
|
|
||||||
|
public int Count => actionSets.Count;
|
||||||
|
|
||||||
|
public IEnumerable<string> Messages => actionSets.Select(a => a.message);
|
||||||
|
public string AggregateMessage => $"Are you sure you want to set {Messages.Aggregate((a, b) => $"{a} and {b}")}?";
|
||||||
|
|
||||||
|
private List<LibraryBook> _libraryBooks;
|
||||||
|
private bool _setDownloaded;
|
||||||
|
private bool _setNotDownloaded;
|
||||||
|
|
||||||
|
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
|
||||||
|
{
|
||||||
|
_libraryBooks = libraryBooks;
|
||||||
|
_setDownloaded = setDownloaded;
|
||||||
|
_setNotDownloaded = setNotDownloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Discover()
|
||||||
|
{
|
||||||
|
var bookExistsList = _libraryBooks
|
||||||
|
.Select(libraryBook => new
|
||||||
|
{
|
||||||
|
libraryBook.Book,
|
||||||
|
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (_setDownloaded)
|
||||||
|
{
|
||||||
|
var books2change = bookExistsList
|
||||||
|
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||||
|
.Select(a => a.Book)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (books2change.Any())
|
||||||
|
actionSets.Add((
|
||||||
|
$"{"book".PluralizeWithCount(books2change.Count)} to 'Downloaded'",
|
||||||
|
LiberatedStatus.Liberated,
|
||||||
|
books2change));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_setNotDownloaded)
|
||||||
|
{
|
||||||
|
var books2change = bookExistsList
|
||||||
|
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||||
|
.Select(a => a.Book)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (books2change.Any())
|
||||||
|
actionSets.Add((
|
||||||
|
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
|
||||||
|
LiberatedStatus.NotLiberated,
|
||||||
|
books2change));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute()
|
||||||
|
{
|
||||||
|
foreach (var a in actionSets)
|
||||||
|
a.Books.UpdateBookStatus(a.newStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,221 +13,205 @@ using static DtoImporterService.PerfLogger;
|
|||||||
|
|
||||||
namespace ApplicationServices
|
namespace ApplicationServices
|
||||||
{
|
{
|
||||||
public static class LibraryCommands
|
public static class LibraryCommands
|
||||||
{
|
{
|
||||||
public static event EventHandler<int> ScanBegin;
|
public static event EventHandler<int> ScanBegin;
|
||||||
public static event EventHandler ScanEnd;
|
public static event EventHandler ScanEnd;
|
||||||
|
|
||||||
public static bool Scanning { get; private set; }
|
public static bool Scanning { get; private set; }
|
||||||
private static object _lock { get; } = new();
|
private static object _lock { get; } = new();
|
||||||
|
|
||||||
static LibraryCommands()
|
static LibraryCommands()
|
||||||
{
|
{
|
||||||
ScanBegin += (_, __) => Scanning = true;
|
ScanBegin += (_, __) => Scanning = true;
|
||||||
ScanEnd += (_, __) => Scanning = false;
|
ScanEnd += (_, __) => Scanning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
||||||
{
|
{
|
||||||
logRestart();
|
logRestart();
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
|
||||||
if (Scanning)
|
|
||||||
return new();
|
|
||||||
}
|
|
||||||
ScanBegin?.Invoke(null, accounts.Length);
|
|
||||||
|
|
||||||
//These are the minimum response groups required for the
|
|
||||||
//library scanner to pass all validation and filtering.
|
|
||||||
var libraryOptions = new LibraryOptions
|
|
||||||
{
|
{
|
||||||
ResponseGroups
|
if (Scanning)
|
||||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
return new();
|
||||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
}
|
||||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
ScanBegin?.Invoke(null, accounts.Length);
|
||||||
};
|
|
||||||
|
//These are the minimum response groups required for the
|
||||||
|
//library scanner to pass all validation and filtering.
|
||||||
|
var libraryOptions = new LibraryOptions
|
||||||
|
{
|
||||||
|
ResponseGroups
|
||||||
|
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||||
|
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||||
|
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||||
|
};
|
||||||
if (accounts is null || accounts.Length == 0)
|
if (accounts is null || accounts.Length == 0)
|
||||||
return new List<LibraryBook>();
|
return new List<LibraryBook>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = libraryItems.Count;
|
var totalCount = libraryItems.Count;
|
||||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||||
|
|
||||||
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||||
|
|
||||||
return missingBookList;
|
return missingBookList;
|
||||||
}
|
}
|
||||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||||
{
|
{
|
||||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||||
|
|
||||||
// nuget Serilog.Exceptions would automatically log custom properties
|
// nuget Serilog.Exceptions would automatically log custom properties
|
||||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||||
{
|
|
||||||
lfEx.RequestUrl,
|
|
||||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
|
||||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
|
||||||
lfEx.ResponseInputFields,
|
|
||||||
lfEx.ResponseBodyFilePaths
|
|
||||||
});
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Logger.Error(ex, "Error scanning library");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
stop();
|
|
||||||
var putBreakPointHere = logOutput;
|
|
||||||
ScanEnd?.Invoke(null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#region FULL LIBRARY scan and import
|
|
||||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
|
|
||||||
{
|
|
||||||
logRestart();
|
|
||||||
|
|
||||||
if (accounts is null || accounts.Length == 0)
|
|
||||||
return (0, 0);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
{
|
||||||
if (Scanning)
|
lfEx.RequestUrl,
|
||||||
return (0, 0);
|
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||||
}
|
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||||
ScanBegin?.Invoke(null, accounts.Length);
|
lfEx.ResponseInputFields,
|
||||||
|
lfEx.ResponseBodyFilePaths
|
||||||
|
});
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error scanning library");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
var putBreakPointHere = logOutput;
|
||||||
|
ScanEnd?.Invoke(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
#region FULL LIBRARY scan and import
|
||||||
var libraryOptions = new LibraryOptions
|
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
|
||||||
{
|
{
|
||||||
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
|
logRestart();
|
||||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
|
||||||
};
|
if (accounts is null || accounts.Length == 0)
|
||||||
|
return (0, 0);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (Scanning)
|
||||||
|
return (0, 0);
|
||||||
|
}
|
||||||
|
ScanBegin?.Invoke(null, accounts.Length);
|
||||||
|
|
||||||
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
|
var libraryOptions = new LibraryOptions
|
||||||
|
{
|
||||||
|
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
|
||||||
|
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||||
|
};
|
||||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = importItems.Count;
|
var totalCount = importItems.Count;
|
||||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||||
|
|
||||||
if (totalCount == 0)
|
if (totalCount == 0)
|
||||||
return default;
|
return default;
|
||||||
|
|
||||||
|
Log.Logger.Information("Begin long-running import");
|
||||||
|
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||||
|
var newCount = await importIntoDbAsync(importItems);
|
||||||
|
logTime($"post {nameof(importIntoDbAsync)}");
|
||||||
|
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||||
|
|
||||||
Log.Logger.Information("Begin scan for orphaned episode parents");
|
return (totalCount, newCount);
|
||||||
var newParents = await findAndAddMissingParents(accounts);
|
}
|
||||||
Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}");
|
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||||
|
{
|
||||||
|
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||||
|
|
||||||
if (newParents >= 0)
|
// nuget Serilog.Exceptions would automatically log custom properties
|
||||||
{
|
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||||
//If any episodes are still orphaned, their series have been
|
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||||
//removed from the catalog and we'll never be able to find them.
|
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||||
|
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||||
|
{
|
||||||
|
lfEx.RequestUrl,
|
||||||
|
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||||
|
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||||
|
lfEx.ResponseInputFields,
|
||||||
|
lfEx.ResponseBodyFilePaths
|
||||||
|
});
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error importing library");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
var putBreakPointHere = logOutput;
|
||||||
|
ScanEnd?.Invoke(null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//only do this if findAndAddMissingParents returned >= 0. If it
|
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
||||||
//returned < 0, an error happened and there's still a chance that
|
{
|
||||||
//a future successful run will find missing parents.
|
var tasks = new List<Task<List<ImportItem>>>();
|
||||||
removedOrphanedEpisodes();
|
foreach (var account in accounts)
|
||||||
}
|
{
|
||||||
|
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||||
|
var apiExtended = await apiExtendedfunc(account);
|
||||||
|
|
||||||
Log.Logger.Information("Begin long-running import");
|
// add scanAccountAsync as a TASK: do not await
|
||||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
|
||||||
var newCount = await importIntoDbAsync(importItems);
|
}
|
||||||
logTime($"post {nameof(importIntoDbAsync)}");
|
|
||||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
|
||||||
|
|
||||||
return (totalCount, newCount);
|
// import library in parallel
|
||||||
}
|
var arrayOfLists = await Task.WhenAll(tasks);
|
||||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||||
{
|
return importItems;
|
||||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
}
|
||||||
|
|
||||||
// nuget Serilog.Exceptions would automatically log custom properties
|
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
|
||||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
{
|
||||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
|
||||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
|
||||||
{
|
|
||||||
lfEx.RequestUrl,
|
|
||||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
|
||||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
|
||||||
lfEx.ResponseInputFields,
|
|
||||||
lfEx.ResponseBodyFilePaths
|
|
||||||
});
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Logger.Error(ex, "Error importing library");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
stop();
|
|
||||||
var putBreakPointHere = logOutput;
|
|
||||||
ScanEnd?.Invoke(null, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||||
{
|
{
|
||||||
var tasks = new List<Task<List<ImportItem>>>();
|
Account = account?.MaskedLogEntry ?? "[null]"
|
||||||
foreach (var account in accounts)
|
});
|
||||||
{
|
|
||||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
|
||||||
var apiExtended = await apiExtendedfunc(account);
|
|
||||||
|
|
||||||
// add scanAccountAsync as a TASK: do not await
|
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
|
|
||||||
}
|
|
||||||
|
|
||||||
// import library in parallel
|
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
||||||
var arrayOfLists = await Task.WhenAll(tasks);
|
|
||||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
|
||||||
return importItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
|
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||||
{
|
|
||||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
|
||||||
|
|
||||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
||||||
{
|
}
|
||||||
Account = account?.MaskedLogEntry ?? "[null]"
|
|
||||||
});
|
|
||||||
|
|
||||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||||
|
|
||||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
|
||||||
|
|
||||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
|
||||||
|
|
||||||
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
|
||||||
{
|
{
|
||||||
logTime("importIntoDbAsync -- pre db");
|
logTime("importIntoDbAsync -- pre db");
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
var libraryBookImporter = new LibraryBookImporter(context);
|
var libraryBookImporter = new LibraryBookImporter(context);
|
||||||
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
||||||
logTime("importIntoDbAsync -- post Import()");
|
logTime("importIntoDbAsync -- post Import()");
|
||||||
int qtyChanges = SaveContext(context);
|
int qtyChanges = SaveContext(context);
|
||||||
logTime("importIntoDbAsync -- post SaveChanges");
|
logTime("importIntoDbAsync -- post SaveChanges");
|
||||||
|
|
||||||
// this is any changes at all to the database, not just new books
|
// this is any changes at all to the database, not just new books
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
await Task.Run(() => finalizeLibrarySizeChange());
|
await Task.Run(() => finalizeLibrarySizeChange());
|
||||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||||
@@ -235,248 +219,249 @@ namespace ApplicationServices
|
|||||||
return newCount;
|
return newCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void removedOrphanedEpisodes()
|
public static int SaveContext(LibationContext context)
|
||||||
{
|
{
|
||||||
using var context = DbContexts.GetContext();
|
try
|
||||||
try
|
{
|
||||||
{
|
return context.SaveChanges();
|
||||||
var orphanedEpisodes =
|
}
|
||||||
context
|
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
|
||||||
.GetLibrary_Flat_NoTracking(includeParents: true)
|
{
|
||||||
.FindOrphanedEpisodes();
|
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
|
||||||
|
|
||||||
context.LibraryBooks.RemoveRange(orphanedEpisodes);
|
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
|
||||||
context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book));
|
|
||||||
|
|
||||||
}
|
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
|
||||||
catch (Exception ex)
|
if (ex.InnerException is null)
|
||||||
{
|
throw new Exception($"{msg}{format(ex)}");
|
||||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to remove orphaned episodes from the database");
|
throw new Exception(
|
||||||
}
|
$"{msg}{format(ex)}",
|
||||||
}
|
new Exception($"Inner Exception{format(ex.InnerException)}"));
|
||||||
|
}
|
||||||
static async Task<int> findAndAddMissingParents(Account[] accounts)
|
|
||||||
{
|
|
||||||
using var context = DbContexts.GetContext();
|
|
||||||
|
|
||||||
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var orphanedEpisodes = library.FindOrphanedEpisodes().ToList();
|
|
||||||
|
|
||||||
if (!orphanedEpisodes.Any())
|
|
||||||
return -1;
|
|
||||||
|
|
||||||
var orphanedSeries =
|
|
||||||
orphanedEpisodes
|
|
||||||
.SelectMany(lb => lb.Book.SeriesLink)
|
|
||||||
.DistinctBy(s => s.Series.AudibleSeriesId)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// The Catalog endpoint does not require authentication.
|
|
||||||
var api = new ApiUnauthenticated(accounts[0].Locale);
|
|
||||||
|
|
||||||
var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList();
|
|
||||||
var items = await api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
|
|
||||||
List<ImportItem> newParentsImportItems = new();
|
|
||||||
foreach (var sp in orphanedSeries)
|
|
||||||
{
|
|
||||||
var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId);
|
|
||||||
|
|
||||||
if (seriesItem.Relationships is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId);
|
|
||||||
|
|
||||||
seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded);
|
|
||||||
seriesItem.Series = new AudibleApi.Common.Series[]
|
|
||||||
{
|
|
||||||
new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"}
|
|
||||||
};
|
|
||||||
|
|
||||||
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
|
|
||||||
}
|
|
||||||
|
|
||||||
var newCoutn = new LibraryBookImporter(context)
|
|
||||||
.Import(newParentsImportItems);
|
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
|
||||||
|
|
||||||
return newCoutn;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "An error occurred while trying to scan for orphaned episode parents.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static int SaveContext(LibationContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return context.SaveChanges();
|
|
||||||
}
|
|
||||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
|
|
||||||
{
|
|
||||||
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
|
|
||||||
|
|
||||||
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
|
|
||||||
|
|
||||||
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
|
|
||||||
if (ex.InnerException is null)
|
|
||||||
throw new Exception($"{msg}{format(ex)}");
|
|
||||||
throw new Exception(
|
|
||||||
$"{msg}{format(ex)}",
|
|
||||||
new Exception($"Inner Exception{format(ex.InnerException)}"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region remove books
|
#region remove/restore books
|
||||||
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||||
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
|
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
|
||||||
{
|
private static int removeBooks(List<string> idsToRemove)
|
||||||
using var context = DbContexts.GetContext();
|
|
||||||
var libBooks = context.GetLibrary_Flat_NoTracking();
|
|
||||||
|
|
||||||
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
|
|
||||||
context.LibraryBooks.RemoveRange(removeLibraryBooks);
|
|
||||||
context.Books.RemoveRange(removeLibraryBooks.Select(lb => lb.Book));
|
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
|
||||||
if (qtyChanges > 0)
|
|
||||||
finalizeLibrarySizeChange();
|
|
||||||
|
|
||||||
return removeLibraryBooks;
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
// call this whenever books are added or removed from library
|
|
||||||
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
|
|
||||||
|
|
||||||
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
|
||||||
public static event EventHandler LibrarySizeChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
|
||||||
/// </summary>
|
|
||||||
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
|
||||||
|
|
||||||
#region Update book details
|
|
||||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
|
||||||
{
|
|
||||||
book.UserDefinedItem.BookStatus = bookStatus;
|
|
||||||
return UpdateUserDefinedItem(book);
|
|
||||||
}
|
|
||||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
|
||||||
{
|
|
||||||
book.UserDefinedItem.PdfStatus = pdfStatus;
|
|
||||||
return UpdateUserDefinedItem(book);
|
|
||||||
}
|
|
||||||
public static int UpdateBook(
|
|
||||||
this Book book,
|
|
||||||
string tags = null,
|
|
||||||
LiberatedStatus? bookStatus = null,
|
|
||||||
LiberatedStatus? pdfStatus = null)
|
|
||||||
=> UpdateBooks(tags, bookStatus, pdfStatus, book);
|
|
||||||
public static int UpdateBooks(
|
|
||||||
string tags = null,
|
|
||||||
LiberatedStatus? bookStatus = null,
|
|
||||||
LiberatedStatus? pdfStatus = null,
|
|
||||||
params Book[] books)
|
|
||||||
{
|
{
|
||||||
foreach (var book in books)
|
try
|
||||||
{
|
{
|
||||||
// blank tags are expected. null tags are not
|
if (idsToRemove is null || !idsToRemove.Any())
|
||||||
if (tags is not null && book.UserDefinedItem.Tags != tags)
|
return 0;
|
||||||
book.UserDefinedItem.Tags = tags;
|
|
||||||
|
|
||||||
if (bookStatus is not null && book.UserDefinedItem.BookStatus != bookStatus.Value)
|
using var context = DbContexts.GetContext();
|
||||||
book.UserDefinedItem.BookStatus = bookStatus.Value;
|
var libBooks = context.GetLibrary_Flat_NoTracking();
|
||||||
|
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
|
||||||
|
|
||||||
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
if (pdfStatus is not null && book.UserDefinedItem.PdfStatus != pdfStatus.Value)
|
foreach (var lb in removeLibraryBooks)
|
||||||
book.UserDefinedItem.PdfStatus = pdfStatus.Value;
|
{
|
||||||
}
|
lb.IsDeleted = true;
|
||||||
|
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
return UpdateUserDefinedItem(books);
|
var qtyChanges = context.SaveChanges();
|
||||||
}
|
if (qtyChanges > 0)
|
||||||
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
|
finalizeLibrarySizeChange();
|
||||||
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (books is null || !books.Any())
|
|
||||||
return 0;
|
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
return qtyChanges;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error removing books");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
|
||||||
foreach (var book in books)
|
{
|
||||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
try
|
||||||
|
{
|
||||||
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
using var context = DbContexts.GetContext();
|
||||||
if (qtyChanges > 0)
|
|
||||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
|
||||||
|
|
||||||
return qtyChanges;
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
}
|
foreach (var lb in libraryBooks)
|
||||||
catch (Exception ex)
|
{
|
||||||
{
|
lb.IsDeleted = false;
|
||||||
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
|
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
throw;
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
// must be here instead of in db layer due to AaxcExists
|
var qtyChanges = context.SaveChanges();
|
||||||
public static LiberatedStatus Liberated_Status(Book book)
|
if (qtyChanges > 0)
|
||||||
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
finalizeLibrarySizeChange();
|
||||||
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
|
||||||
: LiberatedStatus.NotLiberated;
|
|
||||||
|
|
||||||
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
|
return qtyChanges;
|
||||||
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error restoring books");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
// call this whenever books are added or removed from library
|
||||||
|
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
|
||||||
|
|
||||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
|
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
||||||
{
|
public static event EventHandler LibrarySizeChanged;
|
||||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
|
||||||
public bool HasPendingBooks => PendingBooks > 0;
|
|
||||||
|
|
||||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
|
/// <summary>
|
||||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
|
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||||
}
|
/// </summary>
|
||||||
public static LibraryStats GetCounts()
|
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
||||||
{
|
|
||||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
|
||||||
|
|
||||||
var results = libraryBooks
|
#region Update book details
|
||||||
.AsParallel()
|
public static int UpdateUserDefinedItem(
|
||||||
.Select(lb => Liberated_Status(lb.Book))
|
this Book book,
|
||||||
.ToList();
|
string tags = null,
|
||||||
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
|
LiberatedStatus? bookStatus = null,
|
||||||
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
|
LiberatedStatus? pdfStatus = null,
|
||||||
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
|
Rating rating = null)
|
||||||
var booksError = results.Count(r => r == LiberatedStatus.Error);
|
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||||
|
|
||||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
|
public static int UpdateUserDefinedItem(
|
||||||
|
this IEnumerable<Book> books,
|
||||||
|
string tags = null,
|
||||||
|
LiberatedStatus? bookStatus = null,
|
||||||
|
LiberatedStatus? pdfStatus = null,
|
||||||
|
Rating rating = null)
|
||||||
|
=> updateUserDefinedItem(
|
||||||
|
books,
|
||||||
|
udi => {
|
||||||
|
// blank tags are expected. null tags are not
|
||||||
|
if (tags is not null)
|
||||||
|
udi.Tags = tags;
|
||||||
|
|
||||||
var boolResults = libraryBooks
|
if (bookStatus.HasValue)
|
||||||
.AsParallel()
|
udi.BookStatus = bookStatus.Value;
|
||||||
.Where(lb => lb.Book.HasPdf())
|
|
||||||
.Select(lb => 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 });
|
// method handles null logic
|
||||||
|
udi.SetPdfStatus(pdfStatus);
|
||||||
|
|
||||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
|
if (rating is not null)
|
||||||
}
|
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
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)
|
||||||
|
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||||
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||||
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
|
||||||
|
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
||||||
|
=> book.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
|
||||||
|
=> books.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||||
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
||||||
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
|
||||||
|
public static int UpdateTags(this Book book, string tags)
|
||||||
|
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
public static int UpdateTags(this IEnumerable<Book> books, string tags)
|
||||||
|
=> books.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
||||||
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||||
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
|
||||||
|
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||||
|
=> libraryBook.Book.updateUserDefinedItem(action);
|
||||||
|
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||||
|
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
|
||||||
|
|
||||||
|
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
|
||||||
|
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(action);
|
||||||
|
|
||||||
|
private static int updateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => new[] { book }.updateUserDefinedItem(action);
|
||||||
|
private static int updateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (books is null || !books.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
foreach (var book in books)
|
||||||
|
action?.Invoke(book.UserDefinedItem);
|
||||||
|
|
||||||
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
|
foreach (var book in books)
|
||||||
|
{
|
||||||
|
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
context.Attach(book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
var qtyChanges = context.SaveChanges();
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
BookUserDefinedItemCommitted?.Invoke(null, books);
|
||||||
|
|
||||||
|
return qtyChanges;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
// must be here instead of in db layer due to AaxcExists
|
||||||
|
public static LiberatedStatus Liberated_Status(Book book)
|
||||||
|
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
||||||
|
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
||||||
|
: LiberatedStatus.NotLiberated;
|
||||||
|
|
||||||
|
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
|
||||||
|
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
|
||||||
|
|
||||||
|
// 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 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()
|
||||||
|
{
|
||||||
|
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
|
|
||||||
|
var results = libraryBooks
|
||||||
|
.AsParallel()
|
||||||
|
.Select(lb => 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 boolResults = libraryBooks
|
||||||
|
.AsParallel()
|
||||||
|
.Where(lb => lb.Book.HasPdf())
|
||||||
|
.Select(lb => 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 });
|
||||||
|
|
||||||
|
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,9 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
[Name("Content Type")]
|
[Name("Content Type")]
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
|
|
||||||
|
[Name("Audio Format")]
|
||||||
|
public string AudioFormat { get; set; }
|
||||||
}
|
}
|
||||||
public static class LibToDtos
|
public static class LibToDtos
|
||||||
{
|
{
|
||||||
@@ -132,7 +135,8 @@ namespace ApplicationServices
|
|||||||
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
MyLibationTags = a.Book.UserDefinedItem.Tags,
|
||||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||||
ContentType = a.Book.ContentType.ToString()
|
ContentType = a.Book.ContentType.ToString(),
|
||||||
|
AudioFormat = a.Book.AudioFormat.ToString()
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
@@ -176,33 +180,34 @@ namespace ApplicationServices
|
|||||||
var row = sheet.CreateRow(rowIndex);
|
var row = sheet.CreateRow(rowIndex);
|
||||||
|
|
||||||
var columns = new[] {
|
var columns = new[] {
|
||||||
nameof (ExportDto.Account),
|
nameof(ExportDto.Account),
|
||||||
nameof (ExportDto.DateAdded),
|
nameof(ExportDto.DateAdded),
|
||||||
nameof (ExportDto.AudibleProductId),
|
nameof(ExportDto.AudibleProductId),
|
||||||
nameof (ExportDto.Locale),
|
nameof(ExportDto.Locale),
|
||||||
nameof (ExportDto.Title),
|
nameof(ExportDto.Title),
|
||||||
nameof (ExportDto.AuthorNames),
|
nameof(ExportDto.AuthorNames),
|
||||||
nameof (ExportDto.NarratorNames),
|
nameof(ExportDto.NarratorNames),
|
||||||
nameof (ExportDto.LengthInMinutes),
|
nameof(ExportDto.LengthInMinutes),
|
||||||
nameof (ExportDto.Description),
|
nameof(ExportDto.Description),
|
||||||
nameof (ExportDto.Publisher),
|
nameof(ExportDto.Publisher),
|
||||||
nameof (ExportDto.HasPdf),
|
nameof(ExportDto.HasPdf),
|
||||||
nameof (ExportDto.SeriesNames),
|
nameof(ExportDto.SeriesNames),
|
||||||
nameof (ExportDto.SeriesOrder),
|
nameof(ExportDto.SeriesOrder),
|
||||||
nameof (ExportDto.CommunityRatingOverall),
|
nameof(ExportDto.CommunityRatingOverall),
|
||||||
nameof (ExportDto.CommunityRatingPerformance),
|
nameof(ExportDto.CommunityRatingPerformance),
|
||||||
nameof (ExportDto.CommunityRatingStory),
|
nameof(ExportDto.CommunityRatingStory),
|
||||||
nameof (ExportDto.PictureId),
|
nameof(ExportDto.PictureId),
|
||||||
nameof (ExportDto.IsAbridged),
|
nameof(ExportDto.IsAbridged),
|
||||||
nameof (ExportDto.DatePublished),
|
nameof(ExportDto.DatePublished),
|
||||||
nameof (ExportDto.CategoriesNames),
|
nameof(ExportDto.CategoriesNames),
|
||||||
nameof (ExportDto.MyRatingOverall),
|
nameof(ExportDto.MyRatingOverall),
|
||||||
nameof (ExportDto.MyRatingPerformance),
|
nameof(ExportDto.MyRatingPerformance),
|
||||||
nameof (ExportDto.MyRatingStory),
|
nameof(ExportDto.MyRatingStory),
|
||||||
nameof (ExportDto.MyLibationTags),
|
nameof(ExportDto.MyLibationTags),
|
||||||
nameof (ExportDto.BookStatus),
|
nameof(ExportDto.BookStatus),
|
||||||
nameof (ExportDto.PdfStatus),
|
nameof(ExportDto.PdfStatus),
|
||||||
nameof (ExportDto.ContentType)
|
nameof(ExportDto.ContentType),
|
||||||
|
nameof(ExportDto.AudioFormat)
|
||||||
};
|
};
|
||||||
var col = 0;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
@@ -268,6 +273,7 @@ namespace ApplicationServices
|
|||||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||||
|
|
||||||
rowIndex++;
|
rowIndex++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using AudibleApi.Common;
|
||||||
|
using CsvHelper;
|
||||||
|
using DataLayer;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using NPOI.XSSF.UserModel;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace ApplicationServices
|
||||||
|
{
|
||||||
|
public static class RecordExporter
|
||||||
|
{
|
||||||
|
public static void ToXlsx(string saveFilePath, IEnumerable<IRecord> records)
|
||||||
|
{
|
||||||
|
if (!records.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var workbook = new XSSFWorkbook();
|
||||||
|
var sheet = workbook.CreateSheet("Records");
|
||||||
|
|
||||||
|
var detailSubtotalFont = workbook.CreateFont();
|
||||||
|
detailSubtotalFont.IsBold = true;
|
||||||
|
|
||||||
|
var detailSubtotalCellStyle = workbook.CreateCellStyle();
|
||||||
|
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
|
||||||
|
|
||||||
|
// headers
|
||||||
|
var rowIndex = 0;
|
||||||
|
var row = sheet.CreateRow(rowIndex);
|
||||||
|
|
||||||
|
var columns = new List<string>
|
||||||
|
{
|
||||||
|
nameof(Type.Name),
|
||||||
|
nameof(IRecord.Created),
|
||||||
|
nameof(IRecord.Start) + "_ms",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (records.OfType<IAnnotation>().Any())
|
||||||
|
{
|
||||||
|
columns.Add(nameof(IAnnotation.AnnotationId));
|
||||||
|
columns.Add(nameof(IAnnotation.LastModified));
|
||||||
|
}
|
||||||
|
if (records.OfType<IRangeAnnotation>().Any())
|
||||||
|
{
|
||||||
|
columns.Add(nameof(IRangeAnnotation.End) + "_ms");
|
||||||
|
columns.Add(nameof(IRangeAnnotation.Text));
|
||||||
|
}
|
||||||
|
if (records.OfType<Clip>().Any())
|
||||||
|
columns.Add(nameof(Clip.Title));
|
||||||
|
|
||||||
|
var col = 0;
|
||||||
|
foreach (var c in columns)
|
||||||
|
{
|
||||||
|
var cell = row.CreateCell(col++);
|
||||||
|
cell.SetCellValue(c);
|
||||||
|
cell.CellStyle = detailSubtotalCellStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dateFormat = workbook.CreateDataFormat();
|
||||||
|
var dateStyle = workbook.CreateCellStyle();
|
||||||
|
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
|
||||||
|
|
||||||
|
// Add data rows
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
col = 0;
|
||||||
|
|
||||||
|
row = sheet.CreateRow(++rowIndex);
|
||||||
|
|
||||||
|
row.CreateCell(col++).SetCellValue(record.GetType().Name);
|
||||||
|
|
||||||
|
var dateCreatedCell = row.CreateCell(col++);
|
||||||
|
dateCreatedCell.CellStyle = dateStyle;
|
||||||
|
dateCreatedCell.SetCellValue(record.Created.DateTime);
|
||||||
|
|
||||||
|
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
|
||||||
|
|
||||||
|
if (record is IAnnotation annotation)
|
||||||
|
{
|
||||||
|
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
|
||||||
|
|
||||||
|
var lastModifiedCell = row.CreateCell(col++);
|
||||||
|
lastModifiedCell.CellStyle = dateStyle;
|
||||||
|
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
|
||||||
|
|
||||||
|
if (annotation is IRangeAnnotation rangeAnnotation)
|
||||||
|
{
|
||||||
|
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
|
||||||
|
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
|
||||||
|
|
||||||
|
if (rangeAnnotation is Clip clip)
|
||||||
|
row.CreateCell(col++).SetCellValue(clip.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||||
|
workbook.Write(fileData);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
|
||||||
|
{
|
||||||
|
if (!records.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var recordsEx = extendRecords(records);
|
||||||
|
|
||||||
|
var recordsObj = new JObject
|
||||||
|
{
|
||||||
|
{ "title", libraryBook.Book.Title},
|
||||||
|
{ "asin", libraryBook.Book.AudibleProductId},
|
||||||
|
{ "exportTime", DateTime.Now},
|
||||||
|
{ "records", JArray.FromObject(recordsEx) }
|
||||||
|
};
|
||||||
|
|
||||||
|
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ToCsv(string saveFilePath, IEnumerable<IRecord> records)
|
||||||
|
{
|
||||||
|
if (!records.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var writer = new System.IO.StreamWriter(saveFilePath);
|
||||||
|
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
|
||||||
|
|
||||||
|
//Write headers for the present record type that has the most properties
|
||||||
|
if (records.OfType<Clip>().Any())
|
||||||
|
csv.WriteHeader(typeof(ClipEx));
|
||||||
|
else if (records.OfType<Note>().Any())
|
||||||
|
csv.WriteHeader(typeof(NoteEx));
|
||||||
|
else if (records.OfType<Bookmark>().Any())
|
||||||
|
csv.WriteHeader(typeof(BookmarkEx));
|
||||||
|
else
|
||||||
|
csv.WriteHeader(typeof(LastHeardEx));
|
||||||
|
|
||||||
|
var recordsEx = extendRecords(records);
|
||||||
|
|
||||||
|
csv.NextRecord();
|
||||||
|
csv.WriteRecords(recordsEx.OfType<ClipEx>());
|
||||||
|
csv.WriteRecords(recordsEx.OfType<NoteEx>());
|
||||||
|
csv.WriteRecords(recordsEx.OfType<BookmarkEx>());
|
||||||
|
csv.WriteRecords(recordsEx.OfType<LastHeardEx>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<IRecordEx> extendRecords(IEnumerable<IRecord> records)
|
||||||
|
=> records
|
||||||
|
.Select<IRecord, IRecordEx>(
|
||||||
|
r => r switch
|
||||||
|
{
|
||||||
|
Clip c => new ClipEx(nameof(Clip), c),
|
||||||
|
Note n => new NoteEx(nameof(Note), n),
|
||||||
|
Bookmark b => new BookmarkEx(nameof(Bookmark), b),
|
||||||
|
LastHeard l => new LastHeardEx(nameof(LastHeard), l),
|
||||||
|
_ => throw new InvalidOperationException(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
private interface IRecordEx { string Type { get; } }
|
||||||
|
|
||||||
|
private record LastHeardEx : LastHeard, IRecordEx
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public LastHeardEx(string type, LastHeard original) : base(original)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record BookmarkEx : Bookmark, IRecordEx
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public BookmarkEx(string type, Bookmark original) : base(original)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NoteEx : Note, IRecordEx
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public NoteEx(string type, Note original) : base(original)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ClipEx : Clip, IRecordEx
|
||||||
|
{
|
||||||
|
public string Type { get; }
|
||||||
|
public ClipEx(string type, Clip original) : base(original)
|
||||||
|
{
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,23 +43,18 @@ namespace ApplicationServices
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var book in books)
|
foreach (var book in books)
|
||||||
{
|
UpdateUserDefinedItems(book);
|
||||||
UpdateLiberatedStatus(book);
|
|
||||||
UpdateBookTags(book);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void FullReIndex() => performSafeCommand(e =>
|
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||||
fullReIndex(e)
|
|
||||||
);
|
|
||||||
|
|
||||||
internal static void UpdateLiberatedStatus(Book book) => performSafeCommand(e =>
|
internal static void UpdateUserDefinedItems(Book book) => performSafeCommand(e =>
|
||||||
e.UpdateLiberatedStatus(book)
|
{
|
||||||
);
|
e.UpdateLiberatedStatus(book);
|
||||||
|
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
||||||
internal static void UpdateBookTags(Book book) => performSafeCommand(e =>
|
e.UpdateUserRatings(book);
|
||||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
private static void performSafeCommand(Action<SearchEngine> action)
|
private static void performSafeCommand(Action<SearchEngine> action)
|
||||||
@@ -87,7 +82,6 @@ namespace ApplicationServices
|
|||||||
isUpdating = true;
|
isUpdating = true;
|
||||||
|
|
||||||
action(new SearchEngine());
|
action(new SearchEngine());
|
||||||
|
|
||||||
if (!prevIsUpdating)
|
if (!prevIsUpdating)
|
||||||
SearchEngineUpdated?.Invoke(null, null);
|
SearchEngineUpdated?.Invoke(null, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ namespace AudibleUtilities
|
|||||||
Serilog.Log.Logger.Debug("Completed library scan.");
|
Serilog.Log.Logger.Debug("Completed library scan.");
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
//// this will not work for multi accounts
|
||||||
|
//var library_json = "library.json";
|
||||||
|
//library_json = System.IO.Path.GetFullPath(library_json);
|
||||||
|
//if (System.IO.File.Exists(library_json))
|
||||||
|
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
|
||||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
||||||
#endif
|
#endif
|
||||||
var validators = new List<IValidator>();
|
var validators = new List<IValidator>();
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="4.3.1.1" />
|
<PackageReference Include="AudibleApi" Version="7.3.1.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ namespace DataLayer.Configurations
|
|||||||
{
|
{
|
||||||
entity.HasKey(c => c.CategoryId);
|
entity.HasKey(c => c.CategoryId);
|
||||||
entity.HasIndex(c => c.AudibleCategoryId);
|
entity.HasIndex(c => c.AudibleCategoryId);
|
||||||
|
|
||||||
|
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
||||||
|
entity.HasData(Category.GetEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,9 @@ namespace DataLayer.Configurations
|
|||||||
.Metadata
|
.Metadata
|
||||||
.FindNavigation(nameof(Contributor.BooksLink))
|
.FindNavigation(nameof(Contributor.BooksLink))
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
|
||||||
|
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
||||||
|
entity.HasData(Contributor.GetEmpty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -10,13 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.1.1.1" />
|
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.6" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.6">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.1">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
@@ -29,10 +30,6 @@
|
|||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Update="migrate.json">
|
<None Update="migrate.json">
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ namespace DataLayer
|
|||||||
|
|
||||||
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
||||||
{
|
{
|
||||||
|
|
||||||
internal int AudioFormatID { get; private set; }
|
internal int AudioFormatID { get; private set; }
|
||||||
public int Bitrate { get; private init; }
|
public int Bitrate { get; private init; }
|
||||||
public int SampleRate { get; private init; }
|
public int SampleRate { get; private init; }
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ namespace DataLayer
|
|||||||
public DateTime DateAdded { get; private set; }
|
public DateTime DateAdded { get; private set; }
|
||||||
public string Account { get; private set; }
|
public string Account { get; private set; }
|
||||||
|
|
||||||
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
private LibraryBook() { }
|
private LibraryBook() { }
|
||||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||||
Book = book;
|
Book = book;
|
||||||
|
|
||||||
// import previously saved tags
|
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
|
|
||||||
Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Tags
|
#region Tags
|
||||||
@@ -150,10 +146,19 @@ namespace DataLayer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public void SetPdfStatus(LiberatedStatus? pdfStatus)
|
||||||
|
{
|
||||||
|
// don't change whether pdf is actually available. if null, leave as null. if not null, only assign non-null
|
||||||
|
|
||||||
|
// null => non-null : only when adding a supplement
|
||||||
|
|
||||||
|
if (pdfStatus.HasValue && PdfStatus.HasValue)
|
||||||
|
PdfStatus = pdfStatus;
|
||||||
|
}
|
||||||
public LiberatedStatus? PdfStatus
|
public LiberatedStatus? PdfStatus
|
||||||
{
|
{
|
||||||
get => _pdfStatus;
|
get => _pdfStatus;
|
||||||
set
|
internal set
|
||||||
{
|
{
|
||||||
if (_pdfStatus != value)
|
if (_pdfStatus != value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -93,10 +93,12 @@ namespace DataLayer
|
|||||||
|
|
||||||
var starString = new string(STAR, fullStars);
|
var starString = new string(STAR, fullStars);
|
||||||
|
|
||||||
if (score - fullStars >= 0.25f)
|
if (score - fullStars >= 0.75f)
|
||||||
starString += HALF;
|
starString += STAR;
|
||||||
|
else if (score - fullStars >= 0.25f)
|
||||||
|
starString += HALF;
|
||||||
|
|
||||||
return starString;
|
return starString;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
using DataLayer.Configurations;
|
using DataLayer.Configurations;
|
||||||
using Dinah.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
public class LibationContext : InterceptableDbContext
|
public class LibationContext : DbContext
|
||||||
{
|
{
|
||||||
// IMPORTANT: USING DbSet<>
|
// IMPORTANT: USING DbSet<>
|
||||||
// ========================
|
// ========================
|
||||||
@@ -35,14 +34,6 @@ namespace DataLayer
|
|||||||
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
|
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
|
||||||
internal LibationContext(DbContextOptions options) : base(options) { }
|
internal LibationContext(DbContextOptions options) : base(options) { }
|
||||||
|
|
||||||
// called on each instantiation
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
|
||||||
{
|
|
||||||
AddInterceptor(new TagPersistenceInterceptor());
|
|
||||||
|
|
||||||
base.OnConfiguring(optionsBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
// typically only called once per execution; NOT once per instantiation
|
// typically only called once per execution; NOT once per instantiation
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -56,15 +47,6 @@ namespace DataLayer
|
|||||||
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||||
|
|
||||||
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
|
|
||||||
|
|
||||||
modelBuilder
|
|
||||||
.Entity<Category>()
|
|
||||||
.HasData(Category.GetEmpty());
|
|
||||||
modelBuilder
|
|
||||||
.Entity<Contributor>()
|
|
||||||
.HasData(Contributor.GetEmpty());
|
|
||||||
|
|
||||||
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
|
||||||
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
|
||||||
}
|
}
|
||||||
|
|||||||
+401
@@ -0,0 +1,401 @@
|
|||||||
|
// <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("20221214205106_LibraryBookIsDeleted")]
|
||||||
|
partial class LibraryBookIsDeleted
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
|
||||||
|
|
||||||
|
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<int>("LengthInMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Locale")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureLarge")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("ContributorId");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
b.ToTable("Contributors");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ContributorId = -1,
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int?>("PdfStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Tags")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("UserDefinedItem", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<int>("UserDefinedItemBookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b2.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.HasKey("UserDefinedItemBookId");
|
||||||
|
|
||||||
|
b2.ToTable("UserDefinedItem");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("UserDefinedItemBookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
|
||||||
|
b1.Navigation("Rating");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("ContributorsLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("ContributorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("SeriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Series", "Series")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class LibraryBookIsDeleted : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsDeleted",
|
||||||
|
table: "LibraryBooks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsDeleted",
|
||||||
|
table: "LibraryBooks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
@@ -160,6 +160,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<DateTime>("DateAdded")
|
b.Property<DateTime>("DateAdded")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("BookId");
|
b.HasKey("BookId");
|
||||||
|
|
||||||
b.ToTable("LibraryBooks");
|
b.ToTable("LibraryBooks");
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ namespace DataLayer
|
|||||||
|
|
||||||
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||||
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
|
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
|
||||||
|
=> library
|
||||||
|
.Where(lb => !lb.IsDeleted)
|
||||||
|
.getLibrary();
|
||||||
|
|
||||||
|
public static List<LibraryBook> GetDeletedLibraryBooks(this LibationContext context)
|
||||||
|
=> context
|
||||||
|
.LibraryBooks
|
||||||
|
.AsNoTrackingWithIdentityResolution()
|
||||||
|
.Where(lb => lb.IsDeleted)
|
||||||
|
.getLibrary()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
|
||||||
|
private static IQueryable<LibraryBook> getLibrary(this IQueryable<LibraryBook> library)
|
||||||
=> library
|
=> library
|
||||||
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
||||||
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||||
@@ -44,7 +58,7 @@ namespace DataLayer
|
|||||||
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
||||||
|
|
||||||
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s));
|
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
|
||||||
|
|
||||||
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
=> libraryBooks
|
=> libraryBooks
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using Dinah.Core.Collections.Generic;
|
|
||||||
using Dinah.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace DataLayer
|
|
||||||
{
|
|
||||||
internal class TagPersistenceInterceptor : IDbInterceptor
|
|
||||||
{
|
|
||||||
public void Executed(DbContext context) { }
|
|
||||||
|
|
||||||
public void Executing(DbContext context)
|
|
||||||
{
|
|
||||||
var tagsCollection
|
|
||||||
= context
|
|
||||||
.ChangeTracker
|
|
||||||
.Entries()
|
|
||||||
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
|
|
||||||
.Select(e => e.Entity as UserDefinedItem)
|
|
||||||
.Where(udi => udi is not null)
|
|
||||||
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
|
|
||||||
.Select(t => (t.Book.AudibleProductId, t.Tags))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
LibationFileManager.TagsPersistence.Save(tagsCollection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,7 +84,8 @@ namespace DtoImporterService
|
|||||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||||
var authors = item
|
var authors = item
|
||||||
.Authors
|
.Authors
|
||||||
.Select(a => contributorImporter.Cache[a.Name])
|
.DistinctBy(a => a.Name)
|
||||||
|
.Select(a => contributorImporter.Cache[a.Name])
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var narrators
|
var narrators
|
||||||
@@ -94,7 +95,8 @@ namespace DtoImporterService
|
|||||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||||
: item
|
: item
|
||||||
.Narrators
|
.Narrators
|
||||||
.Select(n => contributorImporter.Cache[n.Name])
|
.DistinctBy(a => a.Name)
|
||||||
|
.Select(n => contributorImporter.Cache[n.Name])
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||||
@@ -172,7 +174,10 @@ namespace DtoImporterService
|
|||||||
if (item.PictureLarge is not null)
|
if (item.PictureLarge is not null)
|
||||||
book.PictureLarge = item.PictureLarge;
|
book.PictureLarge = item.PictureLarge;
|
||||||
|
|
||||||
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
|
book.UpdateProductRating(
|
||||||
|
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||||
|
(float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0),
|
||||||
|
(float)(item.Rating?.StoryDistribution?.AverageRating ?? 0));
|
||||||
|
|
||||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
||||||
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ namespace FileLiberator
|
|||||||
public class ConvertToMp3 : AudioDecodable
|
public class ConvertToMp3 : AudioDecodable
|
||||||
{
|
{
|
||||||
public override string Name => "Convert to Mp3";
|
public override string Name => "Convert to Mp3";
|
||||||
private Mp4File m4bBook;
|
private Mp4Operation Mp4Operation;
|
||||||
|
private TimeSpan bookDuration;
|
||||||
private long fileSize;
|
private long fileSize;
|
||||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||||
|
|
||||||
public override Task CancelAsync() => m4bBook?.CancelAsync() ?? Task.CompletedTask;
|
public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask;
|
||||||
|
|
||||||
public static bool ValidateMp3(LibraryBook libraryBook)
|
public static bool ValidateMp3(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
@@ -43,9 +43,9 @@ namespace FileLiberator
|
|||||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||||
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
||||||
|
|
||||||
m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
|
bookDuration = m4bBook.Duration;
|
||||||
fileSize = m4bBook.InputStream.Length;
|
fileSize = m4bBook.InputStream.Length;
|
||||||
|
|
||||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||||
@@ -53,44 +53,65 @@ namespace FileLiberator
|
|||||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||||
|
|
||||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
var config = Configuration.Instance;
|
||||||
var lameConfig = GetLameOptions(Configuration.Instance);
|
var lameConfig = GetLameOptions(config);
|
||||||
var result = await m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
|
||||||
m4bBook.InputStream.Close();
|
|
||||||
mp3File.Close();
|
|
||||||
|
|
||||||
if (result == ConversionResult.Failed)
|
//Finishing configuring lame encoder.
|
||||||
|
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||||
|
m4bBook,
|
||||||
|
lameConfig,
|
||||||
|
config.LameDownsampleMono,
|
||||||
|
config.LameMatchSourceBR);
|
||||||
|
|
||||||
|
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||||
|
try
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(mp3File.Name);
|
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
||||||
|
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||||
|
await Mp4Operation;
|
||||||
|
|
||||||
|
if (Mp4Operation.IsCanceled)
|
||||||
|
{
|
||||||
|
FileUtility.SaferDelete(mp3File.Name);
|
||||||
|
return new StatusHandler { "Cancelled" };
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3");
|
||||||
|
OnFileCreated(libraryBook, realMp3Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Error(ex, "AAXClean error");
|
||||||
return new StatusHandler { "Conversion failed" };
|
return new StatusHandler { "Conversion failed" };
|
||||||
}
|
}
|
||||||
else if (result == ConversionResult.Cancelled)
|
finally
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(mp3File.Name);
|
if (Mp4Operation is not null)
|
||||||
return new StatusHandler { "Cancelled" };
|
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
|
||||||
}
|
|
||||||
|
|
||||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters);
|
m4bBook.InputStream.Close();
|
||||||
OnFileCreated(libraryBook, realMp3Path);
|
mp3File.Close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return new StatusHandler();
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
OnCompleted(libraryBook);
|
||||||
}
|
}
|
||||||
|
return new StatusHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||||
{
|
{
|
||||||
var duration = m4bBook.Duration;
|
var remainingSecsToProcess = (bookDuration - e.ProcessPosition).TotalSeconds;
|
||||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
|
||||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||||
|
|
||||||
if (double.IsNormal(estTimeRemaining))
|
if (double.IsNormal(estTimeRemaining))
|
||||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||||
|
|
||||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / bookDuration.TotalSeconds;
|
||||||
|
|
||||||
OnStreamingProgressChanged(
|
OnStreamingProgressChanged(
|
||||||
new DownloadProgress
|
new DownloadProgress
|
||||||
|
|||||||
@@ -14,318 +14,395 @@ using LibationFileManager;
|
|||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadDecryptBook : AudioDecodable
|
public class DownloadDecryptBook : AudioDecodable
|
||||||
{
|
{
|
||||||
public override string Name => "Download & Decrypt";
|
public override string Name => "Download & Decrypt";
|
||||||
private AudiobookDownloadBase abDownloader;
|
private AudiobookDownloadBase abDownloader;
|
||||||
|
|
||||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||||
|
|
||||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
||||||
|
|
||||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
var entries = new List<FilePathCache.CacheEntry>();
|
var entries = new List<FilePathCache.CacheEntry>();
|
||||||
// these only work so minimally b/c CacheEntry is a record.
|
// these only work so minimally b/c CacheEntry is a record.
|
||||||
// in case of parallel decrypts, only capture the ones for this book id.
|
// in case of parallel decrypts, only capture the ones for this book id.
|
||||||
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
||||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
||||||
{
|
{
|
||||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||||
entries.Add(e);
|
entries.Add(e);
|
||||||
}
|
}
|
||||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
||||||
{
|
{
|
||||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||||
entries.Remove(e);
|
entries.Remove(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
OnBegin(libraryBook);
|
OnBegin(libraryBook);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (libraryBook.Book.Audio_Exists())
|
if (libraryBook.Book.Audio_Exists())
|
||||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||||
|
|
||||||
bool success = false;
|
bool success = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||||
FilePathCache.Removed += FilePathCache_Removed;
|
FilePathCache.Removed += FilePathCache_Removed;
|
||||||
|
|
||||||
success = await downloadAudiobookAsync(libraryBook);
|
success = await downloadAudiobookAsync(libraryBook);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||||
FilePathCache.Removed -= FilePathCache_Removed;
|
FilePathCache.Removed -= FilePathCache_Removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// decrypt failed
|
// decrypt failed
|
||||||
if (!success)
|
if (!success)
|
||||||
{
|
{
|
||||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
||||||
FileUtility.SaferDelete(tmpFile.Path);
|
FileUtility.SaferDelete(tmpFile.Path);
|
||||||
|
|
||||||
return abDownloader?.IsCanceled == true ?
|
return abDownloader?.IsCanceled == true ?
|
||||||
new StatusHandler { "Cancelled" } :
|
new StatusHandler { "Cancelled" } :
|
||||||
new StatusHandler { "Decrypt failed" };
|
new StatusHandler { "Decrypt failed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// moves new files from temp dir to final dest.
|
// moves new files from temp dir to final dest.
|
||||||
// This could take a few seconds if moving hundreds of files.
|
// This could take a few seconds if moving hundreds of files.
|
||||||
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||||
|
|
||||||
// decrypt failed
|
// decrypt failed
|
||||||
if (!movedAudioFile)
|
if (finalStorageDir is null)
|
||||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||||
|
|
||||||
if (Configuration.Instance.DownloadCoverArt)
|
if (Configuration.Instance.DownloadCoverArt)
|
||||||
DownloadCoverArt(libraryBook);
|
downloadCoverArt(libraryBook);
|
||||||
|
|
||||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
// contains logic to check for config setting and OS
|
||||||
|
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
|
||||||
|
|
||||||
return new StatusHandler();
|
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
OnCompleted(libraryBook);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
return new StatusHandler();
|
||||||
{
|
}
|
||||||
var config = Configuration.Instance;
|
finally
|
||||||
|
{
|
||||||
|
OnCompleted(libraryBook);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
downloadValidation(libraryBook);
|
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
var config = Configuration.Instance;
|
||||||
|
|
||||||
var api = await libraryBook.GetApiAsync();
|
downloadValidation(libraryBook);
|
||||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
|
||||||
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
|
||||||
|
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
var api = await libraryBook.GetApiAsync();
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||||
|
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||||
|
|
||||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
else
|
|
||||||
{
|
|
||||||
AaxcDownloadConvertBase converter
|
|
||||||
= config.SplitFilesByChapter ?
|
|
||||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
|
||||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
|
||||||
|
|
||||||
if (config.AllowLibationFixup)
|
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AaxcDownloadConvertBase converter
|
||||||
|
= config.SplitFilesByChapter ?
|
||||||
|
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
||||||
|
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||||
|
|
||||||
abDownloader = converter;
|
if (config.AllowLibationFixup)
|
||||||
}
|
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||||
|
|
||||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
abDownloader = converter;
|
||||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
}
|
||||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
|
||||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
|
||||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
|
||||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
|
||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||||
var success = await abDownloader.RunAsync();
|
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||||
|
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||||
|
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||||
|
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||||
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
|
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||||
|
|
||||||
return success;
|
// REAL WORK DONE HERE
|
||||||
}
|
return await abDownloader.RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||||
{
|
{
|
||||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
||||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
||||||
//These assumptions may be wrong, and only time and bug reports will tell.
|
//These assumptions may be wrong, and only time and bug reports will tell.
|
||||||
|
|
||||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
||||||
|
|
||||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
||||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||||
|
|
||||||
var dlOptions = new DownloadOptions
|
long chapterStartMs = config.StripAudibleBrandAudio ?
|
||||||
(
|
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||||
libraryBook,
|
|
||||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
|
||||||
Resources.USER_AGENT
|
|
||||||
)
|
|
||||||
{
|
|
||||||
AudibleKey = contentLic?.Voucher?.Key,
|
|
||||||
AudibleIV = contentLic?.Voucher?.Iv,
|
|
||||||
OutputFormat = outputFormat,
|
|
||||||
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
|
|
||||||
RetainEncryptedFile = config.RetainAaxFile && encrypted,
|
|
||||||
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
|
|
||||||
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
|
|
||||||
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
|
|
||||||
CreateCueSheet = config.CreateCueSheet,
|
|
||||||
LameConfig = GetLameOptions(config)
|
|
||||||
};
|
|
||||||
|
|
||||||
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
var dlOptions = new DownloadOptions
|
||||||
|
(
|
||||||
if (config.MergeOpeningAndEndCredits)
|
libraryBook,
|
||||||
combineCredits(chapters);
|
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||||
|
Resources.USER_AGENT
|
||||||
|
)
|
||||||
|
{
|
||||||
|
AudibleKey = contentLic?.Voucher?.Key,
|
||||||
|
AudibleIV = contentLic?.Voucher?.Iv,
|
||||||
|
OutputFormat = outputFormat,
|
||||||
|
MoveMoovToBeginning = config.MoveMoovToBeginning,
|
||||||
|
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
|
||||||
|
RetainEncryptedFile = config.RetainAaxFile && encrypted,
|
||||||
|
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
|
||||||
|
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
|
||||||
|
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
|
||||||
|
CreateCueSheet = config.CreateCueSheet,
|
||||||
|
DownloadClipsBookmarks = config.DownloadClipsBookmarks,
|
||||||
|
DownloadSpeedBps = config.DownloadSpeedLimit,
|
||||||
|
LameConfig = GetLameOptions(config),
|
||||||
|
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||||
|
FixupFile = config.AllowLibationFixup
|
||||||
|
};
|
||||||
|
|
||||||
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
|
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||||
{
|
|
||||||
long startMs = dlOptions.TrimOutputToChapterLength ?
|
|
||||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
|
||||||
|
|
||||||
dlOptions.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
|
if (config.MergeOpeningAndEndCredits)
|
||||||
|
combineCredits(chapters);
|
||||||
|
|
||||||
for (int i = 0; i < chapters.Count; i++)
|
for (int i = 0; i < chapters.Count; i++)
|
||||||
{
|
{
|
||||||
var chapter = chapters[i];
|
var chapter = chapters[i];
|
||||||
long chapLenMs = chapter.LengthMs;
|
long chapLenMs = chapter.LengthMs;
|
||||||
|
|
||||||
if (i == 0)
|
if (i == 0)
|
||||||
chapLenMs -= startMs;
|
chapLenMs -= chapterStartMs;
|
||||||
|
|
||||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||||
|
|
||||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return dlOptions;
|
return dlOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
/*
|
||||||
{
|
|
||||||
List<AudibleApi.Common.Chapter> chaps = new();
|
|
||||||
|
|
||||||
foreach (var c in chapters)
|
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||||
{
|
|
||||||
if (c.Chapters is not null)
|
|
||||||
{
|
|
||||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
|
||||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
|
||||||
c.Chapters[0].LengthMs += c.LengthMs;
|
|
||||||
|
|
||||||
var children = flattenChapters(c.Chapters);
|
Audible may deliver chapters like this:
|
||||||
|
|
||||||
foreach (var child in children)
|
00:00 - 00:10 Opening Credits
|
||||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
00:10 - 00:12 Book 1
|
||||||
|
00:12 - 00:14 | Part 1
|
||||||
|
00:14 - 01:40 | | Chapter 1
|
||||||
|
01:40 - 03:20 | | Chapter 2
|
||||||
|
03:20 - 03:22 | Part 2
|
||||||
|
03:22 - 05:00 | | Chapter 3
|
||||||
|
05:00 - 06:40 | | Chapter 4
|
||||||
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 06:44 | Part 3
|
||||||
|
06:44 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
chaps.AddRange(children);
|
And flattenChapters will combine them into this:
|
||||||
}
|
|
||||||
else
|
|
||||||
chaps.Add(c);
|
|
||||||
}
|
|
||||||
return chaps;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
00:00 - 00:10 Opening Credits
|
||||||
{
|
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
{
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
chapters.RemoveAt(0);
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
}
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
13:20 - 13:40 End Credits
|
||||||
{
|
|
||||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
|
||||||
chapters.Remove(chapters[^1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void downloadValidation(LibraryBook libraryBook)
|
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
||||||
{
|
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
||||||
string errorString(string field)
|
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
||||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
||||||
|
|
||||||
string errorTitle()
|
00:00 - 00:10 Opening Credits
|
||||||
{
|
00:10 - 00:25 Book 1
|
||||||
var title
|
00:25 - 00:27 | Part 1
|
||||||
= (libraryBook.Book.Title.Length > 53)
|
00:27 - 01:40 | | Chapter 1
|
||||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
01:40 - 03:20 | | Chapter 2
|
||||||
: libraryBook.Book.Title;
|
03:20 - 03:22 | Part 2
|
||||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
03:22 - 05:00 | | Chapter 3
|
||||||
return errorBookTitle;
|
05:00 - 06:40 | | Chapter 4
|
||||||
};
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 07:02 | Part 3
|
||||||
|
07:02 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
then flattenChapters will combine them into this:
|
||||||
throw new Exception(errorString("Account"));
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
00:00 - 00:10 Opening Credits
|
||||||
throw new Exception(errorString("Locale"));
|
00:10 - 00:25 Book 1
|
||||||
}
|
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
|
06:40 - 07:02 Book 2: Part 3
|
||||||
|
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
|
13:20 - 13:40 End Credits
|
||||||
|
|
||||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
*/
|
||||||
{
|
|
||||||
if (e is not null)
|
|
||||||
OnCoverImageDiscovered(e);
|
|
||||||
else if (Configuration.Instance.AllowLibationFixup)
|
|
||||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Move new files to 'Books' directory</summary>
|
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
||||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
{
|
||||||
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
List<AudibleApi.Common.Chapter> chaps = new();
|
||||||
{
|
|
||||||
// create final directory. move each file into it
|
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
|
||||||
Directory.CreateDirectory(destinationDir);
|
|
||||||
|
|
||||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
foreach (var c in chapters)
|
||||||
|
{
|
||||||
|
if (c.Chapters is not null)
|
||||||
|
{
|
||||||
|
if (c.LengthMs < 10000)
|
||||||
|
{
|
||||||
|
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||||
|
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||||
|
c.Chapters[0].LengthMs += c.LengthMs;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
chaps.Add(c);
|
||||||
|
|
||||||
if (getFirstAudio() == default)
|
var children = flattenChapters(c.Chapters);
|
||||||
return false;
|
|
||||||
|
|
||||||
for (var i = 0; i < entries.Count; i++)
|
foreach (var child in children)
|
||||||
{
|
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||||
var entry = entries[i];
|
|
||||||
|
|
||||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
chaps.AddRange(children);
|
||||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
c.Chapters = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
chaps.Add(c);
|
||||||
|
}
|
||||||
|
return chaps;
|
||||||
|
}
|
||||||
|
|
||||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
||||||
entries[i] = entry with { Path = realDest };
|
{
|
||||||
}
|
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||||
|
{
|
||||||
|
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||||
|
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||||
|
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||||
|
chapters.RemoveAt(0);
|
||||||
|
}
|
||||||
|
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||||
|
{
|
||||||
|
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||||
|
chapters.Remove(chapters[^1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
private static void downloadValidation(LibraryBook libraryBook)
|
||||||
if (cue != default)
|
{
|
||||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
string errorString(string field)
|
||||||
|
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||||
|
|
||||||
AudibleFileStorage.Audio.Refresh();
|
string errorTitle()
|
||||||
|
{
|
||||||
|
var title
|
||||||
|
= (libraryBook.Book.Title.Length > 53)
|
||||||
|
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||||
|
: libraryBook.Book.Title;
|
||||||
|
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||||
|
return errorBookTitle;
|
||||||
|
};
|
||||||
|
|
||||||
return true;
|
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||||
}
|
throw new Exception(errorString("Account"));
|
||||||
|
|
||||||
private void DownloadCoverArt(LibraryBook libraryBook)
|
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||||
{
|
throw new Exception(errorString("Locale"));
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
}
|
||||||
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
|
||||||
|
|
||||||
try
|
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||||
{
|
{
|
||||||
if (File.Exists(coverPath))
|
if (e is not null)
|
||||||
FileUtility.SaferDelete(coverPath);
|
OnCoverImageDiscovered(e);
|
||||||
|
else if (Configuration.Instance.AllowLibationFixup)
|
||||||
|
abDownloader.SetCoverArt(OnRequestCoverArt());
|
||||||
|
}
|
||||||
|
|
||||||
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
|
/// <summary>Move new files to 'Books' directory</summary>
|
||||||
(libraryBook.Book.PictureId, PictureSize.Native) :
|
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||||
(libraryBook.Book.PictureLarge, PictureSize.Native);
|
private static string 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 picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
|
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||||
|
|
||||||
if (picBytes.Length > 0)
|
|
||||||
File.WriteAllBytes(coverPath, picBytes);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
//Failure to download cover art should not be
|
|
||||||
//considered a failure to download the book
|
|
||||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
if (getFirstAudio() == default)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
for (var i = 0; i < entries.Count; i++)
|
||||||
|
{
|
||||||
|
var entry = entries[i];
|
||||||
|
|
||||||
|
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||||
|
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||||
|
|
||||||
|
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||||
|
entries[i] = entry with { Path = realDest };
|
||||||
|
}
|
||||||
|
|
||||||
|
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||||
|
if (cue != default)
|
||||||
|
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||||
|
|
||||||
|
AudibleFileStorage.Audio.Refresh();
|
||||||
|
|
||||||
|
return destinationDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
var coverPath = "[null]";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||||
|
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||||
|
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||||
|
|
||||||
|
if (File.Exists(coverPath))
|
||||||
|
FileUtility.SaferDelete(coverPath);
|
||||||
|
|
||||||
|
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
||||||
|
if (picBytes.Length > 0)
|
||||||
|
File.WriteAllBytes(coverPath, picBytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
//Failure to download cover art should not be considered a failure to download the book
|
||||||
|
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,43 +4,90 @@ using Dinah.Core;
|
|||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using ApplicationServices;
|
||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadOptions : IDownloadOptions
|
public class DownloadOptions : IDownloadOptions, IDisposable
|
||||||
{
|
{
|
||||||
public LibraryBook LibraryBook { get; }
|
public event EventHandler<long> DownloadSpeedChanged;
|
||||||
public LibraryBookDto LibraryBookDto { get; }
|
public LibraryBook LibraryBook { get; }
|
||||||
public string DownloadUrl { get; }
|
public LibraryBookDto LibraryBookDto { get; }
|
||||||
public string UserAgent { get; }
|
public string DownloadUrl { get; }
|
||||||
public string AudibleKey { get; init; }
|
public string UserAgent { get; }
|
||||||
public string AudibleIV { get; init; }
|
public string AudibleKey { get; init; }
|
||||||
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
|
public string AudibleIV { get; init; }
|
||||||
public bool TrimOutputToChapterLength { get; init; }
|
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
|
||||||
public bool RetainEncryptedFile { get; init; }
|
public bool TrimOutputToChapterLength { get; init; }
|
||||||
public bool StripUnabridged { get; init; }
|
public bool RetainEncryptedFile { get; init; }
|
||||||
public bool CreateCueSheet { get; init; }
|
public bool StripUnabridged { get; init; }
|
||||||
public ChapterInfo ChapterInfo { get; set; }
|
public bool CreateCueSheet { get; init; }
|
||||||
public NAudio.Lame.LameConfig LameConfig { get; set; }
|
public bool DownloadClipsBookmarks { get; init; }
|
||||||
public bool Downsample { get; set; }
|
public long DownloadSpeedBps { get; init; }
|
||||||
public bool MatchSourceBitrate { get; set; }
|
public ChapterInfo ChapterInfo { get; init; }
|
||||||
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
|
public bool FixupFile { get; init; }
|
||||||
|
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||||
|
public bool Downsample { get; init; }
|
||||||
|
public bool MatchSourceBitrate { get; init; }
|
||||||
|
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
|
||||||
|
|
||||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
public bool MoveMoovToBeginning { get; init; }
|
||||||
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
|
|
||||||
|
|
||||||
public string GetMultipartTitleName(MultiConvertFileProperties props)
|
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||||
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
|
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
|
||||||
|
|
||||||
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
public string GetMultipartTitleName(MultiConvertFileProperties props)
|
||||||
{
|
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
|
||||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
|
||||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
|
||||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
|
||||||
|
|
||||||
LibraryBookDto = LibraryBook.ToDto();
|
public async Task<string> SaveClipsAndBookmarks(string fileName)
|
||||||
|
{
|
||||||
|
if (DownloadClipsBookmarks)
|
||||||
|
{
|
||||||
|
var format = Configuration.Instance.ClipsBookmarksFileFormat;
|
||||||
|
|
||||||
// no null/empty check for key/iv. unencrypted files do not have them
|
var formatExtension = format.ToString().ToLowerInvariant();
|
||||||
}
|
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
||||||
}
|
|
||||||
|
var api = await LibraryBook.GetApiAsync();
|
||||||
|
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
|
||||||
|
|
||||||
|
switch(format)
|
||||||
|
{
|
||||||
|
case Configuration.ClipBookmarkFormat.CSV:
|
||||||
|
RecordExporter.ToCsv(filePath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Xlsx:
|
||||||
|
RecordExporter.ToXlsx(filePath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Json:
|
||||||
|
RecordExporter.ToJson(filePath, LibraryBook, records);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IDisposable cancellation;
|
||||||
|
public void Dispose() => cancellation?.Dispose();
|
||||||
|
|
||||||
|
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
||||||
|
{
|
||||||
|
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||||
|
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||||
|
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||||
|
// no null/empty check for key/iv. unencrypted files do not have them
|
||||||
|
|
||||||
|
LibraryBookDto = LibraryBook.ToDto();
|
||||||
|
|
||||||
|
cancellation =
|
||||||
|
Configuration.Instance
|
||||||
|
.ObservePropertyChanged<long>(
|
||||||
|
nameof(Configuration.DownloadSpeedLimit),
|
||||||
|
newVal => DownloadSpeedChanged?.Invoke(this, newVal));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ namespace FileLiberator
|
|||||||
{
|
{
|
||||||
OnBegin(libraryBook);
|
OnBegin(libraryBook);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||||
@@ -32,13 +32,22 @@ namespace FileLiberator
|
|||||||
|
|
||||||
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
||||||
|
|
||||||
|
var result = new StatusHandler();
|
||||||
|
result.AddError($"Error downloading PDF. See log for details. Error summary: {ex.Message}");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
OnCompleted(libraryBook);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0-windows</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -27,17 +27,24 @@ namespace FileLiberator
|
|||||||
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
||||||
{
|
{
|
||||||
Account = libraryBook.Account,
|
Account = libraryBook.Account,
|
||||||
|
DateAdded = libraryBook.DateAdded,
|
||||||
|
|
||||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||||
Title = libraryBook.Book.Title ?? "",
|
Title = libraryBook.Book.Title ?? "",
|
||||||
Locale = libraryBook.Book.Locale,
|
Locale = libraryBook.Book.Locale,
|
||||||
|
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||||
|
DatePublished = libraryBook.Book.DatePublished,
|
||||||
|
|
||||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||||
|
|
||||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||||
|
|
||||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order
|
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order,
|
||||||
|
|
||||||
|
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||||
|
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||||
|
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="4.4.1.1" />
|
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
|
||||||
<PackageReference Include="Polly" Version="7.2.3" />
|
<PackageReference Include="Polly" Version="7.2.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ namespace FileManager
|
|||||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||||
public class FileNamingTemplate : NamingTemplate
|
public class FileNamingTemplate : NamingTemplate
|
||||||
{
|
{
|
||||||
|
public ReplacementCharacters ReplacementCharacters { get; }
|
||||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||||
public FileNamingTemplate(string template) : base(template) { }
|
public FileNamingTemplate(string template, ReplacementCharacters replacement) : base(template)
|
||||||
|
{
|
||||||
|
ReplacementCharacters = replacement ?? ReplacementCharacters.Default;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Generate a valid path for this file or directory</summary>
|
/// <summary>Generate a valid path for this file or directory</summary>
|
||||||
public LongPath GetFilePath(ReplacementCharacters replacements, bool returnFirstExisting = false)
|
public LongPath GetFilePath(string fileExtension, bool returnFirstExisting = false)
|
||||||
{
|
{
|
||||||
string fileName =
|
string fileName =
|
||||||
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
|
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
|
||||||
@@ -22,7 +26,7 @@ namespace FileManager
|
|||||||
|
|
||||||
List<string> pathParts = new();
|
List<string> pathParts = new();
|
||||||
|
|
||||||
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, replacements));
|
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, ReplacementCharacters));
|
||||||
|
|
||||||
while (!string.IsNullOrEmpty(fileName))
|
while (!string.IsNullOrEmpty(fileName))
|
||||||
{
|
{
|
||||||
@@ -44,15 +48,23 @@ namespace FileManager
|
|||||||
var fileNamePart = pathParts[^1];
|
var fileNamePart = pathParts[^1];
|
||||||
pathParts.Remove(fileNamePart);
|
pathParts.Remove(fileNamePart);
|
||||||
|
|
||||||
|
fileNamePart = fileNamePart[..^fileExtension.Length];
|
||||||
|
|
||||||
LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray());
|
LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray());
|
||||||
|
|
||||||
//If file already exists, GetValidFilename will append " (n)" to the filename.
|
//If file already exists, GetValidFilename will append " (n)" to the filename.
|
||||||
//This could cause the filename length to exceed MaxFilenameLength, so reduce
|
//This could cause the filename length to exceed MaxFilenameLength, so reduce
|
||||||
//allowable filename length by 5 chars, allowing for up to 99 duplicates.
|
//allowable filename length by 5 chars, allowing for up to 99 duplicates.
|
||||||
return FileUtility.GetValidFilename(Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - 5)), replacements, returnFirstExisting);
|
return FileUtility
|
||||||
|
.GetValidFilename(
|
||||||
|
Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - fileExtension.Length - 5)) + fileExtension,
|
||||||
|
ReplacementCharacters,
|
||||||
|
fileExtension,
|
||||||
|
returnFirstExisting
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
|
private static string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
|
||||||
{
|
{
|
||||||
List<StringBuilder> filenameParts = new();
|
List<StringBuilder> filenameParts = new();
|
||||||
//Build the filename in parts, replacing replacement parameters with
|
//Build the filename in parts, replacing replacement parameters with
|
||||||
@@ -88,7 +100,7 @@ namespace FileManager
|
|||||||
|
|
||||||
//Remove 1 character from the end of the longest filename part until
|
//Remove 1 character from the end of the longest filename part until
|
||||||
//the total filename is less than max filename length
|
//the total filename is less than max filename length
|
||||||
while (filenameParts.Sum(p => p.Length) > maxFilenameLength)
|
while (filenameParts.Sum(p => LongPath.GetFilesystemStringLength(p)) > maxFilenameLength)
|
||||||
{
|
{
|
||||||
int maxLength = filenameParts.Max(p => p.Length);
|
int maxLength = filenameParts.Max(p => p.Length);
|
||||||
var maxEntry = filenameParts.First(p => p.Length == maxLength);
|
var maxEntry = filenameParts.First(p => p.Length == maxLength);
|
||||||
@@ -98,14 +110,14 @@ namespace FileManager
|
|||||||
return string.Join("", filenameParts);
|
return string.Join("", filenameParts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string formatValue(object value, ReplacementCharacters replacements)
|
private static string formatValue(object value, ReplacementCharacters replacements)
|
||||||
{
|
{
|
||||||
if (value is null)
|
if (value is null)
|
||||||
return "";
|
return "";
|
||||||
|
|
||||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
||||||
// Esp important for file templates.
|
// Esp important for file templates.
|
||||||
return replacements.ReplaceInvalidFilenameChars(value.ToString());
|
return replacements.ReplaceFilenameChars(value.ToString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,14 @@ using System.Text.RegularExpressions;
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Polly;
|
using Polly;
|
||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
|
using Dinah.Core.Collections.Generic;
|
||||||
|
|
||||||
namespace FileManager
|
namespace FileManager
|
||||||
{
|
{
|
||||||
public static class FileUtility
|
public static class FileUtility
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "txt" => ".txt"
|
/// "txt" => ".txt"
|
||||||
/// <br />".txt" => ".txt"
|
/// <br />".txt" => ".txt"
|
||||||
@@ -46,32 +49,33 @@ namespace FileManager
|
|||||||
/// <br/>- ensure uniqueness
|
/// <br/>- ensure uniqueness
|
||||||
/// <br/>- enforce max file length
|
/// <br/>- enforce max file length
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, bool returnFirstExisting = false)
|
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string fileExtension, bool returnFirstExisting = false)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||||
|
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
|
||||||
|
fileExtension = GetStandardizedExtension(fileExtension);
|
||||||
|
|
||||||
// remove invalid chars
|
// remove invalid chars
|
||||||
path = GetSafePath(path, replacements);
|
path = GetSafePath(path, replacements);
|
||||||
|
|
||||||
// ensure uniqueness and check lengths
|
// ensure uniqueness and check lengths
|
||||||
var dir = Path.GetDirectoryName(path);
|
var dir = Path.GetDirectoryName(path);
|
||||||
dir = dir?.Truncate(LongPath.MaxDirectoryLength) ?? string.Empty;
|
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
|
||||||
|
|
||||||
var extension = Path.GetExtension(path);
|
var fileName = Path.GetFileName(path);
|
||||||
|
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var filenameWithoutExtension = extIndex >= 0 ? fileName.Remove(extIndex, fileExtension.Length) : fileName;
|
||||||
|
var fileStem
|
||||||
|
= Path.Combine(dir, filenameWithoutExtension.TruncateFilename(LongPath.MaxFilenameLength - fileExtension.Length))
|
||||||
|
.TruncateFilename(LongPath.MaxPathLength - fileExtension.Length);
|
||||||
|
|
||||||
var filename = Path.GetFileNameWithoutExtension(path).Truncate(LongPath.MaxFilenameLength - extension.Length);
|
var fullfilename = removeInvalidWhitespace(fileStem) + fileExtension;
|
||||||
var fileStem = Path.Combine(dir, filename);
|
|
||||||
|
|
||||||
|
|
||||||
var fullfilename = fileStem.Truncate(LongPath.MaxPathLength - extension.Length) + extension;
|
|
||||||
|
|
||||||
fullfilename = removeInvalidWhitespace(fullfilename);
|
|
||||||
|
|
||||||
var i = 0;
|
var i = 0;
|
||||||
while (File.Exists(fullfilename) && !returnFirstExisting)
|
while (File.Exists(fullfilename) && !returnFirstExisting)
|
||||||
{
|
{
|
||||||
var increm = $" ({++i})";
|
var increm = $" ({++i})";
|
||||||
fullfilename = fileStem.Truncate(LongPath.MaxPathLength - increm.Length - extension.Length) + increm + extension;
|
fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - fileExtension.Length) + increm + fileExtension;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fullfilename;
|
return fullfilename;
|
||||||
@@ -84,7 +88,7 @@ namespace FileManager
|
|||||||
|
|
||||||
var pathNoPrefix = path.PathWithoutPrefix;
|
var pathNoPrefix = path.PathWithoutPrefix;
|
||||||
|
|
||||||
pathNoPrefix = replacements.ReplaceInvalidPathChars(pathNoPrefix);
|
pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix);
|
||||||
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
|
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
|
||||||
|
|
||||||
return pathNoPrefix;
|
return pathNoPrefix;
|
||||||
@@ -129,15 +133,28 @@ namespace FileManager
|
|||||||
|
|
||||||
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
|
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
|
||||||
|
|
||||||
|
public static string TruncateFilename(this string filenameStr, int limit)
|
||||||
|
{
|
||||||
|
if (LongPath.IsWindows) return filenameStr.Truncate(limit);
|
||||||
|
|
||||||
|
int index = filenameStr.Length;
|
||||||
|
|
||||||
|
while (index > 0 && System.Text.Encoding.UTF8.GetByteCount(filenameStr, 0, index) > limit)
|
||||||
|
index--;
|
||||||
|
|
||||||
|
return filenameStr[..index];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Move file.
|
/// Move file.
|
||||||
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
||||||
/// <br/>- Perform <see cref="SaferMove"/>
|
/// <br/>- Perform <see cref="SaferMove"/>
|
||||||
/// <br/>- Return valid path
|
/// <br/>- Return valid path
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements)
|
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements, string extension = null)
|
||||||
{
|
{
|
||||||
destination = GetValidFilename(destination, replacements);
|
extension = extension ?? Path.GetExtension(source);
|
||||||
|
destination = GetValidFilename(destination, replacements, extension);
|
||||||
SaferMove(source, destination);
|
SaferMove(source, destination);
|
||||||
return destination;
|
return destination;
|
||||||
}
|
}
|
||||||
@@ -212,25 +229,23 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
var foundFiles = Enumerable.Empty<LongPath>();
|
var foundFiles = Enumerable.Empty<LongPath>();
|
||||||
|
|
||||||
if (searchOption == SearchOption.AllDirectories)
|
try
|
||||||
{
|
{
|
||||||
try
|
if (searchOption == SearchOption.AllDirectories)
|
||||||
{
|
{
|
||||||
IEnumerable <LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
|
IEnumerable<LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
|
||||||
// Add files in subdirectories recursively to the list
|
// Add files in subdirectories recursively to the list
|
||||||
foreach (string dir in subDirs)
|
foreach (string dir in subDirs)
|
||||||
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException) { }
|
|
||||||
catch (PathTooLongException) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Add files from the current directory
|
// Add files from the current directory
|
||||||
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
|
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
|
||||||
}
|
}
|
||||||
catch (UnauthorizedAccessException) { }
|
catch (UnauthorizedAccessException) { }
|
||||||
|
catch (PathTooLongException) { }
|
||||||
|
// Symbolic links will result in DirectoryNotFoundException. Ohter logical directories might also. Just skip them. Don't want to risk (or have to handle) infinite recursion
|
||||||
|
catch (DirectoryNotFoundException) { }
|
||||||
|
|
||||||
return foundFiles;
|
return foundFiles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,33 +10,75 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
//https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd
|
//https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd
|
||||||
|
|
||||||
public const int MaxDirectoryLength = MaxPathLength - 13;
|
|
||||||
public const int MaxPathLength = short.MaxValue;
|
|
||||||
public const int MaxFilenameLength = 255;
|
public const int MaxFilenameLength = 255;
|
||||||
|
public static readonly int MaxDirectoryLength;
|
||||||
|
public static readonly int MaxPathLength;
|
||||||
|
private const int WIN_MAX_PATH = 260;
|
||||||
|
private const string WIN_LONG_PATH_PREFIX = @"\\?\";
|
||||||
|
internal static readonly bool IsWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||||
|
internal static readonly bool IsLinux = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||||
|
internal static readonly bool IsOSX = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
private const int MAX_PATH = 260;
|
static LongPath()
|
||||||
private const string LONG_PATH_PREFIX = "\\\\?\\";
|
{
|
||||||
|
if (IsWindows)
|
||||||
|
{
|
||||||
|
MaxPathLength = short.MaxValue;
|
||||||
|
MaxDirectoryLength = MaxPathLength - 13;
|
||||||
|
}
|
||||||
|
else if (IsOSX)
|
||||||
|
{
|
||||||
|
MaxPathLength = 1024;
|
||||||
|
MaxDirectoryLength = MaxPathLength - MaxFilenameLength;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MaxPathLength = 4096;
|
||||||
|
MaxDirectoryLength = MaxPathLength - MaxFilenameLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string Path { get; init; }
|
private LongPath(string path)
|
||||||
public override string ToString() => Path;
|
{
|
||||||
|
if (IsWindows && path.Length > MaxPathLength)
|
||||||
|
throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} character limit. ({path})");
|
||||||
|
if (!IsWindows && Encoding.UTF8.GetByteCount(path) > MaxPathLength)
|
||||||
|
throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} byte limit. ({path})");
|
||||||
|
|
||||||
|
Path = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Filename limits on NTFS and FAT filesystems are based on characters,
|
||||||
|
//but on ext* filesystems they're based on bytes. The ext* filesystems
|
||||||
|
//don't care about encoding, so how unicode characters are encoded is
|
||||||
|
///a choice made by the linux kernel. As best as I can tell, pretty
|
||||||
|
//much everyone uses UTF-8.
|
||||||
|
public static int GetFilesystemStringLength(StringBuilder filename)
|
||||||
|
=> LongPath.IsWindows ?
|
||||||
|
filename.Length
|
||||||
|
: Encoding.UTF8.GetByteCount(filename.ToString());
|
||||||
|
|
||||||
public static implicit operator LongPath(string path)
|
public static implicit operator LongPath(string path)
|
||||||
{
|
{
|
||||||
|
if (!IsWindows) return new LongPath(path);
|
||||||
|
|
||||||
if (path is null) return null;
|
if (path is null) return null;
|
||||||
|
|
||||||
//File I/O functions in the Windows API convert "/" to "\" as part of converting
|
//File I/O functions in the Windows API convert "/" to "\" as part of converting
|
||||||
//the name to an NT-style name, except when using the "\\?\" prefix
|
//the name to an NT-style name, except when using the "\\?\" prefix
|
||||||
path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);
|
path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
if (path.StartsWith(LONG_PATH_PREFIX))
|
if (path.StartsWith(WIN_LONG_PATH_PREFIX))
|
||||||
return new LongPath { Path = path };
|
return new LongPath(path);
|
||||||
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith("UNC\\"))
|
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith(@"UNC\"))
|
||||||
return new LongPath { Path = LONG_PATH_PREFIX + path };
|
return new LongPath(WIN_LONG_PATH_PREFIX + path);
|
||||||
else if (path.StartsWith("\\\\"))
|
else if (path.StartsWith(@"\\"))
|
||||||
//The "\\?\" prefix can also be used with paths constructed according to the
|
//The "\\?\" prefix can also be used with paths constructed according to the
|
||||||
//universal naming convention (UNC). To specify such a path using UNC, use
|
//universal naming convention (UNC). To specify such a path using UNC, use
|
||||||
//the "\\?\UNC\" prefix.
|
//the "\\?\UNC\" prefix.
|
||||||
return new LongPath { Path = LONG_PATH_PREFIX + "UNC\\" + path.Substring(2) };
|
return new LongPath(WIN_LONG_PATH_PREFIX + @"UNC\" + path.Substring(2));
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
//These prefixes are not used as part of the path itself. They indicate that
|
//These prefixes are not used as part of the path itself. They indicate that
|
||||||
@@ -45,9 +87,9 @@ namespace FileManager
|
|||||||
//a period to represent the current directory, or double dots to represent the
|
//a period to represent the current directory, or double dots to represent the
|
||||||
//parent directory. Because you cannot use the "\\?\" prefix with a relative
|
//parent directory. Because you cannot use the "\\?\" prefix with a relative
|
||||||
//path, relative paths are always limited to a total of MAX_PATH characters.
|
//path, relative paths are always limited to a total of MAX_PATH characters.
|
||||||
if (path.Length > MAX_PATH)
|
if (path.Length > WIN_MAX_PATH)
|
||||||
throw new System.IO.PathTooLongException();
|
throw new System.IO.PathTooLongException();
|
||||||
return new LongPath { Path = path };
|
return new LongPath(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +100,8 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
if (!IsWindows) return Path;
|
||||||
|
|
||||||
//Short Path names are useful for navigating to the file in windows explorer,
|
//Short Path names are useful for navigating to the file in windows explorer,
|
||||||
//which will not recognize paths longer than MAX_PATH. Short path names are not
|
//which will not recognize paths longer than MAX_PATH. Short path names are not
|
||||||
//always enabled on every volume. So to check if a volume enables short path
|
//always enabled on every volume. So to check if a volume enables short path
|
||||||
@@ -96,6 +140,7 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
|
if (!IsWindows) return Path;
|
||||||
if (Path is null) return null;
|
if (Path is null) return null;
|
||||||
|
|
||||||
StringBuilder longPathBuffer = new(MaxPathLength);
|
StringBuilder longPathBuffer = new(MaxPathLength);
|
||||||
@@ -106,9 +151,17 @@ namespace FileManager
|
|||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string PathWithoutPrefix
|
public string PathWithoutPrefix
|
||||||
=> Path?.StartsWith(LONG_PATH_PREFIX) == true ?
|
{
|
||||||
Path.Remove(0, LONG_PATH_PREFIX.Length) :
|
get
|
||||||
Path;
|
{
|
||||||
|
if (!IsWindows) return Path;
|
||||||
|
return
|
||||||
|
Path?.StartsWith(WIN_LONG_PATH_PREFIX) == true ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
|
||||||
|
:Path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Path;
|
||||||
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||||
@@ -116,5 +169,6 @@ namespace FileManager
|
|||||||
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||||
private static extern int GetLongPathName([MarshalAs(UnmanagedType.LPWStr)] string lpszShortPath, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszLongPath, int cchBuffer);
|
private static extern int GetLongPathName([MarshalAs(UnmanagedType.LPWStr)] string lpszShortPath, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszLongPath, int cchBuffer);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,26 +33,43 @@ namespace FileManager
|
|||||||
createNewFile();
|
createNewFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetString(string propertyName)
|
public string GetString(string propertyName, string defaultValue = null)
|
||||||
{
|
{
|
||||||
if (!stringCache.ContainsKey(propertyName))
|
if (!stringCache.ContainsKey(propertyName))
|
||||||
{
|
{
|
||||||
var jObject = readFile();
|
var jObject = readFile();
|
||||||
if (!jObject.ContainsKey(propertyName))
|
if (jObject.ContainsKey(propertyName))
|
||||||
return null;
|
stringCache[propertyName] = jObject[propertyName].Value<string>();
|
||||||
stringCache[propertyName] = jObject[propertyName].Value<string>();
|
else
|
||||||
|
stringCache[propertyName] = defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return stringCache[propertyName];
|
return stringCache[propertyName];
|
||||||
}
|
}
|
||||||
|
|
||||||
public T GetNonString<T>(string propertyName)
|
public T GetNonString<T>(string propertyName, T defaultValue = default)
|
||||||
{
|
{
|
||||||
var obj = GetObject(propertyName);
|
var obj = GetObject(propertyName);
|
||||||
if (obj is null) return default;
|
|
||||||
if (obj is JValue jValue) return jValue.Value<T>();
|
if (obj is null)
|
||||||
if (obj is JObject jObject) return jObject.ToObject<T>();
|
{
|
||||||
return (T)obj;
|
objectCache[propertyName] = defaultValue;
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
|
||||||
|
if (obj is JObject jObject) return jObject.ToObject<T>();
|
||||||
|
if (obj is JValue jValue)
|
||||||
|
{
|
||||||
|
if (typeof(T).IsAssignableTo(typeof(Enum)))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
|
||||||
|
? (T)enumVal
|
||||||
|
: Enum.GetValues(typeof(T)).Cast<T>().First();
|
||||||
|
}
|
||||||
|
return jValue.Value<T>();
|
||||||
|
}
|
||||||
|
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public object GetObject(string propertyName)
|
public object GetObject(string propertyName)
|
||||||
@@ -179,6 +196,7 @@ namespace FileManager
|
|||||||
}
|
}
|
||||||
catch (Exception exDebug)
|
catch (Exception exDebug)
|
||||||
{
|
{
|
||||||
|
Serilog.Log.Logger.Debug(exDebug, "Silent failure");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ using System.Linq;
|
|||||||
|
|
||||||
namespace FileManager
|
namespace FileManager
|
||||||
{
|
{
|
||||||
public class Replacement : ICloneable
|
public record Replacement
|
||||||
{
|
{
|
||||||
public const int FIXED_COUNT = 6;
|
public const int FIXED_COUNT = 6;
|
||||||
|
|
||||||
internal const char QUOTE_MARK = '"';
|
internal const char QUOTE_MARK = '"';
|
||||||
[JsonIgnore] public bool Mandatory { get; internal set; }
|
[JsonIgnore] public bool Mandatory { get; set; }
|
||||||
[JsonProperty] public char CharacterToReplace { get; private set; }
|
[JsonProperty] public char CharacterToReplace { get; private set; }
|
||||||
[JsonProperty] public string ReplacementString { get; private set; }
|
[JsonProperty] public string ReplacementString { get; private set; }
|
||||||
[JsonProperty] public string Description { get; private set; }
|
[JsonProperty] public string Description { get; set; }
|
||||||
public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})";
|
public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})";
|
||||||
|
|
||||||
public Replacement(char charToReplace, string replacementString, string description)
|
public Replacement(char charToReplace, string replacementString, string description)
|
||||||
@@ -30,8 +30,6 @@ namespace FileManager
|
|||||||
Mandatory = mandatory;
|
Mandatory = mandatory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public object Clone() => new Replacement(CharacterToReplace, ReplacementString, Description, Mandatory);
|
|
||||||
|
|
||||||
public void Update(char charToReplace, string replacementString, string description)
|
public void Update(char charToReplace, string replacementString, string description)
|
||||||
{
|
{
|
||||||
ReplacementString = replacementString;
|
ReplacementString = replacementString;
|
||||||
@@ -61,59 +59,118 @@ namespace FileManager
|
|||||||
[JsonConverter(typeof(ReplacementCharactersConverter))]
|
[JsonConverter(typeof(ReplacementCharactersConverter))]
|
||||||
public class ReplacementCharacters
|
public class ReplacementCharacters
|
||||||
{
|
{
|
||||||
public static readonly ReplacementCharacters Default = new()
|
public override bool Equals(object obj)
|
||||||
{
|
{
|
||||||
Replacements = new List<Replacement>()
|
if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count)
|
||||||
{
|
{
|
||||||
Replacement.OtherInvalid("_"),
|
for (int i = 0; i < Replacements.Count; i++)
|
||||||
Replacement.FilenameForwardSlash("∕"),
|
if (Replacements[i] != second.Replacements[i])
|
||||||
Replacement.FilenameBackSlash(""),
|
return false;
|
||||||
Replacement.OpenQuote("“"),
|
|
||||||
Replacement.CloseQuote("”"),
|
|
||||||
Replacement.OtherQuote("""),
|
|
||||||
Replacement.OpenAngleBracket("<"),
|
|
||||||
Replacement.CloseAngleBracket(">"),
|
|
||||||
Replacement.Colon("꞉"),
|
|
||||||
Replacement.Asterisk("✱"),
|
|
||||||
Replacement.QuestionMark("?"),
|
|
||||||
Replacement.Pipe("⏐"),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public static readonly ReplacementCharacters LoFiDefault = new()
|
return true;
|
||||||
{
|
}
|
||||||
Replacements = new List<Replacement>()
|
return false;
|
||||||
|
}
|
||||||
|
public override int GetHashCode() => Replacements.GetHashCode();
|
||||||
|
|
||||||
|
public static readonly ReplacementCharacters Default
|
||||||
|
= IsWindows
|
||||||
|
? new()
|
||||||
{
|
{
|
||||||
Replacement.OtherInvalid("_"),
|
Replacements = new Replacement[]
|
||||||
Replacement.FilenameForwardSlash("_"),
|
{
|
||||||
Replacement.FilenameBackSlash("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.OpenQuote("'"),
|
Replacement.FilenameForwardSlash("∕"),
|
||||||
Replacement.CloseQuote("'"),
|
Replacement.FilenameBackSlash(""),
|
||||||
Replacement.OtherQuote("'"),
|
Replacement.OpenQuote("“"),
|
||||||
Replacement.OpenAngleBracket("{"),
|
Replacement.CloseQuote("”"),
|
||||||
Replacement.CloseAngleBracket("}"),
|
Replacement.OtherQuote("""),
|
||||||
Replacement.Colon("-"),
|
Replacement.OpenAngleBracket("<"),
|
||||||
|
Replacement.CloseAngleBracket(">"),
|
||||||
|
Replacement.Colon("꞉"),
|
||||||
|
Replacement.Asterisk("✱"),
|
||||||
|
Replacement.QuestionMark("?"),
|
||||||
|
Replacement.Pipe("⏐"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
: new()
|
||||||
|
|
||||||
public static readonly ReplacementCharacters Barebones = new()
|
|
||||||
{
|
|
||||||
Replacements = new List<Replacement>()
|
|
||||||
{
|
{
|
||||||
Replacement.OtherInvalid("_"),
|
Replacements = new Replacement[]
|
||||||
Replacement.FilenameForwardSlash("_"),
|
{
|
||||||
Replacement.FilenameBackSlash("_"),
|
Replacement.OtherInvalid("_"),
|
||||||
Replacement.OpenQuote("_"),
|
Replacement.FilenameForwardSlash("∕"),
|
||||||
Replacement.CloseQuote("_"),
|
Replacement.FilenameBackSlash("\\"),
|
||||||
Replacement.OtherQuote("_"),
|
Replacement.OpenQuote("“"),
|
||||||
}
|
Replacement.CloseQuote("”"),
|
||||||
};
|
Replacement.OtherQuote("\"")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private static readonly char[] invalidChars = Path.GetInvalidPathChars().Union(new[] {
|
public static readonly ReplacementCharacters LoFiDefault
|
||||||
'*', '?', ':',
|
= IsWindows
|
||||||
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
|
? new()
|
||||||
// In live code, Path.GetInvalidPathChars() does not include them
|
{
|
||||||
'"', '<', '>'
|
Replacements = new Replacement[]
|
||||||
|
{
|
||||||
|
Replacement.OtherInvalid("_"),
|
||||||
|
Replacement.FilenameForwardSlash("_"),
|
||||||
|
Replacement.FilenameBackSlash("_"),
|
||||||
|
Replacement.OpenQuote("'"),
|
||||||
|
Replacement.CloseQuote("'"),
|
||||||
|
Replacement.OtherQuote("'"),
|
||||||
|
Replacement.OpenAngleBracket("{"),
|
||||||
|
Replacement.CloseAngleBracket("}"),
|
||||||
|
Replacement.Colon("-"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: new ()
|
||||||
|
{
|
||||||
|
Replacements = new Replacement[]
|
||||||
|
{
|
||||||
|
Replacement.OtherInvalid("_"),
|
||||||
|
Replacement.FilenameForwardSlash("_"),
|
||||||
|
Replacement.FilenameBackSlash("\\"),
|
||||||
|
Replacement.OpenQuote("\""),
|
||||||
|
Replacement.CloseQuote("\""),
|
||||||
|
Replacement.OtherQuote("\"")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly ReplacementCharacters Barebones
|
||||||
|
= IsWindows
|
||||||
|
? new ()
|
||||||
|
{
|
||||||
|
Replacements = new Replacement[]
|
||||||
|
{
|
||||||
|
Replacement.OtherInvalid("_"),
|
||||||
|
Replacement.FilenameForwardSlash("_"),
|
||||||
|
Replacement.FilenameBackSlash("_"),
|
||||||
|
Replacement.OpenQuote("_"),
|
||||||
|
Replacement.CloseQuote("_"),
|
||||||
|
Replacement.OtherQuote("_")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: new ()
|
||||||
|
{
|
||||||
|
Replacements = new Replacement[]
|
||||||
|
{
|
||||||
|
Replacement.OtherInvalid("_"),
|
||||||
|
Replacement.FilenameForwardSlash("_"),
|
||||||
|
Replacement.FilenameBackSlash("\\"),
|
||||||
|
Replacement.OpenQuote("\""),
|
||||||
|
Replacement.CloseQuote("\""),
|
||||||
|
Replacement.OtherQuote("\"")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT;
|
||||||
|
|
||||||
|
private static readonly char[] invalidPathChars = Path.GetInvalidFileNameChars().Except(new[] {
|
||||||
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
private static readonly char[] invalidSlashes = Path.GetInvalidFileNameChars().Intersect(new[] {
|
||||||
|
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
public IReadOnlyList<Replacement> Replacements { get; init; }
|
public IReadOnlyList<Replacement> Replacements { get; init; }
|
||||||
@@ -158,6 +215,10 @@ namespace FileManager
|
|||||||
return OtherQuote;
|
return OtherQuote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!IsWindows && toReplace == BackSlash.CharacterToReplace)
|
||||||
|
return BackSlash.ReplacementString;
|
||||||
|
|
||||||
|
//Replace any other non-mandatory characters
|
||||||
for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++)
|
for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++)
|
||||||
{
|
{
|
||||||
var r = Replacements[i];
|
var r = Replacements[i];
|
||||||
@@ -167,11 +228,12 @@ namespace FileManager
|
|||||||
return DefaultReplacement;
|
return DefaultReplacement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static bool ContainsInvalidPathChar(string path)
|
||||||
|
=> path.Any(c => invalidPathChars.Contains(c));
|
||||||
|
public static bool ContainsInvalidFilenameChar(string path)
|
||||||
|
=> ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c));
|
||||||
|
|
||||||
public static bool ContainsInvalid(string path)
|
public string ReplaceFilenameChars(string fileName)
|
||||||
=> path.Any(c => invalidChars.Contains(c));
|
|
||||||
|
|
||||||
public string ReplaceInvalidFilenameChars(string fileName)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(fileName)) return string.Empty;
|
if (string.IsNullOrEmpty(fileName)) return string.Empty;
|
||||||
var builder = new System.Text.StringBuilder();
|
var builder = new System.Text.StringBuilder();
|
||||||
@@ -179,7 +241,9 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
var c = fileName[i];
|
var c = fileName[i];
|
||||||
|
|
||||||
if (invalidChars.Contains(c) || c == ForwardSlash.CharacterToReplace || c == BackSlash.CharacterToReplace)
|
if (invalidPathChars.Contains(c)
|
||||||
|
|| invalidSlashes.Contains(c)
|
||||||
|
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
|
||||||
{
|
{
|
||||||
char preceding = i > 0 ? fileName[i - 1] : default;
|
char preceding = i > 0 ? fileName[i - 1] : default;
|
||||||
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
|
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
|
||||||
@@ -187,30 +251,42 @@ namespace FileManager
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
builder.Append(c);
|
builder.Append(c);
|
||||||
|
|
||||||
}
|
}
|
||||||
return builder.ToString();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ReplaceInvalidPathChars(string pathStr)
|
public string ReplacePathChars(string pathStr)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(pathStr)) return string.Empty;
|
if (string.IsNullOrEmpty(pathStr)) return string.Empty;
|
||||||
|
|
||||||
// replace all colons except within the first 2 chars
|
|
||||||
var builder = new System.Text.StringBuilder();
|
var builder = new System.Text.StringBuilder();
|
||||||
for (var i = 0; i < pathStr.Length; i++)
|
for (var i = 0; i < pathStr.Length; i++)
|
||||||
{
|
{
|
||||||
var c = pathStr[i];
|
var c = pathStr[i];
|
||||||
|
|
||||||
if (!invalidChars.Contains(c) || (c == ':' && i == 1 && Path.IsPathRooted(pathStr)))
|
if (
|
||||||
builder.Append(c);
|
(
|
||||||
else
|
invalidPathChars.Contains(c)
|
||||||
|
|| ( // Replace any other legal characters that they user wants.
|
||||||
|
c != Path.DirectorySeparatorChar
|
||||||
|
&& c != Path.AltDirectorySeparatorChar
|
||||||
|
&& Replacements.Any(r => r.CharacterToReplace == c)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
&& !( // replace all colons except drive letter designator on Windows
|
||||||
|
c == ':'
|
||||||
|
&& i == 1
|
||||||
|
&& Path.IsPathRooted(pathStr)
|
||||||
|
&& IsWindows
|
||||||
|
)
|
||||||
|
)
|
||||||
{
|
{
|
||||||
char preceding = i > 0 ? pathStr[i - 1] : default;
|
char preceding = i > 0 ? pathStr[i - 1] : default;
|
||||||
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
|
||||||
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
builder.Append(c);
|
||||||
}
|
}
|
||||||
return builder.ToString();
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
@@ -232,28 +308,19 @@ namespace FileManager
|
|||||||
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
|
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
|
||||||
//If not, reset to default.
|
//If not, reset to default.
|
||||||
|
|
||||||
var default0 = Replacement.OtherInvalid("");
|
|
||||||
var default1 = Replacement.FilenameForwardSlash("");
|
|
||||||
var default2 = Replacement.FilenameBackSlash("");
|
|
||||||
var default3 = Replacement.OpenQuote("");
|
|
||||||
var default4 = Replacement.CloseQuote("");
|
|
||||||
var default5 = Replacement.OtherQuote("");
|
|
||||||
|
|
||||||
if (dict.Count < Replacement.FIXED_COUNT ||
|
|
||||||
dict[0].CharacterToReplace != default0.CharacterToReplace || dict[0].Description != default0.Description ||
|
|
||||||
dict[1].CharacterToReplace != default1.CharacterToReplace || dict[1].Description != default1.Description ||
|
|
||||||
dict[2].CharacterToReplace != default2.CharacterToReplace || dict[2].Description != default2.Description ||
|
|
||||||
dict[3].CharacterToReplace != default3.CharacterToReplace || dict[3].Description != default3.Description ||
|
|
||||||
dict[4].CharacterToReplace != default4.CharacterToReplace || dict[4].Description != default4.Description ||
|
|
||||||
dict[5].CharacterToReplace != default5.CharacterToReplace || dict[5].Description != default5.Description ||
|
|
||||||
dict.Any(r => ReplacementCharacters.ContainsInvalid(r.ReplacementString))
|
|
||||||
)
|
|
||||||
{
|
|
||||||
dict = ReplacementCharacters.Default.Replacements;
|
|
||||||
}
|
|
||||||
//First FIXED_COUNT are mandatory
|
|
||||||
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
|
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
|
||||||
|
{
|
||||||
|
if (dict.Count < Replacement.FIXED_COUNT
|
||||||
|
|| dict[i].CharacterToReplace != ReplacementCharacters.Barebones.Replacements[i].CharacterToReplace
|
||||||
|
|| dict[i].Description != ReplacementCharacters.Barebones.Replacements[i].Description)
|
||||||
|
{
|
||||||
|
dict = ReplacementCharacters.Default.Replacements;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
//First FIXED_COUNT are mandatory
|
||||||
dict[i].Mandatory = true;
|
dict[i].Mandatory = true;
|
||||||
|
}
|
||||||
|
|
||||||
return new ReplacementCharacters { Replacements = dict };
|
return new ReplacementCharacters { Replacements = dict };
|
||||||
}
|
}
|
||||||
@@ -263,7 +330,7 @@ namespace FileManager
|
|||||||
ReplacementCharacters replacements = (ReplacementCharacters)value;
|
ReplacementCharacters replacements = (ReplacementCharacters)value;
|
||||||
|
|
||||||
var propertyNames = replacements.Replacements
|
var propertyNames = replacements.Replacements
|
||||||
.Select(c => JObject.FromObject(c)).ToList();
|
.Select(JObject.FromObject).ToList();
|
||||||
|
|
||||||
var prop = new JProperty(nameof(Replacement), new JArray(propertyNames));
|
var prop = new JProperty(nameof(Replacement), new JArray(propertyNames));
|
||||||
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
using ApplicationServices;
|
|
||||||
using AppScaffolding;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Hangover
|
|
||||||
{
|
|
||||||
public partial class Form1
|
|
||||||
{
|
|
||||||
private string dbFile;
|
|
||||||
|
|
||||||
private void Load_databaseTab()
|
|
||||||
{
|
|
||||||
dbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
|
||||||
if (dbFile is null)
|
|
||||||
{
|
|
||||||
databaseFileLbl.Text = $"Database file not found";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
databaseFileLbl.Text = $"Database file: {UNSAFE_MigrationHelper.DatabaseFile ?? "not found"}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (!databaseTab.Visible)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sqlExecuteBtn_Click(object sender, EventArgs e)
|
|
||||||
{
|
|
||||||
ensureBackup();
|
|
||||||
|
|
||||||
sqlResultsTb.Clear();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var sql = sqlTb.Text.Trim();
|
|
||||||
|
|
||||||
#region // explanation
|
|
||||||
// Routing statements to non-query is a convenience.
|
|
||||||
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
|
|
||||||
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
|
|
||||||
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
|
|
||||||
// -- line 1 is a comment
|
|
||||||
// delete from foo
|
|
||||||
#endregion
|
|
||||||
var lower = sql.ToLower();
|
|
||||||
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
|
|
||||||
nonQuery(sql);
|
|
||||||
else
|
|
||||||
query(sql);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
sqlResultsTb.Text = $"{ex.Message}\r\n{ex.StackTrace}";
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
deleteUnneededBackups();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string dbBackup;
|
|
||||||
private DateTime dbFileLastModified;
|
|
||||||
private void ensureBackup()
|
|
||||||
{
|
|
||||||
if (dbBackup is not null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
dbFileLastModified = File.GetLastWriteTimeUtc(dbFile);
|
|
||||||
|
|
||||||
dbBackup
|
|
||||||
= Path.ChangeExtension(dbFile, "").TrimEnd('.')
|
|
||||||
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
|
|
||||||
+ Path.GetExtension(dbFile);
|
|
||||||
File.Copy(dbFile, dbBackup);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteUnneededBackups()
|
|
||||||
{
|
|
||||||
var newLastModified = File.GetLastWriteTimeUtc(dbFile);
|
|
||||||
if (dbFileLastModified == newLastModified)
|
|
||||||
{
|
|
||||||
File.Delete(dbBackup);
|
|
||||||
dbBackup = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void query(string sql)
|
|
||||||
{
|
|
||||||
// ef doesn't support truly generic queries. have to drop down to ado.net
|
|
||||||
using var context = DbContexts.GetContext();
|
|
||||||
using var conn = context.Database.GetDbConnection();
|
|
||||||
conn.Open();
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = sql;
|
|
||||||
|
|
||||||
var reader = cmd.ExecuteReader();
|
|
||||||
var results = 0;
|
|
||||||
var builder = new System.Text.StringBuilder();
|
|
||||||
var lines = 0;
|
|
||||||
while (reader.Read())
|
|
||||||
{
|
|
||||||
results++;
|
|
||||||
|
|
||||||
for (var i = 0; i < reader.FieldCount; i++)
|
|
||||||
builder.Append(reader.GetValue(i) + "\t");
|
|
||||||
builder.AppendLine();
|
|
||||||
|
|
||||||
lines++;
|
|
||||||
if (lines % 10 == 0)
|
|
||||||
{
|
|
||||||
sqlResultsTb.AppendText(builder.ToString());
|
|
||||||
builder.Clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlResultsTb.AppendText(builder.ToString());
|
|
||||||
builder.Clear();
|
|
||||||
|
|
||||||
if (results == 0)
|
|
||||||
sqlResultsTb.Text = "[no results]";
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sqlResultsTb.AppendText($"\r\n{results} result");
|
|
||||||
if (results != 1) sqlResultsTb.AppendText("s");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void nonQuery(string sql)
|
|
||||||
{
|
|
||||||
using var context = DbContexts.GetContext();
|
|
||||||
var results = context.Database.ExecuteSqlRaw(sql);
|
|
||||||
|
|
||||||
sqlResultsTb.AppendText($"{results} record");
|
|
||||||
if (results != 1) sqlResultsTb.AppendText("s");
|
|
||||||
sqlResultsTb.AppendText(" affected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace Hangover
|
|
||||||
{
|
|
||||||
public partial class Form1 : Form
|
|
||||||
{
|
|
||||||
public Form1()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
|
|
||||||
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
|
|
||||||
cliTab.VisibleChanged += cliTab_VisibleChanged;
|
|
||||||
|
|
||||||
Load_databaseTab();
|
|
||||||
Load_cliTab();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:HangoverAvalonia"
|
||||||
|
x:Class="HangoverAvalonia.App">
|
||||||
|
<Application.DataTemplates>
|
||||||
|
<local:ViewLocator/>
|
||||||
|
</Application.DataTemplates>
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme Mode="Light"/>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using HangoverAvalonia.ViewModels;
|
||||||
|
using HangoverAvalonia.Views;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia
|
||||||
|
{
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
var mainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = new MainVM(),
|
||||||
|
};
|
||||||
|
desktop.MainWindow = mainWindow;
|
||||||
|
mainWindow.OnLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -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="HangoverAvalonia.Controls.CheckedListBox">
|
||||||
|
|
||||||
|
<UserControl.Resources>
|
||||||
|
<RecyclePool x:Key="RecyclePool" />
|
||||||
|
<DataTemplate x:Key="queuedBook">
|
||||||
|
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||||
|
</DataTemplate>
|
||||||
|
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
|
||||||
|
<RecyclingElementFactory.Templates>
|
||||||
|
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
|
||||||
|
</RecyclingElementFactory.Templates>
|
||||||
|
</RecyclingElementFactory>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<ScrollViewer
|
||||||
|
Name="scroller"
|
||||||
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<ItemsRepeater IsVisible="True"
|
||||||
|
VerticalCacheLength="1.2"
|
||||||
|
HorizontalCacheLength="1"
|
||||||
|
Items="{Binding CheckboxItems}"
|
||||||
|
ItemTemplate="{StaticResource elementFactory}" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Collections;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using HangoverAvalonia.ViewModels;
|
||||||
|
using ReactiveUI;
|
||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.Controls
|
||||||
|
{
|
||||||
|
public partial class CheckedListBox : UserControl
|
||||||
|
{
|
||||||
|
public event EventHandler<ItemCheckEventArgs> ItemCheck;
|
||||||
|
|
||||||
|
public static readonly StyledProperty<IEnumerable> ItemsProperty =
|
||||||
|
AvaloniaProperty.Register<CheckedListBox, IEnumerable>(nameof(Items));
|
||||||
|
|
||||||
|
public IEnumerable Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
|
||||||
|
private CheckedListBoxViewModel _viewModel = new();
|
||||||
|
|
||||||
|
public IEnumerable<object> CheckedItems =>
|
||||||
|
_viewModel
|
||||||
|
.CheckboxItems
|
||||||
|
.Where(i => i.IsChecked)
|
||||||
|
.Select(i => i.Item);
|
||||||
|
|
||||||
|
public void SetItemChecked(int i, bool isChecked) => _viewModel.CheckboxItems[i].IsChecked = isChecked;
|
||||||
|
public void SetItemChecked(object item, bool isChecked)
|
||||||
|
{
|
||||||
|
var obj = _viewModel.CheckboxItems.SingleOrDefault(i => i.Item == item);
|
||||||
|
if (obj is not null)
|
||||||
|
obj.IsChecked = isChecked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CheckedListBox()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
scroller.DataContext = _viewModel;
|
||||||
|
_viewModel.CheckedChanged += _viewModel_CheckedChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void _viewModel_CheckedChanged(object sender, CheckBoxViewModel e)
|
||||||
|
{
|
||||||
|
var args = new ItemCheckEventArgs { Item = e.Item, ItemIndex = _viewModel.CheckboxItems.IndexOf(e), IsChecked = e.IsChecked };
|
||||||
|
ItemCheck?.Invoke(this, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
if (change.Property.Name == nameof(Items) && Items != null)
|
||||||
|
_viewModel.SetItems(Items);
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CheckedListBoxViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public event EventHandler<CheckBoxViewModel> CheckedChanged;
|
||||||
|
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get; private set; }
|
||||||
|
|
||||||
|
public void SetItems(IEnumerable items)
|
||||||
|
{
|
||||||
|
UnsubscribeFromItems(CheckboxItems);
|
||||||
|
CheckboxItems = new(items.OfType<object>().Select(o => new CheckBoxViewModel { Item = o }));
|
||||||
|
SubscribeToItems(CheckboxItems);
|
||||||
|
this.RaisePropertyChanged(nameof(CheckboxItems));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SubscribeToItems(IEnumerable objects)
|
||||||
|
{
|
||||||
|
foreach (var i in objects.OfType<INotifyPropertyChanged>())
|
||||||
|
i.PropertyChanged += I_PropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnsubscribeFromItems(AvaloniaList<CheckBoxViewModel> objects)
|
||||||
|
{
|
||||||
|
if (objects is null) return;
|
||||||
|
|
||||||
|
foreach (var i in objects)
|
||||||
|
i.PropertyChanged -= I_PropertyChanged;
|
||||||
|
}
|
||||||
|
private void I_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
CheckedChanged?.Invoke(this, (CheckBoxViewModel)sender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public class CheckBoxViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private bool _isChecked;
|
||||||
|
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
|
||||||
|
private object _bookText;
|
||||||
|
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ItemCheckEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
public int ItemIndex { get; init; }
|
||||||
|
public bool IsChecked { get; init; }
|
||||||
|
public object Item { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<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>
|
||||||
|
<IsPublishable>true</IsPublishable>
|
||||||
|
<AssemblyName>Hangover</AssemblyName>
|
||||||
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||||
|
<StartupObject />
|
||||||
|
<IsPublishable>true</IsPublishable>
|
||||||
|
|
||||||
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
|
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||||
|
|
||||||
|
<ApplicationIcon>hangover.ico</ApplicationIcon>
|
||||||
|
</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>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<OutputPath>..\bin\Avalonia\Debug</OutputPath>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<OutputPath>..\bin\Avalonia\Release</OutputPath>
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
<AvaloniaXaml Remove="Models\**" />
|
||||||
|
<Compile Remove="Models\**" />
|
||||||
|
<EmbeddedResource Remove="Models\**" />
|
||||||
|
<None Remove="Models\**" />
|
||||||
|
<None Remove=".gitignore" />
|
||||||
|
<None Remove="Assets\hangover.ico" />
|
||||||
|
<None Remove="hangover.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="ViewModels\MainVM.*.cs">
|
||||||
|
<DependentUpon>MainVM.cs</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
<Compile Update="Views\MainWindow.*.cs">
|
||||||
|
<DependentUpon>MainWindow.axaml</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="hangover.ico" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<!--This helps with theme dll-s trimming.
|
||||||
|
If you will publish your application in self-contained mode with p:PublishTrimmed=true and it will use Fluent theme Default theme will be trimmed from the output and vice versa.
|
||||||
|
https://github.com/AvaloniaUI/Avalonia/issues/5593 -->
|
||||||
|
<TrimmableAssembly Include="Avalonia.Themes.Fluent" />
|
||||||
|
<TrimmableAssembly Include="Avalonia.Themes.Default" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
|
||||||
|
<PackageReference Include="Avalonia" Version="11.0.0-preview4" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview4" />
|
||||||
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
|
<PackageReference Condition="'$(Configuration)' == 'Debug'" 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" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||||
|
<!-- Remove obj folder -->
|
||||||
|
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||||
|
<!-- Remove bin folder -->
|
||||||
|
<RemoveDir Directories="$(BaseOutputPath)" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.ReactiveUI;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia
|
||||||
|
{
|
||||||
|
internal class Program
|
||||||
|
{
|
||||||
|
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||||
|
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||||
|
// yet and stuff might break.
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||||
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.LogToTrace()
|
||||||
|
.UseReactiveUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Any CPU</Platform>
|
||||||
|
<PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Any CPU</Platform>
|
||||||
|
<PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Any CPU</Platform>
|
||||||
|
<PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Templates;
|
||||||
|
using HangoverAvalonia.ViewModels;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia
|
||||||
|
{
|
||||||
|
public class ViewLocator : IDataTemplate
|
||||||
|
{
|
||||||
|
public IControl Build(object data)
|
||||||
|
{
|
||||||
|
var name = data.GetType().FullName!.Replace("ViewModel", "View");
|
||||||
|
var type = Type.GetType(name);
|
||||||
|
|
||||||
|
if (type != null)
|
||||||
|
{
|
||||||
|
return (Control)Activator.CreateInstance(type)!;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new TextBlock { Text = "Not Found: " + name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Match(object data)
|
||||||
|
{
|
||||||
|
return data is ViewModelBase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using HangoverBase;
|
||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.ViewModels
|
||||||
|
{
|
||||||
|
public partial class MainVM
|
||||||
|
{
|
||||||
|
private DatabaseTab _tab;
|
||||||
|
|
||||||
|
private string _databaseFileText;
|
||||||
|
private bool _databaseFound;
|
||||||
|
private string _sqlResults;
|
||||||
|
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
|
||||||
|
public string SqlQuery { get; set; }
|
||||||
|
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
|
||||||
|
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
|
||||||
|
|
||||||
|
private void Load_databaseVM()
|
||||||
|
{
|
||||||
|
_tab = new(new(() => SqlQuery, s => SqlResults = s, s => SqlResults = s));
|
||||||
|
|
||||||
|
_tab.LoadDatabaseFile();
|
||||||
|
if (_tab.DbFile is null)
|
||||||
|
{
|
||||||
|
DatabaseFileText = $"Database file not found";
|
||||||
|
DatabaseFound = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseFileText = $"Database file: {_tab.DbFile}";
|
||||||
|
DatabaseFound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ExecuteQuery() => _tab.ExecuteQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using ApplicationServices;
|
||||||
|
using DataLayer;
|
||||||
|
using ReactiveUI;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.ViewModels
|
||||||
|
{
|
||||||
|
public partial class MainVM
|
||||||
|
{
|
||||||
|
private List<LibraryBook> _deletedBooks;
|
||||||
|
public List<LibraryBook> DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); }
|
||||||
|
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
|
||||||
|
|
||||||
|
private int _totalBooksCount = 0;
|
||||||
|
private int _checkedBooksCount = 0;
|
||||||
|
public int CheckedBooksCount
|
||||||
|
{
|
||||||
|
get => _checkedBooksCount;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_checkedBooksCount != value)
|
||||||
|
{
|
||||||
|
_checkedBooksCount = value;
|
||||||
|
this.RaisePropertyChanged(nameof(CheckedCountText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private void Load_deletedVM()
|
||||||
|
{
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reload()
|
||||||
|
{
|
||||||
|
DeletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
||||||
|
_checkedBooksCount = 0;
|
||||||
|
_totalBooksCount = DeletedBooks.Count;
|
||||||
|
this.RaisePropertyChanged(nameof(CheckedCountText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace HangoverAvalonia.ViewModels
|
||||||
|
{
|
||||||
|
public partial class MainVM : ViewModelBase
|
||||||
|
{
|
||||||
|
public MainVM()
|
||||||
|
{
|
||||||
|
Load_databaseVM();
|
||||||
|
Load_deletedVM();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.ViewModels
|
||||||
|
{
|
||||||
|
public class ViewModelBase : ReactiveObject
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace HangoverAvalonia.Views
|
||||||
|
{
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private void cliTab_VisibleChanged(bool isVisible)
|
||||||
|
{
|
||||||
|
if (!isVisible)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace HangoverAvalonia.Views
|
||||||
|
{
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private void databaseTab_VisibleChanged(bool isVisible)
|
||||||
|
{
|
||||||
|
if (!isVisible)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_viewModel.ExecuteQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using ApplicationServices;
|
||||||
|
using DataLayer;
|
||||||
|
using HangoverAvalonia.Controls;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.Views
|
||||||
|
{
|
||||||
|
public partial class MainWindow
|
||||||
|
{
|
||||||
|
private void deletedTab_VisibleChanged(bool isVisible)
|
||||||
|
{
|
||||||
|
if (!isVisible)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (_viewModel.DeletedBooks.Count == 0)
|
||||||
|
_viewModel.reload();
|
||||||
|
}
|
||||||
|
public void Deleted_CheckedListBox_ItemCheck(object sender, ItemCheckEventArgs args)
|
||||||
|
{
|
||||||
|
_viewModel.CheckedBooksCount = deletedCbl.CheckedItems.Count();
|
||||||
|
}
|
||||||
|
public void Deleted_CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var item in deletedCbl.Items)
|
||||||
|
deletedCbl.SetItemChecked(item, true);
|
||||||
|
}
|
||||||
|
public void Deleted_UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
foreach (var item in deletedCbl.Items)
|
||||||
|
deletedCbl.SetItemChecked(item, false);
|
||||||
|
}
|
||||||
|
public void Deleted_Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
||||||
|
var qtyChanges = libraryBooksToRestore.RestoreBooks();
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
_viewModel.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:HangoverAvalonia.ViewModels"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
|
||||||
|
Width="800" Height="500"
|
||||||
|
x:Class="HangoverAvalonia.Views.MainWindow"
|
||||||
|
Icon="/Assets/hangover.ico "
|
||||||
|
Title="Hangover: Libation debug and recovery tool">
|
||||||
|
<Design.DataContext>
|
||||||
|
<vm:MainVM/>
|
||||||
|
</Design.DataContext>
|
||||||
|
|
||||||
|
<TabControl Name="tabControl1" Grid.Row="0">
|
||||||
|
<TabControl.Styles>
|
||||||
|
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
|
||||||
|
<Setter Property="Height" Value="23"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TabItem">
|
||||||
|
<Setter Property="MinHeight" Value="40"/>
|
||||||
|
<Setter Property="Height" Value="40"/>
|
||||||
|
<Setter Property="Padding" Value="8,2,8,5"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TabItem#Header TextBlock">
|
||||||
|
<Setter Property="MinHeight" Value="5"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="Padding" Value="20,5,20,5"/>
|
||||||
|
</Style>
|
||||||
|
</TabControl.Styles>
|
||||||
|
|
||||||
|
<!-- Database Tab -->
|
||||||
|
<TabItem Name="databaseTab">
|
||||||
|
<TabItem.Header>
|
||||||
|
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
|
||||||
|
</TabItem.Header>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto,2*">
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Margin="0,10,0,5"
|
||||||
|
Grid.Row="0"
|
||||||
|
Text="{Binding DatabaseFileText}" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Margin="0,5,0,5"
|
||||||
|
Grid.Row="1"
|
||||||
|
Text="SQL (database command)" />
|
||||||
|
|
||||||
|
<TextBox
|
||||||
|
Margin="0,5,0,5"
|
||||||
|
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
Grid.Row="3"
|
||||||
|
Content="Execute"
|
||||||
|
IsEnabled="{Binding DatabaseFound}"
|
||||||
|
Click="Execute_Click" />
|
||||||
|
|
||||||
|
<TextBox
|
||||||
|
Margin="0,5,0,10"
|
||||||
|
IsReadOnly="True"
|
||||||
|
Grid.Row="4"
|
||||||
|
Text="{Binding SqlResults}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- Deleted Books Tab -->
|
||||||
|
<TabItem Name="deletedTab">
|
||||||
|
<TabItem.Header>
|
||||||
|
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
|
||||||
|
</TabItem.Header>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Row="0"
|
||||||
|
Margin="5"
|
||||||
|
Text="To restore deleted book, check box and save" />
|
||||||
|
|
||||||
|
<controls:CheckedListBox
|
||||||
|
Grid.Row="1"
|
||||||
|
Margin="5,0,5,0"
|
||||||
|
BorderThickness="1"
|
||||||
|
BorderBrush="Gray"
|
||||||
|
Name="deletedCbl"
|
||||||
|
Items="{Binding DeletedBooks}" />
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
Grid.Row="2"
|
||||||
|
Margin="5"
|
||||||
|
ColumnDefinitions="Auto,Auto,Auto,*">
|
||||||
|
|
||||||
|
<Button Grid.Column="0" Margin="0,0,20,0" Content="Check All" Click="Deleted_CheckAll_Click" />
|
||||||
|
<Button Grid.Column="1" Margin="0,0,20,0" Content="Uncheck All" Click="Deleted_UncheckAll_Click" />
|
||||||
|
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding CheckedCountText}" />
|
||||||
|
<Button Grid.Column="3" HorizontalAlignment="Right" Content="Save" Click="Deleted_Save_Click" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- Command Line Interface Tab -->
|
||||||
|
<TabItem Name="cliTab">
|
||||||
|
<TabItem.Header>
|
||||||
|
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
|
||||||
|
</TabItem.Header>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using AppScaffolding;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using HangoverAvalonia.ViewModels;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.Views
|
||||||
|
{
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
MainVM _viewModel => DataContext as MainVM;
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
var config = LibationScaffolding.RunPreConfigMigrations();
|
||||||
|
LibationScaffolding.RunPostConfigMigrations(config);
|
||||||
|
LibationScaffolding.RunPostMigrationScaffolding(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnLoad()
|
||||||
|
{
|
||||||
|
deletedCbl.ItemCheck += Deleted_CheckedListBox_ItemCheck;
|
||||||
|
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
|
||||||
|
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
|
||||||
|
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,154 @@
|
|||||||
|
using System.Text;
|
||||||
|
using ApplicationServices;
|
||||||
|
using AppScaffolding;
|
||||||
|
using Dinah.Core;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace HangoverBase
|
||||||
|
{
|
||||||
|
public class DatabaseTabCommands
|
||||||
|
{
|
||||||
|
public Func<string> SqlInput { get; }
|
||||||
|
public Action<string> SqlOutputAppend { get; }
|
||||||
|
public Action<string> SqlOutputOverwrite { get; }
|
||||||
|
|
||||||
|
public DatabaseTabCommands() { }
|
||||||
|
public DatabaseTabCommands(
|
||||||
|
Func<string> sqlInput,
|
||||||
|
Action<string> sqlDisplayAppend,
|
||||||
|
Action<string> sqlDisplayOverwrite)
|
||||||
|
{
|
||||||
|
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
|
||||||
|
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
|
||||||
|
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DatabaseTab
|
||||||
|
{
|
||||||
|
private DatabaseTabCommands _commands { get; }
|
||||||
|
public string DbFile { get; private set; }
|
||||||
|
|
||||||
|
public DatabaseTab(DatabaseTabCommands commands)
|
||||||
|
{
|
||||||
|
_commands = ArgumentValidator.EnsureNotNull(commands, nameof(commands));
|
||||||
|
|
||||||
|
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
|
||||||
|
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
|
||||||
|
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||||
|
|
||||||
|
private string dbBackup;
|
||||||
|
private DateTime dbFileLastModified;
|
||||||
|
public void EnsureBackup()
|
||||||
|
{
|
||||||
|
if (dbBackup is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
dbFileLastModified = File.GetLastWriteTimeUtc(DbFile);
|
||||||
|
|
||||||
|
dbBackup
|
||||||
|
= Path.ChangeExtension(DbFile, "").TrimEnd('.')
|
||||||
|
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
|
||||||
|
+ Path.GetExtension(DbFile);
|
||||||
|
File.Copy(DbFile, dbBackup);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ExecuteQuery()
|
||||||
|
{
|
||||||
|
EnsureBackup();
|
||||||
|
|
||||||
|
_commands.SqlOutputOverwrite("");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sql = _commands.SqlInput().Trim();
|
||||||
|
|
||||||
|
#region // explanation
|
||||||
|
// Routing statements to non-query is a convenience.
|
||||||
|
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
|
||||||
|
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
|
||||||
|
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
|
||||||
|
// -- line 1 is a comment
|
||||||
|
// delete from foo
|
||||||
|
#endregion
|
||||||
|
var lower = sql.ToLower();
|
||||||
|
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
|
||||||
|
NonQuery(sql);
|
||||||
|
else
|
||||||
|
Query(sql);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_commands.SqlOutputOverwrite($"{ex.Message}\r\n{ex.StackTrace}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
DeleteUnneededBackups();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteUnneededBackups()
|
||||||
|
{
|
||||||
|
var newLastModified = File.GetLastWriteTimeUtc(DbFile);
|
||||||
|
if (dbFileLastModified == newLastModified)
|
||||||
|
{
|
||||||
|
File.Delete(dbBackup);
|
||||||
|
dbBackup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Query(string sql)
|
||||||
|
{
|
||||||
|
// ef doesn't support truly generic queries. have to drop down to ado.net
|
||||||
|
using var context = DbContexts.GetContext();
|
||||||
|
using var conn = context.Database.GetDbConnection();
|
||||||
|
conn.Open();
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = sql;
|
||||||
|
|
||||||
|
var reader = cmd.ExecuteReader();
|
||||||
|
var results = 0;
|
||||||
|
var builder = new StringBuilder();
|
||||||
|
var lines = 0;
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
results++;
|
||||||
|
|
||||||
|
for (var i = 0; i < reader.FieldCount; i++)
|
||||||
|
builder.Append(reader.GetValue(i) + "\t");
|
||||||
|
builder.AppendLine();
|
||||||
|
|
||||||
|
lines++;
|
||||||
|
if (lines % 10 == 0)
|
||||||
|
{
|
||||||
|
_commands.SqlOutputAppend(builder.ToString());
|
||||||
|
builder.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_commands.SqlOutputAppend(builder.ToString());
|
||||||
|
builder.Clear();
|
||||||
|
|
||||||
|
if (results == 0)
|
||||||
|
_commands.SqlOutputOverwrite("[no results]");
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_commands.SqlOutputAppend($"\r\n{results} result");
|
||||||
|
if (results != 1) _commands.SqlOutputAppend("s");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NonQuery(string sql)
|
||||||
|
{
|
||||||
|
using var context = DbContexts.GetContext();
|
||||||
|
var results = context.Database.ExecuteSqlRaw(sql);
|
||||||
|
|
||||||
|
_commands.SqlOutputAppend($"{results} record");
|
||||||
|
if (results != 1) _commands.SqlOutputAppend("s");
|
||||||
|
_commands.SqlOutputAppend(" affected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||||
|
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
|
<DebugType>embedded</DebugType>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using AppScaffolding;
|
using AppScaffolding;
|
||||||
|
|
||||||
namespace Hangover
|
namespace HangoverWinForms
|
||||||
{
|
{
|
||||||
public partial class Form1
|
public partial class Form1
|
||||||
{
|
{
|
||||||
@@ -11,7 +11,7 @@ namespace Hangover
|
|||||||
|
|
||||||
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (!databaseTab.Visible)
|
if (!cliTab.Visible)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using HangoverBase;
|
||||||
|
|
||||||
|
namespace HangoverWinForms
|
||||||
|
{
|
||||||
|
public partial class Form1
|
||||||
|
{
|
||||||
|
private DatabaseTab _tab;
|
||||||
|
|
||||||
|
private void Load_databaseTab()
|
||||||
|
{
|
||||||
|
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
|
||||||
|
|
||||||
|
_tab.LoadDatabaseFile();
|
||||||
|
if (_tab.DbFile is null)
|
||||||
|
{
|
||||||
|
databaseFileLbl.Text = $"Database file not found";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (!databaseTab.Visible)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 409 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user