mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-08-07 04:42:17 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e31847e669 | ||
|
|
1439e38cb0 | ||
|
|
4456432116 | ||
|
|
df2936e0b6 | ||
|
|
53b5c1b902 | ||
|
|
82fba7e752 | ||
|
|
1a95f2923b | ||
|
|
1939aae81c | ||
|
|
9a663fda15 | ||
|
|
84b2996102 | ||
|
|
af8e1cd5ef | ||
|
|
8a1b375f0d | ||
|
|
6800986f25 | ||
|
|
6110b08d16 | ||
|
|
666b5d83df | ||
|
|
7db5a34f1b | ||
|
|
e52772826a | ||
|
|
8ea9b2abc6 | ||
|
|
c10bb276f5 | ||
|
|
9dcb3b3a25 | ||
|
|
d857882220 | ||
|
|
d731db4036 | ||
|
|
ca5b40b176 | ||
|
|
b29ec26f63 | ||
|
|
7569b01bd0 | ||
|
|
6465b0a885 | ||
|
|
5e99cb6f02 | ||
|
|
d737cd2199 | ||
|
|
2d2907e076 | ||
|
|
05c454dce4 | ||
|
|
e64a9d2adf | ||
|
|
6252f015b3 | ||
|
|
7ada0082a9 | ||
|
|
826e53c9cb | ||
|
|
2248d7b24e | ||
|
|
69918c2587 | ||
|
|
1991bf5b4d | ||
|
|
756d387238 | ||
|
|
8d73f5cc7e | ||
|
|
4a65d6bbd3 | ||
|
|
10a1b56b3c | ||
|
|
66fb392b7f | ||
|
|
49ef96055c | ||
|
|
cb4a209f69 | ||
|
|
255e18eb5e | ||
|
|
7e1ec47b46 | ||
|
|
40c725b8c2 | ||
|
|
5d0937dc48 | ||
|
|
bff81bfc4b | ||
|
|
aa7c159985 | ||
|
|
012d94a146 | ||
|
|
22bd1ed121 | ||
|
|
c832f26b08 | ||
|
|
efd73d334e | ||
|
|
0db3ee6fd7 | ||
|
|
6aaf4f63d1 | ||
|
|
ab392a9285 | ||
|
|
efc9ff4bd8 | ||
|
|
a52b466c85 | ||
|
|
5611431abf | ||
|
|
a75932d1f4 | ||
|
|
6c8464b650 | ||
|
|
ba4a1c5a51 | ||
|
|
3681c0f18f | ||
|
|
e365ba7296 | ||
|
|
2afb5365dd | ||
|
|
00cf7693d5 | ||
|
|
dac6877a06 | ||
|
|
36005508a1 | ||
|
|
d9e27fd32e | ||
|
|
d86bcbb414 | ||
|
|
00cbab5b58 | ||
|
|
807725f6ff | ||
|
|
ec9356b36e | ||
|
|
add31024da | ||
|
|
27d2ada5a4 | ||
|
|
702219ee69 | ||
|
|
cdf1a01457 | ||
|
|
a71ccbac6e | ||
|
|
f8c6b836c3 | ||
|
|
090871f50d | ||
|
|
e62f01d2a3 | ||
|
|
68af6a5ebb | ||
|
|
8bba8538d5 | ||
|
|
2cd9b86930 | ||
|
|
b876d90964 | ||
|
|
49c91c273b | ||
|
|
c07bc88493 | ||
|
|
397a516dc1 | ||
|
|
1c2b51aa83 | ||
|
|
fc6f494f0d | ||
|
|
7289459170 | ||
|
|
ed6f741a65 | ||
|
|
1783da3e2d | ||
|
|
e7eac7bed3 | ||
|
|
9ae1f0399b | ||
|
|
784ab73a36 | ||
|
|
99687e968e | ||
|
|
565c84c4ab | ||
|
|
18cf20ecad | ||
|
|
2725340994 | ||
|
|
56de1e7659 | ||
|
|
fd16e97632 | ||
|
|
36076242a7 | ||
|
|
718e6c14d0 | ||
|
|
eb61ba3d69 | ||
|
|
defabf7356 | ||
|
|
1149c10cf1 | ||
|
|
ec7dd1b54a | ||
|
|
bb900b31ef | ||
|
|
eed42bd108 | ||
|
|
3f0e6b9ee5 | ||
|
|
5ec01913d5 | ||
|
|
245e55782e | ||
|
|
cc306e0e19 | ||
|
|
26a9bc6bbf | ||
|
|
fb9d062545 | ||
|
|
49c6b391fd | ||
|
|
e1cd8b8f94 | ||
|
|
ef1edf1136 | ||
|
|
0def1b426a | ||
|
|
230e014bb1 | ||
|
|
34f56d2fd7 | ||
|
|
c45ffaf4a6 | ||
|
|
ae43ab103e | ||
|
|
559977ce0b | ||
|
|
ccd4d3e26d | ||
|
|
e76f99ff28 | ||
|
|
d3607583ab | ||
|
|
3ebd4ce243 | ||
|
|
f6dcc0db1d | ||
|
|
bd49db83e4 | ||
|
|
4140722a6d | ||
|
|
da36f9414d | ||
|
|
1510f71ca6 | ||
|
|
cdb27ef712 | ||
|
|
790319ed98 | ||
|
|
1b0fb2b316 | ||
|
|
02371f2221 | ||
|
|
2b672f86be | ||
|
|
36176bff33 | ||
|
|
174b0c26b8 | ||
|
|
26c60e8e79 | ||
|
|
d94759d868 | ||
|
|
bd7e45ca3c | ||
|
|
52a863c62a | ||
|
|
fe55b90ee3 | ||
|
|
df224cc7f3 | ||
|
|
2a59329350 | ||
|
|
abdf0e7261 | ||
|
|
b9c2a1cce3 | ||
|
|
aa86fca08f | ||
|
|
cf9ec9facf | ||
|
|
f6084ef10c | ||
|
|
740b73beb7 | ||
|
|
5c45802391 | ||
|
|
429aa603f5 | ||
|
|
80ea394934 | ||
|
|
bce4437c79 | ||
|
|
b6ad1a289b | ||
|
|
2a22d05f37 | ||
|
|
d787843fd2 | ||
|
|
ded58f687d | ||
|
|
1f1f34b6ce | ||
|
|
ffadf90f4f | ||
|
|
67807efacf | ||
|
|
980f5afa54 | ||
|
|
b2f68760b2 | ||
|
|
faf86711a5 | ||
|
|
4a78b9d28f | ||
|
|
1b0a7f5062 | ||
|
|
49982043e0 | ||
|
|
378cf7057e | ||
|
|
abdc0f018e | ||
|
|
c65f61b92e | ||
|
|
c12805c8ce | ||
|
|
67f9a6db78 | ||
|
|
bb6336ce2a | ||
|
|
af7a4a6acf | ||
|
|
21d18aa565 | ||
|
|
c96875ba5d | ||
|
|
6ebbfb8e59 | ||
|
|
1e6e28cd57 | ||
|
|
defed72862 | ||
|
|
71503b34b5 | ||
|
|
a00849fb6f | ||
|
|
14b63c0883 | ||
|
|
59d556733e | ||
|
|
a99a175683 | ||
|
|
26fedcfb60 | ||
|
|
dde8024506 | ||
|
|
25f7c29380 | ||
|
|
2f347e83e8 | ||
|
|
080a74884d | ||
|
|
2dbeb64c38 | ||
|
|
bb508c0718 | ||
|
|
9a450b0d63 | ||
|
|
c1de0e60d2 | ||
|
|
dc7c03661d | ||
|
|
952eee6d32 | ||
|
|
472a0f30b9 | ||
|
|
73533c58a8 | ||
|
|
65ef018719 | ||
|
|
f0ca349539 | ||
|
|
500b287721 | ||
|
|
21f3ae45d3 | ||
|
|
d496564f0d | ||
|
|
6fdd6293ce | ||
|
|
3bca495521 | ||
|
|
0fb580f1a5 | ||
|
|
a7cd47e0b1 | ||
|
|
30aecedfae | ||
|
|
e72799efe5 | ||
|
|
ee8c0ae27b | ||
|
|
5b4a4341ad | ||
|
|
56823c1105 | ||
|
|
1f4ada604a | ||
|
|
3a4ab80892 | ||
|
|
bba9c2ba7b | ||
|
|
c4acd5d208 | ||
|
|
381440db4c | ||
|
|
00c8be1f7e | ||
|
|
d665122aa2 | ||
|
|
bb40df5fa3 | ||
|
|
e3c9f70dff | ||
|
|
b351033cec | ||
|
|
18f69bc73d | ||
|
|
39fe7b79d2 | ||
|
|
85769d797b | ||
|
|
9a80f18e1c | ||
|
|
aec8305e52 | ||
|
|
a672174a9b | ||
|
|
6f490b4491 | ||
|
|
5917d059e4 | ||
|
|
40602c7626 | ||
|
|
7d5ee2afa8 | ||
|
|
08b6f8fa11 | ||
|
|
5f9699aa3b | ||
|
|
70607aaaf4 | ||
|
|
1d96d39af7 | ||
|
|
5557772957 | ||
|
|
5c7db6cd23 | ||
|
|
c72b64d74c | ||
|
|
20474e0b3c | ||
|
|
867085600c | ||
|
|
74290ec609 | ||
|
|
5ee555e60c | ||
|
|
a36c28d48f | ||
|
|
0877f2c042 | ||
|
|
2baf5243ea | ||
|
|
b7e71f5812 | ||
|
|
2ed1076fab | ||
|
|
0b20aa751f | ||
|
|
05a4ece8d1 | ||
|
|
25b37c6266 | ||
|
|
b668cff0ac | ||
|
|
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 |
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "daily"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# build-linux.yml
|
||||||
|
# Reusable workflow that builds the Linux and MacOS (x64 and arm64) 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'
|
||||||
|
RELEASE_NAME: 'chardonnay'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest]
|
||||||
|
arch: [x64, arm64]
|
||||||
|
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 -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||||
|
fi
|
||||||
|
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||||
|
- name: Unit test
|
||||||
|
if: ${{ inputs.run_unit_tests }}
|
||||||
|
working-directory: ./Source
|
||||||
|
run: dotnet test
|
||||||
|
|
||||||
|
- name: Publish
|
||||||
|
id: publish
|
||||||
|
working-directory: ./Source
|
||||||
|
run: |
|
||||||
|
os=${{ matrix.os }}
|
||||||
|
target_os="$(echo ${os/-latest/} | sed 's/ubuntu/linux/')"
|
||||||
|
display_os="$(echo ${target_os/macos/macOS} | sed 's/linux/Linux/')"
|
||||||
|
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
|
||||||
|
RUNTIME_IDENTIFIER="$(echo ${target_os/macos/osx})-${{ matrix.arch }}"
|
||||||
|
echo "$RUNTIME_IDENTIFIER"
|
||||||
|
dotnet publish \
|
||||||
|
LibationAvalonia/LibationAvalonia.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
dotnet publish \
|
||||||
|
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
dotnet publish \
|
||||||
|
LibationCli/LibationCli.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
dotnet publish \
|
||||||
|
HangoverAvalonia/HangoverAvalonia.csproj \
|
||||||
|
--runtime "$RUNTIME_IDENTIFIER" \
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
|
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
||||||
|
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
- name: Build bundle
|
||||||
|
id: bundle
|
||||||
|
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }}
|
||||||
|
run: |
|
||||||
|
BUNDLE_DIR=$(pwd)
|
||||||
|
echo "Bundle dir: ${BUNDLE_DIR}"
|
||||||
|
cd ..
|
||||||
|
SCRIPT=../../../Scripts/Bundle_${{ steps.publish.outputs.display_os }}.sh
|
||||||
|
chmod +rx ${SCRIPT}
|
||||||
|
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ matrix.arch }}"
|
||||||
|
artifact=$(ls ./bundle)
|
||||||
|
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||||
|
- name: Publish bundle
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ steps.bundle.outputs.artifact }}
|
||||||
|
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# 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 `
|
||||||
|
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
|
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
dotnet publish `
|
||||||
|
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
|
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
dotnet publish `
|
||||||
|
LibationCli/LibationCli.csproj `
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
dotnet publish `
|
||||||
|
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||||
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
|
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
|
||||||
|
- name: Zip artifact
|
||||||
|
id: zip
|
||||||
|
working-directory: ./Source/bin/Publish
|
||||||
|
run: |
|
||||||
|
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
|
||||||
|
$delfiles = @(
|
||||||
|
"libmp3lame.x64.so",
|
||||||
|
"libmp3lame.arm64.so",
|
||||||
|
"libmp3lame.x64.dylib",
|
||||||
|
"libmp3lame.arm64.dylib",
|
||||||
|
"ffmpegaac.x64.so",
|
||||||
|
"ffmpegaac.arm64.so",
|
||||||
|
"ffmpegaac.x64.dylib",
|
||||||
|
"ffmpegaac.arm64.dylib",
|
||||||
|
"WindowsConfigApp.exe",
|
||||||
|
"WindowsConfigApp.runtimeconfig.json",
|
||||||
|
"WindowsConfigApp.deps.json"
|
||||||
|
)
|
||||||
|
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
||||||
|
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
|
||||||
|
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||||
|
Compress-Archive -Path "${bin_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,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@v4
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
||||||
|
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: [prerelease,build]
|
||||||
|
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 v${{ 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
|
||||||
+6
-4
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win-classic\\.zip",
|
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||||
"WindowsAvalonia":"Libation\\.\\d+\\.\\d+\\.\\d+-win-chardonnay\\.zip",
|
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
|
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
||||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
|
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
||||||
|
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
||||||
|
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
||||||
}
|
}
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": ".NET Core Launch (console)",
|
||||||
|
"type": "coreclr",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "build",
|
||||||
|
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"stopAtEntry": false,
|
||||||
|
"console": "internalConsole"
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+42
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
// for the documentation about the tasks.json format
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "build",
|
||||||
|
"dependsOn": [
|
||||||
|
"build_libation",
|
||||||
|
"build_linuxconfigapp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build_libation",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
//"reveal": "silent"
|
||||||
|
},
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "build_linuxconfigapp",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "dotnet",
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"${workspaceFolder}/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj"
|
||||||
|
],
|
||||||
|
"group": "build",
|
||||||
|
"presentation": {
|
||||||
|
//"reveal": "silent"
|
||||||
|
},
|
||||||
|
"problemMatcher": "$msCompile"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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"]
|
||||||
+13
-13
@@ -1,6 +1,6 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
@@ -8,9 +8,8 @@
|
|||||||
# 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](NamingTemplates.md)
|
||||||
- [Command Line Interface](#command-line-interface)
|
- [Command Line Interface](#command-line-interface)
|
||||||
|
|
||||||
|
|
||||||
@@ -25,20 +24,10 @@ 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.
|
||||||
|
|
||||||
### Custom File Naming
|
|
||||||
|
|
||||||
In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
|
|
||||||
|
|
||||||
These templates apply to GUI and CLI.
|
|
||||||
|
|
||||||
### Command Line Interface
|
### Command Line Interface
|
||||||
|
|
||||||
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
||||||
@@ -76,4 +65,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,39 @@
|
|||||||
|
## [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 [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
### Disclaimer
|
||||||
|
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
@@ -20,9 +20,21 @@
|
|||||||
|
|
||||||
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
||||||
|
|
||||||
|
##### Which version? Chardonnay vs Classic
|
||||||
|
|
||||||
|
Nearly 100% of the difference is look and feel -- it's a matter of preference.
|
||||||
|
|
||||||
|
Chardonnay has an updated look and will work and look the same on Windows, Mac, and Linux.
|
||||||
|
Classic is Windows only. It has an older look because it's built with older, duller, and more mature technology. This tech has built into it better support for things like accessibility for screen readers.
|
||||||
|
|
||||||
### Installation
|
### 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 [PayPal.me](https://paypal.me/MBucari?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,43 @@
|
|||||||
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?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 the file from the latest release and extract it.
|
||||||
|
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
|
||||||
|
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||||
|
- Move the extracted Libation app bundle to your applications folder.
|
||||||
|
- Open a terminal (Go > Utilities > Terminal)
|
||||||
|
- Copy/paste/run the following command (you'll be prompted to enter your password)
|
||||||
|
|
||||||
|
```Console
|
||||||
|
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
|
||||||
|
```
|
||||||
|
- Close the terminal and use Libation!
|
||||||
|
|
||||||
|
## Running Hangover
|
||||||
|
|
||||||
|
Libation comes with a recovery app called Hangover. You can start it by running this command:
|
||||||
|
```Console
|
||||||
|
open /Applications/Libation.app --args hangover
|
||||||
|
```
|
||||||
|
|
||||||
|
## Runnign LibationCli
|
||||||
|
|
||||||
|
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
|
||||||
|
```Console
|
||||||
|
open /Applications/Libation.app --args cli
|
||||||
|
```
|
||||||
|
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
|
||||||
|
|
||||||
|
Then use `./LibationCli` to execute a command.
|
||||||
|
|
||||||
|
## Get Libation running on Mac
|
||||||
|
|
||||||
|
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Naming Templates
|
||||||
|
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
|
||||||
|
|
||||||
|
These templates apply to both GUI and CLI.
|
||||||
|
|
||||||
|
# Table of Contents
|
||||||
|
|
||||||
|
- [Template Tags](#template-tags)
|
||||||
|
- [Property Tags](#property-tags)
|
||||||
|
- [Conditional Tags](#conditional-tags)
|
||||||
|
- [Tag Formatters](#tag-formatters)
|
||||||
|
- [Text Formatters](#text-formatters)
|
||||||
|
- [Name List Formatters](#name-list-formatters)
|
||||||
|
- [Integer Formatters](#integer-formatters)
|
||||||
|
- [Date Formatters](#date-formatters)
|
||||||
|
|
||||||
|
|
||||||
|
# Template Tags
|
||||||
|
|
||||||
|
These are the naming template tags currently supported by Libation.
|
||||||
|
|
||||||
|
## Property Tags
|
||||||
|
These tags will be replaced in the template with the audiobook's values.
|
||||||
|
|
||||||
|
|Tag|Description|Type|
|
||||||
|
|-|-|-|
|
||||||
|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||||
|
|\<title\>|Full title|Text|
|
||||||
|
|\<title short\>|Title. Stop at first colon|Text|
|
||||||
|
|\<author\>|Author(s)|Name List|
|
||||||
|
|\<first author\>|First author|Text|
|
||||||
|
|\<narrator\>|Narrator(s)|Name List|
|
||||||
|
|\<first narrator\>|First narrator|Text|
|
||||||
|
|\<series\>|Name of series|Text|
|
||||||
|
|\<series#\>|Number order in series|Text|
|
||||||
|
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|
||||||
|
|\<samplerate\>|File's original audio sample rate|Integer|
|
||||||
|
|\<channels\>|Number of audio channels|Integer|
|
||||||
|
|\<account\>|Audible account of this book|Text|
|
||||||
|
|\<locale\>|Region/country|Text|
|
||||||
|
|\<year\>|Year published|Integer|
|
||||||
|
|\<language\>|Book's language|Text|
|
||||||
|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||||
|
|\<file date\>|File creation date/time.|DateTime|
|
||||||
|
|\<pub date\>|Audiobook publication date|DateTime|
|
||||||
|
|\<date added\>|Date the book added to your Audible account|DateTime|
|
||||||
|
|\<ch count\> **‡**|Number of chapters|Integer|
|
||||||
|
|\<ch title\> **‡**|Chapter title|Text|
|
||||||
|
|\<ch#\> **‡**|Chapter number|Integer|
|
||||||
|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
|
||||||
|
|
||||||
|
**†** Does not support custom formatting
|
||||||
|
|
||||||
|
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
|
||||||
|
|
||||||
|
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
|
||||||
|
|
||||||
|
## Conditional Tags
|
||||||
|
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|
||||||
|
|
||||||
|
|Tag|Description|Type|
|
||||||
|
|-|-|-|
|
||||||
|
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|
||||||
|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||||
|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||||
|
|
||||||
|
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
||||||
|
|
||||||
|
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
|
||||||
|
|
||||||
|
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
||||||
|
|
||||||
|
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
|
||||||
|
|
||||||
|
|
||||||
|
# Tag Formatters
|
||||||
|
**Text**, **Name List**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
||||||
|
|
||||||
|
## Text Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
||||||
|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
||||||
|
|
||||||
|
## Name List Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||||
|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
||||||
|
|sort(F \| M \| L)|Sorts the names by first, middle, or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||||
|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||||
|
|
||||||
|
## Integer Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||||
|
|
||||||
|
## Date Formatters
|
||||||
|
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||||
|
### Standard DateTime Formatters
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|
||||||
|
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
|
||||||
|
|
||||||
|
### Custom DateTime Formatters
|
||||||
|
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||||
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
|
|-|-|-|-|
|
||||||
|
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|
||||||
|
|yy|2-digit year|\<file date[yy]\>|23|
|
||||||
|
|MM|2-digit month|\<file date[MM]\>|02|
|
||||||
|
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|
||||||
|
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,32 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512px" enable-background="new 0 0 512 512">
|
||||||
|
<path id="slosh" transform=
|
||||||
|
"translate(-50 23)
|
||||||
|
scale(0.7, 0.7)
|
||||||
|
rotate(12 256,256)"
|
||||||
|
d=
|
||||||
|
"M139,2
|
||||||
|
A 192,200 0 0 0 103,84
|
||||||
|
A 222,334 41 0 0 241,320
|
||||||
|
V478
|
||||||
|
H160
|
||||||
|
A 16,16 0 0 0 160,510
|
||||||
|
H352
|
||||||
|
A16 16 0 0 0 352,478
|
||||||
|
H271
|
||||||
|
V320
|
||||||
|
A 222,334 -41 0 0 409,84
|
||||||
|
A 192,200 0 0 0 373,2
|
||||||
|
M355,32
|
||||||
|
A 192,200 0 0 1 381,127
|
||||||
|
A 187.5,334 -35 0 1 256,286
|
||||||
|
A 187.5,334 35 0 1 131,127
|
||||||
|
A 192,200 0 0 1 157,32
|
||||||
|
H355
|
||||||
|
M146,147
|
||||||
|
A 168,300 35 0 0 256,270
|
||||||
|
A 168,300 -35 0 0 366,128
|
||||||
|
S 360,50 280,110
|
||||||
|
S 192,128 147,147
|
||||||
|
z" />
|
||||||
|
<use href="#slosh" transform="translate(512 0) scale(-1 1)" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 736 B |
@@ -0,0 +1,28 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
|
||||||
|
<path id="glass" d=
|
||||||
|
"M139,2
|
||||||
|
A 192,200 0 0 0 103,84
|
||||||
|
A 222,334 41 0 0 241,320
|
||||||
|
V478
|
||||||
|
H160
|
||||||
|
A 16,16 0 0 0 160,510
|
||||||
|
H352
|
||||||
|
A16 16 0 0 0 352,478
|
||||||
|
H271
|
||||||
|
V320
|
||||||
|
A 222,334 -41 0 0 409,84
|
||||||
|
A 192,200 0 0 0 373,2
|
||||||
|
M355,32
|
||||||
|
A 192,200 0 0 1 381,127
|
||||||
|
A 187.5,334 -35 0 1 256,286
|
||||||
|
A 187.5,334 35 0 1 131,127
|
||||||
|
A 192,200 0 0 1 157,32
|
||||||
|
H355
|
||||||
|
z" />
|
||||||
|
|
||||||
|
<path id="wine-level" d=
|
||||||
|
"M146,128
|
||||||
|
A 168,300 35 0 0 256,270
|
||||||
|
A 168,300 -35 0 0 366,128
|
||||||
|
z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
@@ -0,0 +1,30 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
|
||||||
|
|
||||||
|
<g transform="translate(0 80) rotate(90 256,256)">
|
||||||
|
<path id="glass" d=
|
||||||
|
"M139,2
|
||||||
|
A 192,200 0 0 0 103,84
|
||||||
|
A 222,334 41 0 0 241,320
|
||||||
|
V478
|
||||||
|
H160
|
||||||
|
A 16,16 0 0 0 160,510
|
||||||
|
H352
|
||||||
|
A16 16 0 0 0 352,478
|
||||||
|
H271
|
||||||
|
V320
|
||||||
|
A 222,334 -41 0 0 409,84
|
||||||
|
A 192,200 0 0 0 373,2
|
||||||
|
M355,32
|
||||||
|
A 192,200 0 0 1 381,127
|
||||||
|
A 187.5,334 -35 0 1 256,286
|
||||||
|
A 187.5,334 35 0 1 131,127
|
||||||
|
A 192,200 0 0 1 157,32
|
||||||
|
H355
|
||||||
|
z" />
|
||||||
|
<path id="wine-level" d=
|
||||||
|
"M345,44
|
||||||
|
A 192,184 0 0 1 366,126
|
||||||
|
A 320,180 55 0 1 345,226
|
||||||
|
z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 638 B |
@@ -0,0 +1,33 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
|
||||||
|
<path
|
||||||
|
transform=
|
||||||
|
"rotate(15 256,256)
|
||||||
|
translate(0 25)
|
||||||
|
scale(0.93, 0.93)"
|
||||||
|
d=
|
||||||
|
"M139,2
|
||||||
|
A 192,200 0 0 0 103,84
|
||||||
|
A 222,334 41 0 0 241,320
|
||||||
|
V478
|
||||||
|
H160
|
||||||
|
A 16,16 0 0 0 160,510
|
||||||
|
H352
|
||||||
|
A16 16 0 0 0 352,478
|
||||||
|
H271
|
||||||
|
V320
|
||||||
|
A 222,334 -41 0 0 409,84
|
||||||
|
A 192,200 0 0 0 373,2
|
||||||
|
M355,32
|
||||||
|
A 192,200 0 0 1 381,127
|
||||||
|
A 187.5,334 -35 0 1 256,286
|
||||||
|
A 187.5,334 35 0 1 131,127
|
||||||
|
A 192,200 0 0 1 157,32
|
||||||
|
H355
|
||||||
|
M146,147
|
||||||
|
A 168,300 35 0 0 256,270
|
||||||
|
A 168,300 -35 0 0 366,128
|
||||||
|
S 360,50 280,110
|
||||||
|
S 192,128 147,147
|
||||||
|
z" />
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 649 B |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||||
|
|
||||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
# Table of Contents
|
# Table of Contents
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
- [Getting started](Documentation/GettingStarted.md)
|
- [Getting started](Documentation/GettingStarted.md)
|
||||||
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
|
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
|
||||||
- [Installation](Documentation/GettingStarted.md#installation)
|
- [Installation](Documentation/GettingStarted.md#installation)
|
||||||
- [Installation on Ubuntu](Source/LibationAvalonia/README.md)
|
|
||||||
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
|
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
|
||||||
- [Import your library](Documentation/GettingStarted.md#import-your-library)
|
- [Import your library](Documentation/GettingStarted.md#import-your-library)
|
||||||
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
|
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
|
||||||
@@ -28,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/NamingTemplates.md)
|
||||||
- [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
|
||||||
|
|
||||||
@@ -55,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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
BIN_DIR=$1; shift
|
||||||
|
VERSION=$1; shift
|
||||||
|
ARCH=$1; shift
|
||||||
|
|
||||||
|
if [ -z "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with a the Libation Linux bins directory as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "The directory \"$BIN_DIR\" does not exist."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VERSION" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation version number as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$ARCH" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation cpu architecture as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
|
||||||
|
|
||||||
|
if ! contains "$BIN_DIR" "$ARCH"
|
||||||
|
then
|
||||||
|
echo "This script must be called with a Libation binaries for ${ARCH}."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
|
||||||
|
|
||||||
|
DEB_DIR=./deb
|
||||||
|
|
||||||
|
FOLDER_EXEC=$DEB_DIR/usr/lib/libation
|
||||||
|
echo "Exec dir: $FOLDER_EXEC"
|
||||||
|
mkdir -p $FOLDER_EXEC
|
||||||
|
|
||||||
|
echo "Moving bins from $BIN_DIR to $FOLDER_EXEC"
|
||||||
|
mv "${BIN_DIR}/"* $FOLDER_EXEC
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]
|
||||||
|
then echo "Error moving ${BIN_DIR} files"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||||
|
if [[ "$ARCH" == "arm64" ]]
|
||||||
|
then
|
||||||
|
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
||||||
|
else
|
||||||
|
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
||||||
|
fi
|
||||||
|
|
||||||
|
for n in "${delfiles[@]}"
|
||||||
|
do
|
||||||
|
echo "Deleting $n"
|
||||||
|
rm $FOLDER_EXEC/$n
|
||||||
|
done
|
||||||
|
|
||||||
|
FOLDER_ICON=$DEB_DIR/usr/share/icons/hicolor/scalable/apps/
|
||||||
|
echo "Icon dir: $FOLDER_ICON"
|
||||||
|
|
||||||
|
FOLDER_DESKTOP=$DEB_DIR/usr/share/applications
|
||||||
|
echo "Desktop dir: $FOLDER_DESKTOP"
|
||||||
|
|
||||||
|
FOLDER_DEBIAN=$DEB_DIR/DEBIAN
|
||||||
|
echo "Debian dir: $FOLDER_DEBIAN"
|
||||||
|
|
||||||
|
mkdir -p $FOLDER_ICON
|
||||||
|
mkdir -p $FOLDER_DESKTOP
|
||||||
|
mkdir -p $FOLDER_DEBIAN
|
||||||
|
|
||||||
|
echo "Copying icon..."
|
||||||
|
cp $FOLDER_EXEC/libation_glass.svg $FOLDER_ICON/libation.svg
|
||||||
|
|
||||||
|
echo "Copying desktop file..."
|
||||||
|
cp $FOLDER_EXEC/Libation.desktop $FOLDER_DESKTOP/Libation.desktop
|
||||||
|
|
||||||
|
echo "Creating pre-install file..."
|
||||||
|
echo "#!/bin/bash
|
||||||
|
# Pre-install script, removes previous installation program files and sym links
|
||||||
|
echo \"Removing previously created symlinks...\"
|
||||||
|
rm /usr/bin/libation
|
||||||
|
rm /usr/bin/hangover
|
||||||
|
rm /usr/bin/libationcli
|
||||||
|
echo \"Removing previously installed Libation files...\"
|
||||||
|
rm -r /usr/lib/libation
|
||||||
|
# making sure it won't stop installation
|
||||||
|
exit 0
|
||||||
|
" >> $FOLDER_DEBIAN/preinst
|
||||||
|
|
||||||
|
echo "Creating post-install file..."
|
||||||
|
echo "#!/bin/bash
|
||||||
|
gtk-update-icon-cache -f /usr/share/icons/hicolor/
|
||||||
|
ln -s /usr/lib/libation/Libation /usr/bin/libation
|
||||||
|
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
|
||||||
|
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
|
||||||
|
# Increase the maximum number of inotify instances
|
||||||
|
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
|
||||||
|
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
|
||||||
|
fi
|
||||||
|
# workaround until this file is moved to the user's home directory
|
||||||
|
touch /usr/lib/libation/appsettings.json
|
||||||
|
chmod 666 /usr/lib/libation/appsettings.json
|
||||||
|
" >> $FOLDER_DEBIAN/postinst
|
||||||
|
|
||||||
|
echo "Creating control file..."
|
||||||
|
echo "Package: Libation
|
||||||
|
Version: $VERSION
|
||||||
|
Architecture: $ARCH
|
||||||
|
Essential: no
|
||||||
|
Priority: optional
|
||||||
|
Maintainer: github.com/rmcrackan
|
||||||
|
Description: liberate your audiobooks
|
||||||
|
" >> $FOLDER_DEBIAN/control
|
||||||
|
|
||||||
|
echo "Changing permissions for pre- and post-install files..."
|
||||||
|
chmod +x "$FOLDER_DEBIAN/preinst"
|
||||||
|
chmod +x "$FOLDER_DEBIAN/postinst"
|
||||||
|
|
||||||
|
if [ "$(uname -s)" == "Darwin" ]; then
|
||||||
|
echo "macOS detected, installing dpkg"
|
||||||
|
brew install dpkg
|
||||||
|
fi
|
||||||
|
|
||||||
|
DEB_FILE=Libation.${VERSION}-linux-chardonnay-${ARCH}.deb
|
||||||
|
echo "Creating $DEB_FILE"
|
||||||
|
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
|
||||||
|
|
||||||
|
echo "moving to ./bundle/$DEB_FILE"
|
||||||
|
mkdir bundle
|
||||||
|
mv $DEB_FILE ./bundle/$DEB_FILE
|
||||||
|
|
||||||
|
rm -r "$BIN_DIR"
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
BIN_DIR=$1; shift
|
||||||
|
VERSION=$1; shift
|
||||||
|
ARCH=$1; shift
|
||||||
|
|
||||||
|
if [ -z "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with a the Libation macos bins directory as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$BIN_DIR" ]
|
||||||
|
then
|
||||||
|
echo "The directory \"$BIN_DIR\" does not exist."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z $VERSION ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation version number as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z $ARCH ]
|
||||||
|
then
|
||||||
|
echo "This script must be called with the Libation cpu architecture as an argument."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
|
||||||
|
|
||||||
|
if ! contains "$BIN_DIR" $ARCH
|
||||||
|
then
|
||||||
|
echo "This script must be called with a Libation binaries for ${ARCH}."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUNDLE=./Libation.app
|
||||||
|
echo "Bundle dir: $BUNDLE"
|
||||||
|
|
||||||
|
if [[ -d $BUNDLE ]]
|
||||||
|
then
|
||||||
|
echo "$BUNDLE directory already exists, aborting."
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUNDLE_CONTENTS=$BUNDLE/Contents
|
||||||
|
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
|
||||||
|
|
||||||
|
BUNDLE_RESOURCES=$BUNDLE_CONTENTS/Resources
|
||||||
|
echo "Resources dir: $BUNDLE_RESOURCES"
|
||||||
|
|
||||||
|
BUNDLE_MACOS=$BUNDLE_CONTENTS/MacOS
|
||||||
|
echo "MacOS dir: $BUNDLE_MACOS"
|
||||||
|
|
||||||
|
mkdir -p $BUNDLE_CONTENTS
|
||||||
|
mkdir -p $BUNDLE_RESOURCES
|
||||||
|
mkdir -p $BUNDLE_MACOS
|
||||||
|
|
||||||
|
mv "${BIN_DIR}/"* $BUNDLE_MACOS
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]
|
||||||
|
then echo "Error moving ${BIN_DIR} files"
|
||||||
|
exit
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Make fileicon executable..."
|
||||||
|
chmod +x $BUNDLE_MACOS/fileicon
|
||||||
|
|
||||||
|
echo "Moving icon..."
|
||||||
|
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||||
|
|
||||||
|
echo "Moving Info.plist file..."
|
||||||
|
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
|
||||||
|
|
||||||
|
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
|
||||||
|
echo "Set LSArchitecturePriority to $PLIST_ARCH"
|
||||||
|
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
|
||||||
|
|
||||||
|
echo "Set CFBundleVersion to $VERSION"
|
||||||
|
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
||||||
|
|
||||||
|
|
||||||
|
delfiles=( 'libmp3lame.arm64.so' 'libmp3lame.x64.so' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.so' 'ffmpegaac.x64.so' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||||
|
if [[ "$ARCH" == "arm64" ]]
|
||||||
|
then
|
||||||
|
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
|
||||||
|
else
|
||||||
|
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
for n in "${delfiles[@]}"
|
||||||
|
do
|
||||||
|
echo "Deleting $n"
|
||||||
|
rm $BUNDLE_MACOS/$n
|
||||||
|
done
|
||||||
|
|
||||||
|
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
|
||||||
|
|
||||||
|
echo "Signing executables in: $BUNDLE"
|
||||||
|
codesign --force --deep -s - $BUNDLE
|
||||||
|
|
||||||
|
echo "Creating app bundle: $APP_FILE"
|
||||||
|
tar -czvf $APP_FILE $BUNDLE
|
||||||
|
|
||||||
|
mkdir bundle
|
||||||
|
echo "moving to ./bundle/$APP_FILE"
|
||||||
|
mv $APP_FILE ./bundle/$APP_FILE
|
||||||
|
|
||||||
|
rm -r $BUNDLE
|
||||||
|
|
||||||
|
echo "Done!"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.12" />
|
<PackageReference Include="AAXClean.Codecs" Version="1.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AAXClean;
|
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||||
|
|
||||||
protected AaxFile AaxFile;
|
protected AaxFile AaxFile { get; private set; }
|
||||||
|
protected Mp4Operation AaxConversion { get; set; }
|
||||||
|
|
||||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||||
@@ -22,9 +22,17 @@ namespace AaxDecrypter
|
|||||||
AaxFile.AppleTags.Cover = coverArt;
|
AaxFile.AppleTags.Cover = coverArt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task CancelAsync()
|
||||||
|
{
|
||||||
|
IsCanceled = true;
|
||||||
|
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||||
|
FinalizeDownload();
|
||||||
|
}
|
||||||
|
|
||||||
protected bool Step_GetMetadata()
|
protected bool Step_GetMetadata()
|
||||||
{
|
{
|
||||||
AaxFile = new AaxFile(InputFileStream);
|
AaxFile = new AaxFile(InputFileStream);
|
||||||
|
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||||
|
|
||||||
if (DownloadOptions.StripUnabridged)
|
if (DownloadOptions.StripUnabridged)
|
||||||
{
|
{
|
||||||
@@ -32,6 +40,15 @@ namespace AaxDecrypter
|
|||||||
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (DownloadOptions.FixupFile)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
||||||
|
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||||
|
}
|
||||||
|
|
||||||
//Finishing configuring lame encoder.
|
//Finishing configuring lame encoder.
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||||
MpegUtil.ConfigureLameOptions(
|
MpegUtil.ConfigureLameOptions(
|
||||||
@@ -40,7 +57,6 @@ namespace AaxDecrypter
|
|||||||
DownloadOptions.Downsample,
|
DownloadOptions.Downsample,
|
||||||
DownloadOptions.MatchSourceBitrate);
|
DownloadOptions.MatchSourceBitrate);
|
||||||
|
|
||||||
|
|
||||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||||
@@ -50,58 +66,5 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected DownloadProgress Step_DownloadAudiobook_Start()
|
|
||||||
{
|
|
||||||
var zeroProgress = new DownloadProgress
|
|
||||||
{
|
|
||||||
BytesReceived = 0,
|
|
||||||
ProgressPercentage = 0,
|
|
||||||
TotalBytesToReceive = InputFileStream.Length
|
|
||||||
};
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(zeroProgress);
|
|
||||||
|
|
||||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
|
||||||
return zeroProgress;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
|
|
||||||
{
|
|
||||||
AaxFile.Close();
|
|
||||||
|
|
||||||
CloseInputFileStream();
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(zeroProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
|
||||||
{
|
|
||||||
var duration = AaxFile.Duration;
|
|
||||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
|
||||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
|
||||||
|
|
||||||
if (double.IsNormal(estTimeRemaining))
|
|
||||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
|
||||||
|
|
||||||
var progressPercent = (e.ProcessPosition / e.TotalDuration);
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(
|
|
||||||
new DownloadProgress
|
|
||||||
{
|
|
||||||
ProgressPercentage = 100 * progressPercent,
|
|
||||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
|
||||||
TotalBytesToReceive = InputFileStream.Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task CancelAsync()
|
|
||||||
{
|
|
||||||
IsCanceled = true;
|
|
||||||
if (AaxFile != null)
|
|
||||||
await AaxFile.CancelAsync();
|
|
||||||
AaxFile?.Dispose();
|
|
||||||
CloseInputFileStream();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,66 +1,24 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using AAXClean;
|
|
||||||
using AAXClean.Codecs;
|
using AAXClean.Codecs;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||||
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)
|
||||||
|
|
||||||
public override async Task<bool> RunAsync()
|
|
||||||
{
|
{
|
||||||
try
|
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
|
||||||
{
|
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
|
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
//Step 1
|
|
||||||
Serilog.Log.Information("Begin Get Aaxc Metadata");
|
|
||||||
if (await Task.Run(Step_GetMetadata))
|
|
||||||
Serilog.Log.Information("Completed Get Aaxc Metadata");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Get Aaxc Metadata");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 2
|
|
||||||
Serilog.Log.Information("Begin Download Decrypted Audiobook");
|
|
||||||
if (await Step_DownloadAudiobookAsMultipleFilesPerChapter())
|
|
||||||
Serilog.Log.Information("Completed Download Decrypted Audiobook");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Download Decrypted Audiobook");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 3
|
|
||||||
Serilog.Log.Information("Begin Cleanup");
|
|
||||||
if (await Task.Run(Step_Cleanup))
|
|
||||||
Serilog.Log.Information("Completed Cleanup");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Cleanup");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -87,10 +45,8 @@ The book will be split into the following files:
|
|||||||
|
|
||||||
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
|
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
|
||||||
*/
|
*/
|
||||||
private async Task<bool> Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
|
||||||
|
|
||||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||||
|
|
||||||
// Ensure split files are at least minChapterLength in duration.
|
// Ensure split files are at least minChapterLength in duration.
|
||||||
@@ -113,77 +69,77 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reset, just in case
|
try
|
||||||
multiPartFilePaths.Clear();
|
|
||||||
|
|
||||||
ConversionResult result;
|
|
||||||
|
|
||||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
|
||||||
result = await ConvertToMultiMp4a(splitChapters);
|
|
||||||
else
|
|
||||||
result = await ConvertToMultiMp3(splitChapters);
|
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
|
||||||
|
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
|
||||||
|
|
||||||
return result == ConversionResult.NoErrorsDetected;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
|
|
||||||
{
|
|
||||||
var chapterCount = 0;
|
|
||||||
return AaxFile.ConvertToMultiMp4aAsync
|
|
||||||
(
|
|
||||||
splitChapters,
|
|
||||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
|
|
||||||
{
|
|
||||||
var chapterCount = 0;
|
|
||||||
return AaxFile.ConvertToMultiMp3Async
|
|
||||||
(
|
|
||||||
splitChapters,
|
|
||||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
|
||||||
DownloadOptions.LameConfig,
|
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
|
|
||||||
=> Callback(currentChapter, splitChapters, newSplitCallback as NewSplitCallback);
|
|
||||||
|
|
||||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
|
||||||
{
|
|
||||||
MultiConvertFileProperties props = new()
|
|
||||||
{
|
{
|
||||||
OutputFileName = OutputFileName,
|
await (AaxConversion = decryptMultiAsync(splitChapters));
|
||||||
PartsPosition = currentChapter,
|
|
||||||
PartsTotal = splitChapters.Count,
|
if (AaxConversion.IsCompletedSuccessfully)
|
||||||
Title = newSplitCallback?.Chapter?.Title,
|
await moveMoovToBeginning(workingFileStream?.Name);
|
||||||
};
|
|
||||||
newSplitCallback.OutputFile = createOutputFileStream(props);
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
|
}
|
||||||
newSplitCallback.TrackNumber = currentChapter;
|
finally
|
||||||
newSplitCallback.TrackCount = splitChapters.Count;
|
{
|
||||||
|
workingFileStream?.Dispose();
|
||||||
|
FinalizeDownload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
|
||||||
{
|
{
|
||||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
var chapterCount = 0;
|
||||||
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters);
|
return
|
||||||
|
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||||
|
? AaxFile.ConvertToMultiMp4aAsync
|
||||||
|
(
|
||||||
|
splitChapters,
|
||||||
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
|
||||||
|
)
|
||||||
|
: AaxFile.ConvertToMultiMp3Async
|
||||||
|
(
|
||||||
|
splitChapters,
|
||||||
|
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||||
|
DownloadOptions.LameConfig
|
||||||
|
);
|
||||||
|
|
||||||
multiPartFilePaths.Add(fileName);
|
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||||
|
{
|
||||||
|
MultiConvertFileProperties props = new()
|
||||||
|
{
|
||||||
|
OutputFileName = OutputFileName,
|
||||||
|
PartsPosition = currentChapter,
|
||||||
|
PartsTotal = splitChapters.Count,
|
||||||
|
Title = newSplitCallback?.Chapter?.Title,
|
||||||
|
};
|
||||||
|
|
||||||
FileUtility.SaferDelete(fileName);
|
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
|
||||||
|
|
||||||
var file = File.Open(fileName, FileMode.OpenOrCreate);
|
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
|
||||||
OnFileCreated(fileName);
|
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
|
||||||
return file;
|
newSplitCallback.TrackNumber = currentChapter;
|
||||||
|
newSplitCallback.TrackCount = splitChapters.Count;
|
||||||
|
|
||||||
|
OnFileCreated(workingFileStream.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||||
|
{
|
||||||
|
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||||
|
FileUtility.SaferDelete(fileName);
|
||||||
|
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mp4Operation moveMoovToBeginning(string filename)
|
||||||
|
{
|
||||||
|
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||||
|
&& DownloadOptions.MoveMoovToBeginning
|
||||||
|
&& filename is not null
|
||||||
|
&& File.Exists(filename))
|
||||||
|
{
|
||||||
|
return Mp4File.RelocateMoovAsync(filename);
|
||||||
|
}
|
||||||
|
else return Mp4Operation.CompletedOperation;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,113 +1,92 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
|
using AAXClean.Codecs;
|
||||||
|
using Dinah.Core.Net.Http;
|
||||||
|
using FileManager;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AAXClean;
|
|
||||||
using AAXClean.Codecs;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||||
{
|
{
|
||||||
|
private readonly AverageSpeed averageSpeed = new();
|
||||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
: base(outFileName, cacheDirectory, dlOptions)
|
||||||
|
|
||||||
public override async Task<bool> RunAsync()
|
|
||||||
{
|
{
|
||||||
try
|
var step = 1;
|
||||||
{
|
|
||||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
|
|
||||||
//Step 1
|
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
|
||||||
Serilog.Log.Information("Begin Step 1: Get Aaxc Metadata");
|
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
|
||||||
if (await Task.Run(Step_GetMetadata))
|
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
Serilog.Log.Information("Completed Step 1: Get Aaxc Metadata");
|
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||||
else
|
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
|
||||||
{
|
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
Serilog.Log.Information("Failed to Complete Step 1: Get Aaxc Metadata");
|
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 2
|
|
||||||
Serilog.Log.Information("Begin Step 2: Download Decrypted Audiobook");
|
|
||||||
if (await Step_DownloadAudiobookAsSingleFile())
|
|
||||||
Serilog.Log.Information("Completed Step 2: Download Decrypted Audiobook");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 2: Download Decrypted Audiobook");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 3
|
|
||||||
Serilog.Log.Information("Begin Step 3: Create Cue");
|
|
||||||
if (await Task.Run(Step_CreateCue))
|
|
||||||
Serilog.Log.Information("Completed Step 3: Create Cue");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 3: Create Cue");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 4
|
|
||||||
Serilog.Log.Information("Begin Step 4: Cleanup");
|
|
||||||
if (await Task.Run(Step_Cleanup))
|
|
||||||
Serilog.Log.Information("Completed Step 4: Cleanup");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 4: Cleanup");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
|
||||||
|
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
FileUtility.SaferDelete(OutputFileName);
|
||||||
|
|
||||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
OnFileCreated(OutputFileName);
|
OnFileCreated(OutputFileName);
|
||||||
|
|
||||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
try
|
||||||
|
{
|
||||||
|
await (AaxConversion = decryptAsync(outputFile));
|
||||||
|
|
||||||
ConversionResult decryptionResult = await decryptAsync(outputFile);
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
|
}
|
||||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
finally
|
||||||
|
{
|
||||||
Step_DownloadAudiobook_End(zeroProgress);
|
FinalizeDownload();
|
||||||
|
}
|
||||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
|
||||||
if (success)
|
|
||||||
base.OnFileCreated(OutputFileName);
|
|
||||||
|
|
||||||
return success;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<ConversionResult> decryptAsync(Stream outputFile)
|
private async Task<bool> Step_MoveMoov()
|
||||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 ?
|
{
|
||||||
AaxFile.ConvertToMp3Async
|
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
|
||||||
|
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
|
||||||
|
await AaxConversion;
|
||||||
|
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
|
||||||
|
return AaxConversion.IsCompletedSuccessfully;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||||
|
{
|
||||||
|
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
||||||
|
|
||||||
|
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
|
||||||
|
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
|
||||||
|
|
||||||
|
if (double.IsNormal(estTimeRemaining))
|
||||||
|
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||||
|
|
||||||
|
OnDecryptProgressUpdate(
|
||||||
|
new DownloadProgress
|
||||||
|
{
|
||||||
|
ProgressPercentage = 100 * e.FractionCompleted,
|
||||||
|
BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted),
|
||||||
|
TotalBytesToReceive = InputFileStream.Length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mp4Operation decryptAsync(Stream outputFile)
|
||||||
|
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
||||||
|
? AaxFile.ConvertToMp3Async
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.LameConfig,
|
DownloadOptions.LameConfig,
|
||||||
DownloadOptions.ChapterInfo,
|
DownloadOptions.ChapterInfo
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
|
||||||
)
|
)
|
||||||
: DownloadOptions.FixupFile ?
|
: DownloadOptions.FixupFile
|
||||||
AaxFile.ConvertToMp4aAsync
|
? AaxFile.ConvertToMp4aAsync
|
||||||
(
|
(
|
||||||
outputFile,
|
outputFile,
|
||||||
DownloadOptions.ChapterInfo,
|
DownloadOptions.ChapterInfo
|
||||||
DownloadOptions.TrimOutputToChapterLength
|
)
|
||||||
)
|
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using System;
|
using Dinah.Core;
|
||||||
using System.Collections.Generic;
|
using Dinah.Core.Net.Http;
|
||||||
|
using Dinah.Core.StepRunner;
|
||||||
|
using FileManager;
|
||||||
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dinah.Core;
|
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -20,19 +20,18 @@ namespace AaxDecrypter
|
|||||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||||
public event EventHandler<string> FileCreated;
|
public event EventHandler<string> FileCreated;
|
||||||
|
|
||||||
public bool IsCanceled { get; set; }
|
public bool IsCanceled { get; protected set; }
|
||||||
public string TempFilePath { get; }
|
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||||
|
protected string OutputFileName { get; }
|
||||||
protected string OutputFileName { get; private set; }
|
|
||||||
protected IDownloadOptions DownloadOptions { get; }
|
protected IDownloadOptions DownloadOptions { get; }
|
||||||
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
|
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||||
|
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||||
|
private bool downloadFinished;
|
||||||
|
|
||||||
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
|
private readonly NetworkFileStreamPersister nfsPersister;
|
||||||
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
|
private readonly DownloadProgress zeroProgress;
|
||||||
|
private readonly string jsonDownloadState;
|
||||||
private NetworkFileStreamPersister nfsPersister;
|
private readonly string tempFilePath;
|
||||||
|
|
||||||
private string jsonDownloadState { get; }
|
|
||||||
|
|
||||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
{
|
{
|
||||||
@@ -46,15 +45,78 @@ namespace AaxDecrypter
|
|||||||
Directory.CreateDirectory(cacheDirectory);
|
Directory.CreateDirectory(cacheDirectory);
|
||||||
|
|
||||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
||||||
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||||
|
|
||||||
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
|
DownloadOptions = 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);
|
||||||
}
|
|
||||||
|
nfsPersister = OpenNetworkFileStream();
|
||||||
|
|
||||||
|
zeroProgress = new DownloadProgress
|
||||||
|
{
|
||||||
|
BytesReceived = 0,
|
||||||
|
ProgressPercentage = 0,
|
||||||
|
TotalBytesToReceive = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
OnDecryptProgressUpdate(zeroProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RunAsync()
|
||||||
|
{
|
||||||
|
await InputFileStream.BeginDownloadingAsync();
|
||||||
|
var progressTask = Task.Run(reportProgress);
|
||||||
|
|
||||||
|
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
||||||
|
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||||
|
|
||||||
|
await progressTask;
|
||||||
|
|
||||||
|
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||||
|
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||||
|
|
||||||
|
return success;
|
||||||
|
|
||||||
|
async Task reportProgress()
|
||||||
|
{
|
||||||
|
AverageSpeed averageSpeed = new();
|
||||||
|
|
||||||
|
while (
|
||||||
|
InputFileStream.CanRead
|
||||||
|
&& InputFileStream.Length > InputFilePosition
|
||||||
|
&& !InputFileStream.IsCancelled
|
||||||
|
&& !downloadFinished)
|
||||||
|
{
|
||||||
|
averageSpeed.AddPosition(InputFilePosition);
|
||||||
|
|
||||||
|
var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average;
|
||||||
|
|
||||||
|
if (double.IsNormal(estSecsRemaining))
|
||||||
|
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
|
||||||
|
|
||||||
|
var progressPercent = 100d * InputFilePosition / InputFileStream.Length;
|
||||||
|
|
||||||
|
OnDecryptProgressUpdate(
|
||||||
|
new DownloadProgress
|
||||||
|
{
|
||||||
|
ProgressPercentage = progressPercent,
|
||||||
|
BytesReceived = InputFilePosition,
|
||||||
|
TotalBytesToReceive = InputFileStream.Length
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.Delay(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnDecryptTimeRemaining(TimeSpan.Zero);
|
||||||
|
OnDecryptProgressUpdate(zeroProgress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public abstract Task CancelAsync();
|
public abstract Task CancelAsync();
|
||||||
|
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||||
|
|
||||||
public virtual void SetCoverArt(byte[] coverArt)
|
public virtual void SetCoverArt(byte[] coverArt)
|
||||||
{
|
{
|
||||||
@@ -62,8 +124,6 @@ namespace AaxDecrypter
|
|||||||
OnRetrievedCoverArt(coverArt);
|
OnRetrievedCoverArt(coverArt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task<bool> RunAsync();
|
|
||||||
|
|
||||||
protected void OnRetrievedTitle(string title)
|
protected void OnRetrievedTitle(string title)
|
||||||
=> RetrievedTitle?.Invoke(this, title);
|
=> RetrievedTitle?.Invoke(this, title);
|
||||||
protected void OnRetrievedAuthors(string authors)
|
protected void OnRetrievedAuthors(string authors)
|
||||||
@@ -72,97 +132,107 @@ 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);
|
||||||
protected void OnFileCreated(string path)
|
protected void OnFileCreated(string path)
|
||||||
=> FileCreated?.Invoke(this, path);
|
=> FileCreated?.Invoke(this, path);
|
||||||
|
|
||||||
protected void CloseInputFileStream()
|
protected virtual void FinalizeDownload()
|
||||||
{
|
{
|
||||||
nfsPersister?.NetworkFileStream?.Close();
|
|
||||||
nfsPersister?.Dispose();
|
nfsPersister?.Dispose();
|
||||||
|
downloadFinished = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_CreateCue()
|
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
|
||||||
{
|
{
|
||||||
if (!DownloadOptions.CreateCueSheet) return true;
|
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
|
||||||
|
{
|
||||||
|
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
|
||||||
|
|
||||||
|
if (File.Exists(recordsFile))
|
||||||
|
OnFileCreated(recordsFile);
|
||||||
|
}
|
||||||
|
return !IsCanceled;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<bool> Step_CreateCueAsync()
|
||||||
|
{
|
||||||
|
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
|
||||||
|
|
||||||
// not a critical step. its failure should not prevent future steps from running
|
// not a critical step. its failure should not prevent future steps from running
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||||
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
|
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
|
||||||
OnFileCreated(path);
|
OnFileCreated(path);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
|
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
|
||||||
}
|
}
|
||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_Cleanup()
|
private async Task<bool> CleanupAsync()
|
||||||
{
|
{
|
||||||
bool success = !IsCanceled;
|
if (IsCanceled) return false;
|
||||||
if (success)
|
|
||||||
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||||
|
!string.IsNullOrEmpty(DownloadOptions.AudibleIV) &&
|
||||||
|
DownloadOptions.RetainEncryptedFile)
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||||
|
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||||
|
|
||||||
if (DownloadOptions.AudibleKey is not null &&
|
//Write aax decryption key
|
||||||
DownloadOptions.AudibleIV is not null &&
|
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||||
DownloadOptions.RetainEncryptedFile)
|
FileUtility.SaferDelete(keyPath);
|
||||||
{
|
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||||
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
|
|
||||||
FileUtility.SaferMove(TempFilePath, aaxPath);
|
|
||||||
|
|
||||||
//Write aax decryption key
|
OnFileCreated(aaxPath);
|
||||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
OnFileCreated(keyPath);
|
||||||
FileUtility.SaferDelete(keyPath);
|
|
||||||
File.WriteAllText(keyPath, $"Key={DownloadOptions.AudibleKey}\r\nIV={DownloadOptions.AudibleIV}");
|
|
||||||
|
|
||||||
OnFileCreated(aaxPath);
|
|
||||||
OnFileCreated(keyPath);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
FileUtility.SaferDelete(TempFilePath);
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
FileUtility.SaferDelete(tempFilePath);
|
||||||
|
|
||||||
return success;
|
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))
|
||||||
// If More than ~1 hour has elapsed since getting the download url, it will expire.
|
return nfsp = newNetworkFilePersister();
|
||||||
// The new url will be to the same file.
|
|
||||||
|
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
||||||
|
// The download url expires after 1 hour.
|
||||||
|
// The new url points to the same file.
|
||||||
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
||||||
return nfsp;
|
return nfsp;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
FileUtility.SaferDelete(TempFilePath);
|
FileUtility.SaferDelete(tempFilePath);
|
||||||
return NewNetworkFilePersister();
|
return nfsp = newNetworkFilePersister();
|
||||||
}
|
}
|
||||||
}
|
finally
|
||||||
|
|
||||||
private NetworkFileStreamPersister NewNetworkFilePersister()
|
|
||||||
{
|
|
||||||
var headers = new System.Net.WebHeaderCollection
|
|
||||||
{
|
{
|
||||||
{ "User-Agent", DownloadOptions.UserAgent }
|
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||||
};
|
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||||
|
}
|
||||||
|
|
||||||
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, headers);
|
NetworkFileStreamPersister newNetworkFilePersister()
|
||||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
{
|
||||||
|
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
|
||||||
|
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
using Dinah.Core;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AaxDecrypter;
|
||||||
|
|
||||||
|
public static class LinqStats
|
||||||
|
{
|
||||||
|
public static (double mean, double stdDev) BasicStatisticsBy<T>(this IEnumerable<T> values, Func<T, double> selector)
|
||||||
|
{
|
||||||
|
var count = values.Count();
|
||||||
|
var mean = values.Average(selector);
|
||||||
|
|
||||||
|
return (mean, Math.Sqrt(values.Sum(s => Math.Pow(selector(s) - mean, 2)) / (count - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool T_Test_2By<T>(this IEnumerable<T> values, Func<T, double> selector, IEnumerable<T> secondGroup, Significance confidence)
|
||||||
|
{
|
||||||
|
var n1 = values.Count();
|
||||||
|
var n2 = secondGroup.Count();
|
||||||
|
var n = n1 + n2;
|
||||||
|
|
||||||
|
if (n1 < 3 || n2 < 3) return false;
|
||||||
|
|
||||||
|
(var mean1, var stdDev1) = values.BasicStatisticsBy(selector);
|
||||||
|
(var mean2, var stdDev2) = secondGroup.BasicStatisticsBy(selector);
|
||||||
|
|
||||||
|
var pooledStdDev = Math.Sqrt((((n1 - 1) * (stdDev1 * stdDev1)) + ((n2 - 1) * (stdDev2 * stdDev2))) / (n1 + n2 - 2));
|
||||||
|
|
||||||
|
var testStat = Math.Abs(mean1 - mean2) / (pooledStdDev * Math.Sqrt(1d / n1 + 1d / n2));
|
||||||
|
var crit = T_Stat(Math.Min(n - 2, MAX_DEGREES_FREEDOM), confidence);
|
||||||
|
|
||||||
|
return testStat > crit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool T_Test_1By<T>(this IEnumerable<T> values, Func<T, double> selector, double testMean, Significance confidence)
|
||||||
|
{
|
||||||
|
var n = values.Count();
|
||||||
|
|
||||||
|
if (n < 2) return false;
|
||||||
|
|
||||||
|
(var sampleMean, var sampleStdDev) = values.BasicStatisticsBy(selector);
|
||||||
|
|
||||||
|
var testStat = Math.Abs(sampleMean - testMean) / (sampleStdDev / Math.Sqrt(n));
|
||||||
|
var crit = T_Stat(Math.Min(n - 1, MAX_DEGREES_FREEDOM), confidence);
|
||||||
|
|
||||||
|
return testStat > crit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double T_Stat(int degreesFreedom, Significance confidence)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureBetweenInclusive(degreesFreedom, nameof(degreesFreedom), MIN_DEGREES_FREEDOM, MAX_DEGREES_FREEDOM);
|
||||||
|
|
||||||
|
return T_TABLE[(int)confidence][degreesFreedom - MIN_DEGREES_FREEDOM];
|
||||||
|
}
|
||||||
|
|
||||||
|
static LinqStats()
|
||||||
|
{
|
||||||
|
T_TABLE = new double[][] { T_Table_01, T_Table_05, T_Table_10, T_Table_15, T_Table_20, T_Table_25 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private const int MIN_DEGREES_FREEDOM = 1;
|
||||||
|
private const int MAX_DEGREES_FREEDOM = 201;
|
||||||
|
/// <summary>
|
||||||
|
/// 2-tailed t-Distribution critical values at 75%, 80%, 85%,
|
||||||
|
/// 90%, 95%, and 99% confidence for 1 - 201 degrees of freedom.
|
||||||
|
/// </summary>
|
||||||
|
private readonly static double[][] T_TABLE;
|
||||||
|
private readonly static double[] T_Table_25 = { 2.414213562, 1.603567451, 1.422625281, 1.344397556, 1.300949037, 1.273349309, 1.254278682, 1.240318261, 1.229659173, 1.221255395, 1.214460246, 1.208852542, 1.204146242, 1.200140298, 1.196689284, 1.193685414, 1.191047107, 1.188711483, 1.186629298, 1.184761434, 1.183076432, 1.181548697, 1.180157199, 1.178884497, 1.177716003, 1.176639425, 1.175644329, 1.174721803, 1.173864189, 1.173064871, 1.1723181, 1.17161886, 1.170962753, 1.17034591, 1.169764906, 1.169216709, 1.168698615, 1.168208212, 1.167743338, 1.167302049, 1.166882595, 1.166483396, 1.166103019, 1.165740162, 1.165393644, 1.165062385, 1.164745398, 1.164441782, 1.164150707, 1.163871412, 1.163603196, 1.163345413, 1.163097467, 1.162858803, 1.162628911, 1.162407316, 1.162193577, 1.161987283, 1.161788052, 1.161595527, 1.161409375, 1.161229286, 1.161054967, 1.160886145, 1.160722566, 1.160563987, 1.160410184, 1.160260944, 1.160116066, 1.159975363, 1.159838656, 1.159705777, 1.159576569, 1.15945088, 1.15932857, 1.159209503, 1.159093552, 1.158980598, 1.158870524, 1.158763222, 1.158658589, 1.158556526, 1.15845694, 1.158359742, 1.158264847, 1.158172173, 1.158081645, 1.157993188, 1.157906731, 1.157822209, 1.157739556, 1.157658712, 1.157579617, 1.157502216, 1.157426454, 1.157352281, 1.157279646, 1.157208502, 1.157138804, 1.157070509, 1.157003573, 1.156937958, 1.156873624, 1.156810534, 1.156748653, 1.156687945, 1.156628379, 1.156569922, 1.156512543, 1.156456213, 1.156400904, 1.156346587, 1.156293237, 1.156240827, 1.156189334, 1.156138733, 1.156089001, 1.156040117, 1.155992058, 1.155944804, 1.155898335, 1.155852631, 1.155807674, 1.155763446, 1.155719928, 1.155677105, 1.155634959, 1.155593475, 1.155552637, 1.15551243, 1.155472839, 1.155433851, 1.155395452, 1.155357629, 1.155320368, 1.155283658, 1.155247486, 1.155211841, 1.15517671, 1.155142084, 1.15510795, 1.1550743, 1.155041122, 1.155008406, 1.154976144, 1.154944326, 1.154912942, 1.154881984, 1.154851443, 1.154821311, 1.15479158, 1.154762241, 1.154733287, 1.154704711, 1.154676505, 1.154648662, 1.154621175, 1.154594037, 1.154567242, 1.154540783, 1.154514654, 1.154488849, 1.154463361, 1.154438185, 1.154413316, 1.154388747, 1.154364474, 1.15434049, 1.154316792, 1.154293373, 1.154270229, 1.154247355, 1.154224746, 1.154202398, 1.154180307, 1.154158467, 1.154136875, 1.154115526, 1.154094417, 1.154073543, 1.1540529, 1.154032485, 1.154012294, 1.153992323, 1.153972568, 1.153953027, 1.153933695, 1.15391457, 1.153895647, 1.153876925, 1.153858399, 1.153840066, 1.153821925, 1.15380397, 1.153786201, 1.153768613, 1.153751204, 1.153733972, 1.153716914, 1.153700026 };
|
||||||
|
private readonly static double[] T_Table_20 = { 3.077683537, 1.885618083, 1.637744354, 1.533206274, 1.475884049, 1.439755747, 1.414923928, 1.39681531, 1.383028738, 1.372183641, 1.363430318, 1.356217334, 1.350171289, 1.345030374, 1.340605608, 1.336757167, 1.33337939, 1.330390944, 1.327728209, 1.325340707, 1.323187874, 1.321236742, 1.31946024, 1.317835934, 1.316345073, 1.314971864, 1.313702913, 1.312526782, 1.311433647, 1.310415025, 1.309463549, 1.308572793, 1.307737124, 1.306951587, 1.306211802, 1.305513886, 1.304854381, 1.304230204, 1.303638589, 1.303077053, 1.302543359, 1.302035487, 1.301551608, 1.30109006, 1.300649332, 1.300228048, 1.299824947, 1.299438879, 1.299068785, 1.298713694, 1.298372713, 1.298045016, 1.297729843, 1.297426488, 1.2971343, 1.296852673, 1.296581044, 1.29631889, 1.296065725, 1.295821094, 1.295584571, 1.295355762, 1.295134294, 1.29491982, 1.294712013, 1.294510568, 1.294315197, 1.294125629, 1.293941609, 1.293762898, 1.293589269, 1.293420507, 1.293256413, 1.293096793, 1.292941469, 1.292790268, 1.292643029, 1.292499597, 1.292359828, 1.292223583, 1.29209073, 1.291961144, 1.291834705, 1.291711301, 1.291590824, 1.291473171, 1.291358243, 1.291245948, 1.291136195, 1.291028899, 1.290923979, 1.290821356, 1.290720956, 1.290622708, 1.290526543, 1.290432395, 1.290340202, 1.290249904, 1.290161442, 1.290074761, 1.289989809, 1.289906533, 1.289824884, 1.289744816, 1.289666283, 1.289589241, 1.289513648, 1.289439464, 1.289366649, 1.289295166, 1.289224979, 1.289156054, 1.289088355, 1.289021851, 1.28895651, 1.288892302, 1.288829199, 1.288767171, 1.288706191, 1.288646234, 1.288587273, 1.288529284, 1.288472243, 1.288416127, 1.288360913, 1.288306581, 1.288253109, 1.288200477, 1.288148665, 1.288097654, 1.288047427, 1.287997964, 1.287949248, 1.287901264, 1.287853994, 1.287807422, 1.287761534, 1.287716314, 1.287671748, 1.287627821, 1.287584521, 1.287541833, 1.287499745, 1.287458245, 1.287417319, 1.287376957, 1.287337146, 1.287297876, 1.287259135, 1.287220914, 1.2871832, 1.287145985, 1.287109259, 1.287073012, 1.287037235, 1.287001918, 1.286967053, 1.286932631, 1.286898644, 1.286865084, 1.286831942, 1.286799212, 1.286766884, 1.286734952, 1.286703409, 1.286672248, 1.286641461, 1.286611042, 1.286580985, 1.286551283, 1.286521929, 1.286492918, 1.286464244, 1.286435901, 1.286407882, 1.286380184, 1.286352799, 1.286325724, 1.286298952, 1.286272479, 1.286246299, 1.286220408, 1.286194801, 1.286169474, 1.286144421, 1.286119638, 1.286095122, 1.286070867, 1.28604687, 1.286023127, 1.285999633, 1.285976384, 1.285953377, 1.285930609, 1.285908074, 1.285885771, 1.285863694, 1.285841842, 1.285820209, 1.285798794 };
|
||||||
|
private readonly static double[] T_Table_15 = { 4.16529977, 2.281930588, 1.924319657, 1.778192164, 1.699362566, 1.650173154, 1.616591737, 1.59222144, 1.573735785, 1.559235933, 1.547559766, 1.537956495, 1.529919606, 1.523095061, 1.517227969, 1.51213017, 1.507659754, 1.503707672, 1.500188756, 1.497035518, 1.494193795, 1.491619612, 1.489276897, 1.487135783, 1.485171326, 1.483362535, 1.481691617, 1.48014339, 1.478704821, 1.477364662, 1.47611315, 1.474941772, 1.473843072, 1.47281049, 1.471838233, 1.470921166, 1.470054719, 1.469234815, 1.468457801, 1.467720399, 1.467019655, 1.466352901, 1.465717725, 1.465111933, 1.464533534, 1.463980712, 1.463451805, 1.462945295, 1.46245979, 1.461994009, 1.461546775, 1.461117, 1.460703683, 1.460305896, 1.45992278, 1.459553538, 1.45919743, 1.458853767, 1.458521908, 1.458201256, 1.457891251, 1.457591373, 1.457301133, 1.457020074, 1.456747768, 1.45648381, 1.456227824, 1.455979454, 1.455738365, 1.455504241, 1.455276784, 1.455055715, 1.454840767, 1.45463169, 1.454428246, 1.454230212, 1.454037373, 1.453849529, 1.453666487, 1.453488066, 1.453314093, 1.453144404, 1.452978842, 1.452817259, 1.452659513, 1.452505469, 1.452354998, 1.452207977, 1.452064289, 1.451923821, 1.451786468, 1.451652126, 1.451520697, 1.451392088, 1.451266209, 1.451142973, 1.451022299, 1.450904108, 1.450788323, 1.450674871, 1.450563684, 1.450454694, 1.450347836, 1.450243048, 1.450140271, 1.450039448, 1.449940523, 1.449843444, 1.449748158, 1.449654617, 1.449562773, 1.449472581, 1.449383997, 1.449296977, 1.449211481, 1.449127468, 1.449044902, 1.448963744, 1.448883959, 1.448805513, 1.448728372, 1.448652503, 1.448577876, 1.44850446, 1.448432226, 1.448361146, 1.448291192, 1.448222337, 1.448154557, 1.448087826, 1.44802212, 1.447957415, 1.447893688, 1.447830919, 1.447769085, 1.447708165, 1.44764814, 1.44758899, 1.447530695, 1.447473238, 1.447416601, 1.447360765, 1.447305715, 1.447251433, 1.447197905, 1.447145113, 1.447093044, 1.447041682, 1.446991013, 1.446941023, 1.446891698, 1.446843026, 1.446794994, 1.446747588, 1.446700797, 1.446654609, 1.446609012, 1.446563996, 1.446519548, 1.446475659, 1.446432318, 1.446389514, 1.446347238, 1.44630548, 1.44626423, 1.44622348, 1.44618322, 1.446143442, 1.446104137, 1.446065296, 1.446026911, 1.445988975, 1.44595148, 1.445914417, 1.44587778, 1.445841561, 1.445805753, 1.445770349, 1.445735343, 1.445700727, 1.445666495, 1.445632641, 1.445599159, 1.445566042, 1.445533284, 1.445500881, 1.445468825, 1.445437112, 1.445405736, 1.445374691, 1.445343973, 1.445313576, 1.445283495, 1.445253726, 1.445224264, 1.445195103, 1.445166239, 1.445137668, 1.445109385, 1.445081387 };
|
||||||
|
private readonly static double[] T_Table_10 = { 6.313751515, 2.91998558, 2.353363435, 2.131846786, 2.015048373, 1.943180281, 1.894578605, 1.859548038, 1.833112933, 1.812461123, 1.795884819, 1.782287556, 1.770933396, 1.761310136, 1.753050356, 1.745883676, 1.739606726, 1.734063607, 1.729132812, 1.724718243, 1.720742903, 1.717144374, 1.713871528, 1.71088208, 1.708140761, 1.70561792, 1.703288446, 1.701130934, 1.699127027, 1.697260887, 1.695518783, 1.693888748, 1.692360309, 1.690924255, 1.689572458, 1.688297714, 1.68709362, 1.68595446, 1.684875122, 1.683851013, 1.682878002, 1.681952357, 1.681070703, 1.680229977, 1.679427393, 1.678660414, 1.677926722, 1.677224196, 1.676550893, 1.675905025, 1.67528495, 1.674689154, 1.674116237, 1.673564906, 1.673033965, 1.672522303, 1.672028888, 1.671552762, 1.671093032, 1.670648865, 1.670219484, 1.669804163, 1.669402222, 1.669013025, 1.668635976, 1.668270514, 1.667916114, 1.667572281, 1.667238549, 1.666914479, 1.666599658, 1.666293696, 1.665996224, 1.665706893, 1.665425373, 1.665151353, 1.664884537, 1.664624645, 1.664371409, 1.664124579, 1.663883913, 1.663649184, 1.663420175, 1.663196679, 1.6629785, 1.662765449, 1.662557349, 1.662354029, 1.662155326, 1.661961084, 1.661771155, 1.661585397, 1.661403674, 1.661225855, 1.661051817, 1.66088144, 1.66071461, 1.660551217, 1.660391156, 1.660234326, 1.66008063, 1.659929976, 1.659782273, 1.659637437, 1.659495383, 1.659356034, 1.659219312, 1.659085144, 1.658953458, 1.658824187, 1.658697265, 1.658572629, 1.658450216, 1.658329969, 1.65821183, 1.658095744, 1.657981659, 1.657869522, 1.657759285, 1.657650899, 1.657544319, 1.657439499, 1.657336397, 1.65723497, 1.657135178, 1.657036982, 1.656940344, 1.656845226, 1.656751594, 1.656659413, 1.656568649, 1.65647927, 1.656391244, 1.656304542, 1.656219133, 1.656134988, 1.65605208, 1.655970382, 1.655889868, 1.655810511, 1.655732287, 1.655655173, 1.655579143, 1.655504177, 1.655430251, 1.655357345, 1.655285437, 1.655214506, 1.655144534, 1.6550755, 1.655007387, 1.654940175, 1.654873847, 1.654808385, 1.654743774, 1.654679996, 1.654617035, 1.654554875, 1.654493503, 1.654432901, 1.654373057, 1.654313957, 1.654255585, 1.654197929, 1.654140976, 1.654084713, 1.654029128, 1.653974208, 1.653919942, 1.653866317, 1.653813324, 1.653760949, 1.653709184, 1.653658017, 1.653607437, 1.653557435, 1.653508002, 1.653459126, 1.6534108, 1.653363013, 1.653315758, 1.653269024, 1.653222803, 1.653177088, 1.653131869, 1.653087138, 1.653042889, 1.652999113, 1.652955802, 1.652912949, 1.652870547, 1.652828589, 1.652787068, 1.652745977, 1.65270531, 1.652665059, 1.652625219, 1.652585784, 1.652546746, 1.652508101 };
|
||||||
|
private readonly static double[] T_Table_05 = { 12.70620474, 4.30265273, 3.182446305, 2.776445105, 2.570581836, 2.446911851, 2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.20098516, 2.17881283, 2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.10092204, 2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.06865761, 2.063898562, 2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456, 2.039513446, 2.036933343, 2.034515297, 2.032244509, 2.030107928, 2.028094001, 2.026192463, 2.024394164, 2.02269092, 2.02107539, 2.01954097, 2.018081703, 2.016692199, 2.015367574, 2.014103389, 2.012895599, 2.011740514, 2.010634758, 2.009575237, 2.008559112, 2.00758377, 2.006646805, 2.005745995, 2.004879288, 2.004044783, 2.003240719, 2.002465459, 2.001717484, 2.000995378, 2.000297822, 1.999623585, 1.998971517, 1.998340543, 1.997729654, 1.997137908, 1.996564419, 1.996008354, 1.995468931, 1.994945415, 1.994437112, 1.993943368, 1.993463567, 1.992997126, 1.992543495, 1.992102154, 1.99167261, 1.991254395, 1.990847069, 1.99045021, 1.990063421, 1.989686323, 1.989318557, 1.98895978, 1.988609667, 1.988267907, 1.987934206, 1.987608282, 1.987289865, 1.9869787, 1.986674541, 1.986377154, 1.986086317, 1.985801814, 1.985523442, 1.985251004, 1.984984312, 1.984723186, 1.984467455, 1.984216952, 1.983971519, 1.983731003, 1.983495259, 1.983264145, 1.983037526, 1.982815274, 1.982597262, 1.98238337, 1.982173483, 1.98196749, 1.981765282, 1.981566757, 1.981371815, 1.981180359, 1.980992298, 1.980807541, 1.980626002, 1.980447599, 1.980272249, 1.980099876, 1.979930405, 1.979763763, 1.979599878, 1.979438685, 1.979280117, 1.979124109, 1.978970602, 1.978819535, 1.97867085, 1.978524491, 1.978380405, 1.978238539, 1.978098842, 1.977961264, 1.977825758, 1.977692277, 1.977560777, 1.977431212, 1.977303542, 1.977177724, 1.97705372, 1.976931489, 1.976810994, 1.976692198, 1.976575066, 1.976459563, 1.976345655, 1.976233309, 1.976122494, 1.976013178, 1.975905331, 1.975798924, 1.975693928, 1.975590315, 1.975488058, 1.975387131, 1.975287508, 1.975189163, 1.975092073, 1.974996213, 1.97490156, 1.974808092, 1.974715786, 1.974624621, 1.974534576, 1.97444563, 1.974357764, 1.974270957, 1.974185191, 1.974100447, 1.974016708, 1.973933954, 1.973852169, 1.973771337, 1.97369144, 1.973612462, 1.973534388, 1.973457202, 1.973380889, 1.973305434, 1.973230823, 1.973157042, 1.973084077, 1.973011915, 1.972940542, 1.972869946, 1.972800114, 1.972731033, 1.972662692, 1.972595079, 1.972528182, 1.97246199, 1.972396491, 1.972331676, 1.972267533, 1.972204051, 1.972141222, 1.972079034, 1.972017478, 1.971956544, 1.971896224 };
|
||||||
|
private readonly static double[] T_Table_01 = { 63.65674116, 9.924843201, 5.84090931, 4.604094871, 4.032142984, 3.707428021, 3.499483297, 3.355387331, 3.249835542, 3.169272673, 3.105806516, 3.054539589, 3.012275839, 2.976842734, 2.946712883, 2.920781622, 2.89823052, 2.878440473, 2.860934606, 2.84533971, 2.831359558, 2.818756061, 2.807335684, 2.796939505, 2.787435814, 2.778714533, 2.770682957, 2.763262455, 2.756385904, 2.749995654, 2.744041919, 2.738481482, 2.733276642, 2.728394367, 2.723805589, 2.71948463, 2.715408722, 2.711557602, 2.707913184, 2.704459267, 2.701181304, 2.698066186, 2.695102079, 2.692278266, 2.689585019, 2.687013492, 2.684555618, 2.682204027, 2.679951974, 2.677793271, 2.675722234, 2.673733631, 2.671822636, 2.669984796, 2.668215988, 2.666512398, 2.664870482, 2.663286954, 2.661758752, 2.660283029, 2.658857127, 2.657478565, 2.656145025, 2.654854337, 2.653604469, 2.652393515, 2.651219685, 2.650081299, 2.648976774, 2.647904624, 2.646863444, 2.645851913, 2.644868782, 2.643912872, 2.642983067, 2.642078313, 2.641197611, 2.640340015, 2.639504627, 2.638690596, 2.637897113, 2.63712341, 2.636368757, 2.635632458, 2.634913852, 2.634212309, 2.633527229, 2.632858038, 2.632204191, 2.631565166, 2.630940463, 2.630329608, 2.629732145, 2.629147638, 2.628575671, 2.628015844, 2.627467774, 2.626931096, 2.626405457, 2.625890521, 2.625385965, 2.624891476, 2.624406758, 2.623931523, 2.623465496, 2.623008411, 2.622560015, 2.622120061, 2.621688313, 2.621264543, 2.620848534, 2.620440073, 2.620038957, 2.619644989, 2.619257981, 2.618877749, 2.618504116, 2.618136914, 2.617775976, 2.617421145, 2.617072266, 2.616729191, 2.616391776, 2.616059883, 2.615733377, 2.615412127, 2.615096008, 2.614784899, 2.61447868, 2.614177238, 2.613880461, 2.613588242, 2.613300477, 2.613017065, 2.612737908, 2.61246291, 2.61219198, 2.611925028, 2.611661966, 2.611402711, 2.611147181, 2.610895295, 2.610646976, 2.61040215, 2.610160742, 2.609922682, 2.609687901, 2.609456331, 2.609227907, 2.609002566, 2.608780245, 2.608560883, 2.608344423, 2.608130807, 2.60791998, 2.607711886, 2.607506474, 2.607303692, 2.607103489, 2.606905817, 2.606710628, 2.606517876, 2.606327515, 2.606139501, 2.605953791, 2.605770342, 2.605589114, 2.605410067, 2.605233162, 2.605058359, 2.604885623, 2.604714916, 2.604546204, 2.60437945, 2.604214622, 2.604051686, 2.60389061, 2.603731363, 2.603573912, 2.603418229, 2.603264282, 2.603112045, 2.602961487, 2.602812582, 2.602665303, 2.602519622, 2.602375515, 2.602232955, 2.602091918, 2.60195238, 2.601814317, 2.601677705, 2.601542523, 2.601408747, 2.601276355, 2.601145327, 2.601015642, 2.600887278, 2.600760216, 2.600634436 };
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Significance
|
||||||
|
{
|
||||||
|
P01,
|
||||||
|
P05,
|
||||||
|
P10,
|
||||||
|
P15,
|
||||||
|
P20,
|
||||||
|
P25
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AverageSpeed
|
||||||
|
{
|
||||||
|
/// <summary>Average speed in units per second</summary>
|
||||||
|
public double Average { get; private set; }
|
||||||
|
public TimeSpan SlowWindow { get; }
|
||||||
|
public TimeSpan FastWindow { get; }
|
||||||
|
public Significance SlowSignificance { get; }
|
||||||
|
public Significance FastSignificance { get; }
|
||||||
|
|
||||||
|
private DateTime start;
|
||||||
|
private TimeSpan lastTime;
|
||||||
|
private double lastPosition = double.NaN;
|
||||||
|
|
||||||
|
private readonly record struct Point(TimeSpan Time, double Velocity);
|
||||||
|
private readonly LinkedList<Point> speeds = new();
|
||||||
|
private const int MAX_SPEEDS = 200;
|
||||||
|
|
||||||
|
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
||||||
|
|
||||||
|
/// <param name="slowWindow">Total moving average time window</param>
|
||||||
|
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||||
|
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
|
||||||
|
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
|
||||||
|
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
|
||||||
|
{
|
||||||
|
SlowWindow = ArgumentValidator.EnsureGreaterThan(slowWindow, nameof(slowWindow), fastWindow);
|
||||||
|
FastWindow = ArgumentValidator.EnsureGreaterThan(fastWindow, nameof(fastWindow), TimeSpan.Zero);
|
||||||
|
SlowSignificance = slowSignificance;
|
||||||
|
FastSignificance = fastSignificance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Add a new position to the moving average</summary>
|
||||||
|
public void AddPosition(double position)
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
if (start == default)
|
||||||
|
start = now;
|
||||||
|
|
||||||
|
var time = now - start;
|
||||||
|
|
||||||
|
while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First.Value.Time > SlowWindow))
|
||||||
|
speeds.RemoveFirst();
|
||||||
|
|
||||||
|
if (!double.IsNaN(lastPosition))
|
||||||
|
{
|
||||||
|
var newSpeed = (position - lastPosition) / (time - lastTime).TotalSeconds;
|
||||||
|
speeds.AddLast(new Point(time, newSpeed));
|
||||||
|
}
|
||||||
|
|
||||||
|
lastTime = time;
|
||||||
|
lastPosition = position;
|
||||||
|
|
||||||
|
Average = ComputeNextAverage();
|
||||||
|
}
|
||||||
|
|
||||||
|
private double ComputeNextAverage()
|
||||||
|
{
|
||||||
|
if (speeds.Count == 0)
|
||||||
|
return 0;
|
||||||
|
else if (speeds.Count == 1)
|
||||||
|
return speeds.Last.Value.Velocity;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var n_newest = speeds.Count(s => s.Time > lastTime.Subtract(FastWindow));
|
||||||
|
|
||||||
|
var n_oldest = speeds.Count - n_newest;
|
||||||
|
|
||||||
|
if (speeds.Take(n_oldest).T_Test_2By(s => s.Velocity, speeds.TakeLast(n_newest), FastSignificance))
|
||||||
|
{
|
||||||
|
//Speeds in FastWindow are significantly different from reset of speeds in SlowWindow.
|
||||||
|
//Discard older speeds and keep only speeds in FastWindow
|
||||||
|
for (; n_oldest > 0; n_oldest--)
|
||||||
|
speeds.RemoveFirst();
|
||||||
|
|
||||||
|
return speeds.Average(s => s.Velocity);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return
|
||||||
|
speeds.T_Test_1By(s => s.Velocity, Average, SlowSignificance)
|
||||||
|
? speeds.Average(s => s.Velocity)
|
||||||
|
: Average;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
using System;
|
using AAXClean;
|
||||||
|
using Dinah.Core;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using AAXClean;
|
|
||||||
using Dinah.Core;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -16,15 +15,14 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
var startOffset = chapters.StartOffset;
|
var startOffset = chapters.StartOffset;
|
||||||
|
|
||||||
var trackCount = 0;
|
var trackCount = 1;
|
||||||
foreach (var c in chapters.Chapters)
|
foreach (var c in chapters.Chapters)
|
||||||
{
|
{
|
||||||
var startTime = c.StartOffset - startOffset;
|
var startTime = c.StartOffset - startOffset;
|
||||||
trackCount++;
|
|
||||||
|
|
||||||
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
|
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
|
||||||
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
||||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds / 1000d * 75)}");
|
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return stringBuilder.ToString();
|
return stringBuilder.ToString();
|
||||||
@@ -46,7 +44,7 @@ namespace AaxDecrypter
|
|||||||
for (var i = 0; i < cueContents.Length; i++)
|
for (var i = 0; i < cueContents.Length; i++)
|
||||||
{
|
{
|
||||||
var line = cueContents[i];
|
var line = cueContents[i];
|
||||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
|
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public interface IDownloadOptions
|
public interface IDownloadOptions
|
||||||
{
|
{
|
||||||
FileManager.ReplacementCharacters ReplacementCharacters { get; }
|
event EventHandler<long> DownloadSpeedChanged;
|
||||||
string DownloadUrl { get; }
|
string DownloadUrl { get; }
|
||||||
string UserAgent { get; }
|
string UserAgent { get; }
|
||||||
string AudibleKey { get; }
|
string AudibleKey { get; }
|
||||||
string AudibleIV { get; }
|
string AudibleIV { get; }
|
||||||
|
TimeSpan RuntimeLength { get; }
|
||||||
OutputFormat OutputFormat { get; }
|
OutputFormat OutputFormat { get; }
|
||||||
bool TrimOutputToChapterLength { get; }
|
bool TrimOutputToChapterLength { get; }
|
||||||
bool RetainEncryptedFile { get; }
|
bool RetainEncryptedFile { get; }
|
||||||
bool StripUnabridged { get; }
|
bool StripUnabridged { get; }
|
||||||
bool CreateCueSheet { get; }
|
bool CreateCueSheet { get; }
|
||||||
|
bool DownloadClipsBookmarks { get; }
|
||||||
|
long DownloadSpeedBps { get; }
|
||||||
ChapterInfo ChapterInfo { get; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool FixupFile { get; }
|
bool FixupFile { get; }
|
||||||
NAudio.Lame.LameConfig LameConfig { get; }
|
NAudio.Lame.LameConfig LameConfig { get; }
|
||||||
bool Downsample { get; }
|
bool Downsample { get; }
|
||||||
bool MatchSourceBitrate { get; }
|
bool MatchSourceBitrate { get; }
|
||||||
|
bool MoveMoovToBeginning { get; }
|
||||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||||
string GetMultipartTitleName(MultiConvertFileProperties props);
|
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||||
}
|
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
using NAudio.Lame;
|
using NAudio.Lame;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -11,17 +10,26 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
double bitrateMultiple = 1;
|
double bitrateMultiple = 1;
|
||||||
|
|
||||||
|
if (mp4File.TimeScale < lameConfig.OutputSampleRate)
|
||||||
|
{
|
||||||
|
lameConfig.OutputSampleRate = mp4File.TimeScale;
|
||||||
|
}
|
||||||
|
else if (mp4File.TimeScale > lameConfig.OutputSampleRate)
|
||||||
|
{
|
||||||
|
bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale;
|
||||||
|
}
|
||||||
|
|
||||||
if (mp4File.AudioChannels == 2)
|
if (mp4File.AudioChannels == 2)
|
||||||
{
|
{
|
||||||
if (downsample)
|
if (downsample)
|
||||||
bitrateMultiple = 0.5;
|
bitrateMultiple /= 2;
|
||||||
else
|
else
|
||||||
lameConfig.Mode = MPEGMode.Stereo;
|
lameConfig.Mode = MPEGMode.Stereo;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchSourceBitrate)
|
if (matchSourceBitrate)
|
||||||
{
|
{
|
||||||
int kbps = (int)(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
|
||||||
|
|
||||||
if (lameConfig.VBR is null)
|
if (lameConfig.VBR is null)
|
||||||
lameConfig.BitRate = kbps;
|
lameConfig.BitRate = kbps;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
@@ -10,6 +8,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.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;
|
||||||
|
public event EventHandler DownloadCompleted;
|
||||||
|
|
||||||
#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,32 +65,32 @@ 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));
|
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
|
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
|
||||||
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
||||||
|
|
||||||
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
||||||
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
||||||
|
|
||||||
SaveFilePath = saveFilePath;
|
RequestHeaders = requestHeaders ?? new();
|
||||||
Uri = uri;
|
|
||||||
WritePosition = writePosition;
|
|
||||||
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
|
|
||||||
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 +106,10 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
#region Downloader
|
#region Downloader
|
||||||
|
|
||||||
/// <summary>
|
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||||
/// Update the <see cref="JsonFilePersister"/>.
|
private void OnUpdate()
|
||||||
/// </summary>
|
|
||||||
private void Update()
|
|
||||||
{
|
{
|
||||||
RequestHeaders = HttpRequest.Headers;
|
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Updated?.Invoke(this, EventArgs.Empty);
|
Updated?.Invoke(this, EventArgs.Empty);
|
||||||
@@ -155,9 +120,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 +128,32 @@ 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>
|
public async Task BeginDownloadingAsync()
|
||||||
private void BeginDownloading()
|
|
||||||
{
|
{
|
||||||
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
|
|
||||||
|
|
||||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||||
{
|
{
|
||||||
hasBegunDownloading = true;
|
_backgroundDownloadTask = Task.CompletedTask;
|
||||||
downloadEnded.Set();
|
|
||||||
return;
|
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 = await new HttpClient().SendAsync(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 = await response.Content.ReadAsStreamAsync(_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())
|
_backgroundDownloadTask = Task.Run(() => 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, _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();
|
OnUpdate();
|
||||||
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,110 +224,17 @@ 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();
|
||||||
}
|
OnUpdate();
|
||||||
|
DownloadCompleted?.Invoke(this, null);
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Json Connverters
|
|
||||||
|
|
||||||
public static JsonSerializerSettings GetJsonSerializerSettings()
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,10 +243,10 @@ namespace AaxDecrypter
|
|||||||
#region Download Stream Reader
|
#region Download Stream Reader
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool CanRead => true;
|
public override bool CanRead => _readFile.CanRead;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool CanSeek => true;
|
public override bool CanSeek => _readFile.CanSeek;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool CanWrite => false;
|
public override bool CanWrite => false;
|
||||||
@@ -383,8 +256,8 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!hasBegunDownloading)
|
if (_backgroundDownloadTask is null)
|
||||||
BeginDownloading();
|
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||||
return ContentLength;
|
return ContentLength;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,18 +274,18 @@ 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)
|
if (_backgroundDownloadTask is null)
|
||||||
BeginDownloading();
|
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||||
|
|
||||||
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 +301,43 @@ 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.
|
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
|
||||||
/// </summary>
|
|
||||||
/// <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()
|
private bool disposed = false;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0
|
||||||
|
*
|
||||||
|
* In derived classes, do not override the Close() method, instead, put all of the
|
||||||
|
* Stream cleanup logic in the Dispose(Boolean) method.
|
||||||
|
*/
|
||||||
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
IsCancelled = true;
|
if (disposing && !disposed)
|
||||||
|
{
|
||||||
|
_cancellationSource.Cancel();
|
||||||
|
_backgroundDownloadTask?.GetAwaiter().GetResult();
|
||||||
|
_downloadedPiece?.Dispose();
|
||||||
|
_cancellationSource?.Dispose();
|
||||||
|
_readFile.Dispose();
|
||||||
|
_writeFile.Dispose();
|
||||||
|
OnUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ;
|
disposed = true;
|
||||||
|
base.Dispose(disposing);
|
||||||
_readFile.Close();
|
|
||||||
_writeFile.Close();
|
|
||||||
_networkStream?.Close();
|
|
||||||
Update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
~NetworkFileStream()
|
|
||||||
{
|
|
||||||
downloadEnded?.Close();
|
|
||||||
downloadedPiece?.Close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
using Dinah.Core.IO;
|
using Dinah.Core.IO;
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
||||||
{
|
{
|
||||||
|
|
||||||
/// <summary>Alias for Target </summary>
|
/// <summary>Alias for Target </summary>
|
||||||
public NetworkFileStream NetworkFileStream => Target;
|
public NetworkFileStream NetworkFileStream => Target;
|
||||||
|
|
||||||
@@ -17,7 +15,11 @@ namespace AaxDecrypter
|
|||||||
public NetworkFileStreamPersister(string path, string jsonPath = null)
|
public NetworkFileStreamPersister(string path, string jsonPath = null)
|
||||||
: base(path, jsonPath) { }
|
: base(path, jsonPath) { }
|
||||||
|
|
||||||
protected override JsonSerializerSettings GetSerializerSettings() => NetworkFileStream.GetJsonSerializerSettings();
|
protected override void Dispose(bool disposing)
|
||||||
|
{
|
||||||
}
|
if (disposing)
|
||||||
|
NetworkFileStream?.Dispose();
|
||||||
|
base.Dispose(disposing);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,112 +1,46 @@
|
|||||||
using System;
|
using FileManager;
|
||||||
using System.Threading;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Dinah.Core.Net.Http;
|
|
||||||
using FileManager;
|
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||||
{
|
{
|
||||||
|
protected override long InputFilePosition => InputFileStream.WritePosition;
|
||||||
|
|
||||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
||||||
: base(outFileName, cacheDirectory, dlLic) { }
|
: base(outFileName, cacheDirectory, dlLic)
|
||||||
|
|
||||||
public override async Task<bool> RunAsync()
|
|
||||||
{
|
{
|
||||||
try
|
AsyncSteps.Name = "Download Unencrypted Audiobook";
|
||||||
{
|
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
|
||||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
|
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
||||||
//Step 1
|
|
||||||
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
|
|
||||||
if (await Task.Run(Step_GetMetadata))
|
|
||||||
Serilog.Log.Information("Completed Step 1: Get Mp3 Metadata");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 1: Get Mp3 Metadata");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 2
|
|
||||||
Serilog.Log.Information("Begin Step 2: Download Audiobook");
|
|
||||||
if (await Task.Run(Step_DownloadAudiobookAsSingleFile))
|
|
||||||
Serilog.Log.Information("Completed Step 2: Download Audiobook");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 2: Download Audiobook");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
//Step 3
|
|
||||||
Serilog.Log.Information("Begin Step 3: Cleanup");
|
|
||||||
if (await Task.Run(Step_Cleanup))
|
|
||||||
Serilog.Log.Information("Completed Step 3: Cleanup");
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Serilog.Log.Information("Failed to Complete Step 3: Cleanup");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public override Task CancelAsync()
|
public override Task CancelAsync()
|
||||||
{
|
{
|
||||||
IsCanceled = true;
|
IsCanceled = true;
|
||||||
CloseInputFileStream();
|
FinalizeDownload();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_GetMetadata()
|
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
OnRetrievedCoverArt(null);
|
TaskCompletionSource completionSource = new();
|
||||||
|
|
||||||
return !IsCanceled;
|
InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
|
||||||
}
|
|
||||||
|
|
||||||
private bool Step_DownloadAudiobookAsSingleFile()
|
await completionSource.Task;
|
||||||
{
|
|
||||||
DateTime startTime = DateTime.Now;
|
|
||||||
|
|
||||||
// MUST put InputFileStream.Length first, because it starts background downloader.
|
if (IsCanceled)
|
||||||
|
return false;
|
||||||
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
|
else
|
||||||
{
|
{
|
||||||
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
|
FinalizeDownload();
|
||||||
|
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
|
||||||
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
|
OnFileCreated(OutputFileName);
|
||||||
|
return true;
|
||||||
if (double.IsNormal(estTimeRemaining))
|
|
||||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
|
||||||
|
|
||||||
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
|
|
||||||
|
|
||||||
OnDecryptProgressUpdate(
|
|
||||||
new DownloadProgress
|
|
||||||
{
|
|
||||||
ProgressPercentage = 100 * progressPercent,
|
|
||||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
|
||||||
TotalBytesToReceive = InputFileStream.Length
|
|
||||||
});
|
|
||||||
Thread.Sleep(200);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CloseInputFileStream();
|
|
||||||
|
|
||||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
|
|
||||||
SetOutputFileName(realOutputFileName);
|
|
||||||
OnFileCreated(realOutputFileName);
|
|
||||||
|
|
||||||
return !IsCanceled;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<?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</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<Version>8.3.5.1</Version>
|
<Version>10.1.0.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Octokit" Version="1.0.1" />
|
<PackageReference Include="Octokit" Version="5.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ 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;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using System.Runtime.InteropServices;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
@@ -17,22 +18,45 @@ namespace AppScaffolding
|
|||||||
public enum ReleaseIdentifier
|
public enum ReleaseIdentifier
|
||||||
{
|
{
|
||||||
None,
|
None,
|
||||||
WindowsClassic,
|
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
|
||||||
WindowsAvalonia,
|
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
|
||||||
LinuxAvalonia,
|
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
|
||||||
MacOSAvalonia
|
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
|
||||||
|
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
|
||||||
|
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64
|
||||||
|
}
|
||||||
|
|
||||||
|
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
||||||
|
[Flags]
|
||||||
|
public enum Variety
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Classic = 0x10000,
|
||||||
|
Chardonnay = 0x20000,
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class LibationScaffolding
|
public static class LibationScaffolding
|
||||||
{
|
{
|
||||||
public static bool IsWindows { get; } = OperatingSystem.IsWindows();
|
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
||||||
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
|
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
|
||||||
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
|
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
|
||||||
|
|
||||||
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||||
|
public static Variety Variety { get; private set; }
|
||||||
|
|
||||||
public static void SetReleaseIdentifier(ReleaseIdentifier releaseID)
|
public static void SetReleaseIdentifier(Variety varietyType)
|
||||||
=> ReleaseIdentifier = releaseID;
|
{
|
||||||
|
Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
|
||||||
|
|
||||||
|
var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
|
||||||
|
|
||||||
|
if (Enum.IsDefined(releaseID))
|
||||||
|
ReleaseIdentifier = releaseID;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ReleaseIdentifier = ReleaseIdentifier.None;
|
||||||
|
Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AppScaffolding
|
// AppScaffolding
|
||||||
private static Assembly _executingAssembly;
|
private static Assembly _executingAssembly;
|
||||||
@@ -58,6 +82,8 @@ namespace AppScaffolding
|
|||||||
// // outdated. kept here as an example of what belongs in this area
|
// // outdated. kept here as an example of what belongs in this area
|
||||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||||
|
|
||||||
|
Configuration.SetLibationVersion(BuildVersion);
|
||||||
|
|
||||||
//***********************************************//
|
//***********************************************//
|
||||||
// //
|
// //
|
||||||
// do not use Configuration before this line //
|
// do not use Configuration before this line //
|
||||||
@@ -70,106 +96,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.BetaOptIn)))
|
|
||||||
config.BetaOptIn = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
|
||||||
config.AllowLibationFixup = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.CreateCueSheet)))
|
|
||||||
config.CreateCueSheet = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.RetainAaxFile)))
|
|
||||||
config.RetainAaxFile = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.SplitFilesByChapter)))
|
|
||||||
config.SplitFilesByChapter = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.StripUnabridged)))
|
|
||||||
config.StripUnabridged = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.StripAudibleBrandAudio)))
|
|
||||||
config.StripAudibleBrandAudio = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DecryptToLossy)))
|
|
||||||
config.DecryptToLossy = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameTargetBitrate)))
|
|
||||||
config.LameTargetBitrate = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameDownsampleMono)))
|
|
||||||
config.LameDownsampleMono = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameBitrate)))
|
|
||||||
config.LameBitrate = 64;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameConstantBitrate)))
|
|
||||||
config.LameConstantBitrate = false;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameMatchSourceBR)))
|
|
||||||
config.LameMatchSourceBR = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.LameVBRQuality)))
|
|
||||||
config.LameVBRQuality = 2;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.BadBook)))
|
|
||||||
config.BadBook = Configuration.BadBookAction.Ask;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ShowImportedStats)))
|
|
||||||
config.ShowImportedStats = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ImportEpisodes)))
|
|
||||||
config.ImportEpisodes = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DownloadEpisodes)))
|
|
||||||
config.DownloadEpisodes = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ReplacementCharacters)))
|
|
||||||
config.ReplacementCharacters = FileManager.ReplacementCharacters.Default;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.FolderTemplate)))
|
|
||||||
config.FolderTemplate = Templates.Folder.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.FileTemplate)))
|
|
||||||
config.FileTemplate = Templates.File.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ChapterFileTemplate)))
|
|
||||||
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.ChapterTitleTemplate)))
|
|
||||||
config.ChapterTitleTemplate = Templates.ChapterTitle.DefaultTemplate;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AutoScan)))
|
|
||||||
config.AutoScan = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsVisibilities)))
|
|
||||||
config.GridColumnsVisibilities = new Dictionary<string, bool>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsDisplayIndices)))
|
|
||||||
config.GridColumnsDisplayIndices = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.GridColumnsWidths)))
|
|
||||||
config.GridColumnsWidths = new Dictionary<string, int>();
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.DownloadCoverArt)))
|
|
||||||
config.DownloadCoverArt = true;
|
|
||||||
|
|
||||||
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
|
|
||||||
config.AutoDownloadEpisodes = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||||
@@ -179,6 +111,8 @@ namespace AppScaffolding
|
|||||||
configureLogging(config);
|
configureLogging(config);
|
||||||
logStartupState(config);
|
logStartupState(config);
|
||||||
|
|
||||||
|
// all else should occur after logging
|
||||||
|
|
||||||
wireUpSystemEvents(config);
|
wireUpSystemEvents(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +152,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);
|
||||||
@@ -236,8 +170,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
|
||||||
@@ -276,12 +210,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)
|
||||||
{
|
{
|
||||||
@@ -293,12 +227,6 @@ namespace AppScaffolding
|
|||||||
if (System.Diagnostics.Debugger.IsAttached)
|
if (System.Diagnostics.Debugger.IsAttached)
|
||||||
mode += " (Debugger attached)";
|
mode += " (Debugger attached)";
|
||||||
|
|
||||||
string OS
|
|
||||||
= IsLinux ? "Linux"
|
|
||||||
: IsMacOs ? "MacOS"
|
|
||||||
: IsWindows ? "Windows"
|
|
||||||
: "UNKNOWN_OS";
|
|
||||||
|
|
||||||
// begin logging session with a form feed
|
// begin logging session with a form feed
|
||||||
Log.Logger.Information("\r\n\f");
|
Log.Logger.Information("\r\n\f");
|
||||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||||
@@ -306,8 +234,9 @@ namespace AppScaffolding
|
|||||||
AppName = EntryAssembly.GetName().Name,
|
AppName = EntryAssembly.GetName().Name,
|
||||||
Version = BuildVersion.ToString(),
|
Version = BuildVersion.ToString(),
|
||||||
ReleaseIdentifier,
|
ReleaseIdentifier,
|
||||||
OS,
|
Configuration.OS,
|
||||||
Mode = mode,
|
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(),
|
||||||
@@ -315,8 +244,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.BetaOptIn,
|
config.BetaOptIn,
|
||||||
config.LibationFiles,
|
config.UseCoverAsFolderIcon,
|
||||||
|
config.LibationFiles,
|
||||||
AudibleFileStorage.BooksDirectory,
|
AudibleFileStorage.BooksDirectory,
|
||||||
|
|
||||||
config.InProgress,
|
config.InProgress,
|
||||||
@@ -327,9 +257,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);
|
||||||
@@ -362,7 +295,7 @@ 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, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
@@ -382,8 +315,8 @@ namespace AppScaffolding
|
|||||||
}
|
}
|
||||||
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||||
{
|
{
|
||||||
var ownerAccount = "rmcrackan";
|
const string ownerAccount = "rmcrackan";
|
||||||
var repoName = "Libation";
|
const string repoName = "Libation";
|
||||||
|
|
||||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||||
|
|
||||||
@@ -391,12 +324,11 @@ namespace AppScaffolding
|
|||||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||||
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||||
|
|
||||||
// https://octokitnet.readthedocs.io/en/latest/releases/
|
|
||||||
var releases = await gitHubClient.Repository.Release.GetAll(ownerAccount, repoName);
|
|
||||||
|
|
||||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
var latestRelease = releases.FirstOrDefault(r => !r.Draft && !r.Prerelease && r.Assets.Any(a => regex.IsMatch(a.Name)));
|
|
||||||
|
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||||
|
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
||||||
|
|
||||||
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,74 +381,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 lastMigrationThrew = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
|
|
||||||
|
|
||||||
if (lastMigrationThrew) 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
@@ -25,9 +26,6 @@ namespace AppScaffolding
|
|||||||
: value;
|
: value;
|
||||||
|
|
||||||
#region appsettings.json
|
#region appsettings.json
|
||||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json");
|
|
||||||
|
|
||||||
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
|
|
||||||
|
|
||||||
public static bool APPSETTINGS_TryGet(string key, out string value)
|
public static bool APPSETTINGS_TryGet(string key, out string value)
|
||||||
{
|
{
|
||||||
@@ -61,11 +59,7 @@ namespace AppScaffolding
|
|||||||
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
||||||
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
|
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
|
||||||
{
|
{
|
||||||
// only insert if not exists
|
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
|
||||||
if (!APPSETTINGS_Json_Exists)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
|
|
||||||
|
|
||||||
JObject jObj;
|
JObject jObj;
|
||||||
try
|
try
|
||||||
@@ -88,7 +82,7 @@ namespace AppScaffolding
|
|||||||
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
File.WriteAllText(APPSETTINGS_JSON, endingContents_indented);
|
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
|
||||||
System.Threading.Thread.Sleep(100);
|
System.Threading.Thread.Sleep(100);
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
{
|
{
|
||||||
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
|
public partial record UpgradeProperties
|
||||||
|
{
|
||||||
|
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 = LinkStripRegex().Replace(notes, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
|
||||||
|
private static partial Regex LinkStripRegex();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<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="CsvHelper" Version="28.0.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -103,7 +103,16 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
[Name("Audio Format")]
|
[Name("Audio Format")]
|
||||||
public string AudioFormat { get; set; }
|
public string AudioFormat { get; set; }
|
||||||
}
|
|
||||||
|
[Name("Language")]
|
||||||
|
public string Language { get; set; }
|
||||||
|
|
||||||
|
[Name("LastDownloaded")]
|
||||||
|
public DateTime? LastDownloaded { get; set; }
|
||||||
|
|
||||||
|
[Name("LastDownloadedVersion")]
|
||||||
|
public string LastDownloadedVersion { get; set; }
|
||||||
|
}
|
||||||
public static class LibToDtos
|
public static class LibToDtos
|
||||||
{
|
{
|
||||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||||
@@ -136,7 +145,11 @@ namespace ApplicationServices
|
|||||||
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()
|
AudioFormat = a.Book.AudioFormat.ToString(),
|
||||||
|
Language = a.Book.Language,
|
||||||
|
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||||
|
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||||
|
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
@@ -207,8 +220,11 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.BookStatus),
|
nameof(ExportDto.BookStatus),
|
||||||
nameof(ExportDto.PdfStatus),
|
nameof(ExportDto.PdfStatus),
|
||||||
nameof(ExportDto.ContentType),
|
nameof(ExportDto.ContentType),
|
||||||
nameof(ExportDto.AudioFormat)
|
nameof(ExportDto.AudioFormat),
|
||||||
};
|
nameof(ExportDto.Language),
|
||||||
|
nameof(ExportDto.LastDownloaded),
|
||||||
|
nameof(ExportDto.LastDownloadedVersion),
|
||||||
|
};
|
||||||
var col = 0;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
{
|
{
|
||||||
@@ -233,9 +249,9 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||||
|
|
||||||
var dateAddedCell = row.CreateCell(col++);
|
var dateCell = row.CreateCell(col++);
|
||||||
dateAddedCell.CellStyle = dateStyle;
|
dateCell.CellStyle = dateStyle;
|
||||||
dateAddedCell.SetCellValue(dto.DateAdded);
|
dateCell.SetCellValue(dto.DateAdded);
|
||||||
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||||
@@ -273,9 +289,19 @@ 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);
|
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||||
|
|
||||||
rowIndex++;
|
if (dto.LastDownloaded.HasValue)
|
||||||
|
{
|
||||||
|
dateCell = row.CreateCell(col);
|
||||||
|
dateCell.CellStyle = dateStyle;
|
||||||
|
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
||||||
|
|
||||||
|
rowIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using System.Diagnostics;
|
||||||
using AudibleApi;
|
using AudibleApi;
|
||||||
using AudibleApi.Common;
|
using AudibleApi.Common;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using Newtonsoft.Json.Converters;
|
|
||||||
using Polly;
|
using Polly;
|
||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
namespace AudibleUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
@@ -19,6 +18,9 @@ namespace AudibleUtilities
|
|||||||
{
|
{
|
||||||
public Api Api { get; private set; }
|
public Api Api { get; private set; }
|
||||||
|
|
||||||
|
private const int MaxConcurrency = 10;
|
||||||
|
private const int BatchSize = 50;
|
||||||
|
|
||||||
private ApiExtended(Api api) => Api = api;
|
private ApiExtended(Api api) => Api = api;
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||||
@@ -39,42 +41,6 @@ namespace AudibleUtilities
|
|||||||
return new ApiExtended(api);
|
return new ApiExtended(api);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens else login with native api callbacks.</summary>
|
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginCallback loginCallback)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
|
||||||
{
|
|
||||||
LoginType = nameof(ILoginCallback),
|
|
||||||
Account = account?.MaskedLogEntry ?? "[null]",
|
|
||||||
LocaleName = account?.Locale?.Name
|
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
|
||||||
loginCallback,
|
|
||||||
account.Locale,
|
|
||||||
AudibleApiStorage.AccountsSettingsFile,
|
|
||||||
account.GetIdentityTokensJsonPath());
|
|
||||||
return new ApiExtended(api);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens else login with external browser</summary>
|
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginExternal loginExternal)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
|
||||||
{
|
|
||||||
LoginType = nameof(ILoginExternal),
|
|
||||||
Account = account?.MaskedLogEntry ?? "[null]",
|
|
||||||
LocaleName = account?.Locale?.Name
|
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
|
||||||
loginExternal,
|
|
||||||
account.Locale,
|
|
||||||
AudibleApiStorage.AccountsSettingsFile,
|
|
||||||
account.GetIdentityTokensJsonPath());
|
|
||||||
return new ApiExtended(api);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||||
{
|
{
|
||||||
@@ -121,226 +87,184 @@ namespace AudibleUtilities
|
|||||||
|
|
||||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
||||||
{
|
{
|
||||||
var items = new List<Item>();
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||||
|
|
||||||
List<Task<List<Item>>> getChildEpisodesTasks = new();
|
List<Item> items = new();
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var totalTime = TimeSpan.Zero;
|
||||||
|
using var semaphore = new SemaphoreSlim(MaxConcurrency);
|
||||||
|
|
||||||
int count = 0, maxConcurrentEpisodeScans = 5;
|
var episodeChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||||
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
|
var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore);
|
||||||
|
|
||||||
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
|
//Scan the library for all added books.
|
||||||
|
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
|
||||||
|
await foreach (var item in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
|
||||||
{
|
{
|
||||||
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
|
if (importEpisodes)
|
||||||
{
|
{
|
||||||
//Get child episodes asynchronously and await all at the end
|
var episodes = item.Where(i => i.IsEpisodes).ToList();
|
||||||
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
|
var series = item.Where(i => i.IsSeriesParent).ToList();
|
||||||
|
|
||||||
|
var parentAsins = episodes
|
||||||
|
.SelectMany(i => i.Relationships)
|
||||||
|
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||||
|
.Select(r => r.Asin);
|
||||||
|
|
||||||
|
var episodeAsins = series
|
||||||
|
.SelectMany(i => i.Relationships)
|
||||||
|
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
||||||
|
.Select(r => r.Asin);
|
||||||
|
|
||||||
|
foreach (var asin in parentAsins.Concat(episodeAsins))
|
||||||
|
episodeChannel.Writer.TryWrite(asin);
|
||||||
|
|
||||||
|
items.AddRange(episodes);
|
||||||
|
items.AddRange(series);
|
||||||
}
|
}
|
||||||
else if (!item.IsEpisodes && !item.IsSeriesParent)
|
|
||||||
items.Add(item);
|
|
||||||
|
|
||||||
count++;
|
items.AddRange(item.Where(i => !i.IsSeriesParent && !i.IsEpisodes));
|
||||||
}
|
}
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
|
sw.Stop();
|
||||||
|
totalTime += sw.Elapsed;
|
||||||
|
Serilog.Log.Logger.Debug("Library scan complete after {elappsed_ms} ms. Found {count} books and series. Waiting on series episode scans to complete.", sw.ElapsedMilliseconds, items.Count);
|
||||||
|
sw.Restart();
|
||||||
|
|
||||||
//await and add all episodes from all parents
|
//Signal that we're done adding asins
|
||||||
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
|
episodeChannel.Writer.Complete();
|
||||||
items.AddRange(epList);
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed library scan.");
|
//Wait for all episodes/parents to be retrived
|
||||||
|
var allEps = await batchReaderTask;
|
||||||
|
|
||||||
#if DEBUG
|
sw.Stop();
|
||||||
//// this will not work for multi accounts
|
totalTime += sw.Elapsed;
|
||||||
//var library_json = "library.json";
|
Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count);
|
||||||
//library_json = System.IO.Path.GetFullPath(library_json);
|
sw.Restart();
|
||||||
//if (System.IO.File.Exists(library_json))
|
|
||||||
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
|
Serilog.Log.Logger.Debug("Begin indexing series episodes");
|
||||||
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
|
items.AddRange(allEps);
|
||||||
#endif
|
|
||||||
var validators = new List<IValidator>();
|
//Set the Item.Series info for episodes and parents.
|
||||||
validators.AddRange(getValidators());
|
foreach (var parent in items.Where(i => i.IsSeriesParent))
|
||||||
foreach (var v in validators)
|
|
||||||
{
|
{
|
||||||
var exceptions = v.Validate(items);
|
var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin));
|
||||||
if (exceptions is not null && exceptions.Any())
|
SetSeries(parent, children);
|
||||||
throw new AggregateException(exceptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null);
|
||||||
|
if (orphansRemoved > 0)
|
||||||
|
Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved);
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
totalTime += sw.Elapsed;
|
||||||
|
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
|
||||||
|
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
|
||||||
|
|
||||||
|
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
|
||||||
|
if (allExceptions?.Count > 0)
|
||||||
|
throw new ImportValidationException(items, allExceptions);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<IValidator> getValidators()
|
|
||||||
{
|
|
||||||
var type = typeof(IValidator);
|
|
||||||
var types = AppDomain.CurrentDomain.GetAssemblies()
|
|
||||||
.SelectMany(s => s.GetTypes())
|
|
||||||
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
|
|
||||||
|
|
||||||
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region episodes and podcasts
|
#region episodes and podcasts
|
||||||
|
|
||||||
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
|
/// <summary>
|
||||||
|
/// Read asins from the channel and request catalog item info in batches of <see cref="BatchSize"/>. Blocks until <paramref name="channelReader"/> is closed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="channelReader">Input asins to batch</param>
|
||||||
|
/// <param name="semaphore">Shared semaphore to limit concurrency</param>
|
||||||
|
/// <returns>All <see cref="Item"/>s of asins written to the channel.</returns>
|
||||||
|
private async Task<List<Item>> readAllAsinsAsync(ChannelReader<string> channelReader, SemaphoreSlim semaphore)
|
||||||
{
|
{
|
||||||
await concurrencySemaphore.WaitAsync();
|
int batchNum = 1;
|
||||||
|
List<Task<List<Item>>> getTasks = new();
|
||||||
|
|
||||||
|
while (await channelReader.WaitToReadAsync())
|
||||||
|
{
|
||||||
|
List<string> asins = new();
|
||||||
|
|
||||||
|
while (asins.Count < BatchSize && await channelReader.WaitToReadAsync())
|
||||||
|
{
|
||||||
|
var asin = await channelReader.ReadAsync();
|
||||||
|
|
||||||
|
if (!asins.Contains(asin))
|
||||||
|
asins.Add(asin);
|
||||||
|
}
|
||||||
|
await semaphore.WaitAsync();
|
||||||
|
getTasks.Add(getProductsAsync(batchNum++, asins, semaphore));
|
||||||
|
}
|
||||||
|
|
||||||
|
var completed = await Task.WhenAll(getTasks);
|
||||||
|
//We only want Series parents and Series episodes. Explude other relationship types (e.g. 'season')
|
||||||
|
return completed.SelectMany(l => l).Where(i => i.IsSeriesParent || i.IsEpisodes).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<Item>> getProductsAsync(int batchNum, List<string> asins, SemaphoreSlim semaphore)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Debug($"Batch {batchNum} Begin: Fetching {asins.Count} asins");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
var sw = Stopwatch.StartNew();
|
||||||
|
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
List<Item> children;
|
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
|
||||||
|
|
||||||
if (parent.IsEpisodes)
|
return items;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins });
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally { semaphore.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetSeries(Item parent, IEnumerable<Item> children)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(parent, nameof(parent));
|
||||||
|
ArgumentValidator.EnsureNotNull(children, nameof(children));
|
||||||
|
|
||||||
|
//A series parent will always have exactly 1 Series
|
||||||
|
parent.Series = new[]
|
||||||
|
{
|
||||||
|
new Series
|
||||||
{
|
{
|
||||||
//The 'parent' is a single episode that was added to the library.
|
Asin = parent.Asin,
|
||||||
//Get the episode's parent and add it to the database.
|
Sequence = "-1",
|
||||||
|
Title = parent.TitleWithSubtitle
|
||||||
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
|
|
||||||
|
|
||||||
children = new() { parent };
|
|
||||||
|
|
||||||
var parentAsins = parent.Relationships
|
|
||||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
|
||||||
.Select(p => p.Asin);
|
|
||||||
|
|
||||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
|
|
||||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
|
||||||
if (numSeriesParents != 1)
|
|
||||||
{
|
|
||||||
//There should only ever be 1 top-level parent per episode. If not, log
|
|
||||||
//so we can figure out what to do about those special cases, and don't
|
|
||||||
//import the episode.
|
|
||||||
JsonSerializerSettings Settings = new()
|
|
||||||
{
|
|
||||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
|
||||||
DateParseHandling = DateParseHandling.None,
|
|
||||||
Converters =
|
|
||||||
{
|
|
||||||
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
|
|
||||||
return new List<Item>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var realParent = seriesParents.Single(p => p.IsSeriesParent);
|
|
||||||
realParent.PurchaseDate = parent.PurchaseDate;
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
|
|
||||||
parent = realParent;
|
|
||||||
}
|
}
|
||||||
else
|
};
|
||||||
|
|
||||||
|
if (parent.PurchaseDate == default)
|
||||||
|
{
|
||||||
|
parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default);
|
||||||
|
|
||||||
|
if (parent.PurchaseDate == default)
|
||||||
{
|
{
|
||||||
children = await getEpisodeChildrenAsync(parent);
|
Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent);
|
||||||
if (!children.Any())
|
parent.PurchaseDate = DateTimeOffset.UtcNow;
|
||||||
return new();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//A series parent will always have exactly 1 Series
|
foreach (var child in children)
|
||||||
parent.Series = new Series[]
|
{
|
||||||
|
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||||
|
child.PurchaseDate = parent.PurchaseDate;
|
||||||
|
// parent is essentially a series
|
||||||
|
child.Series = new[]
|
||||||
{
|
{
|
||||||
new Series
|
new Series
|
||||||
{
|
{
|
||||||
Asin = parent.Asin,
|
Asin = parent.Asin,
|
||||||
Sequence = "-1",
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||||
|
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
|
||||||
Title = parent.TitleWithSubtitle
|
Title = parent.TitleWithSubtitle
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var child in children)
|
|
||||||
{
|
|
||||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
|
||||||
child.PurchaseDate = parent.PurchaseDate;
|
|
||||||
// parent is essentially a series
|
|
||||||
child.Series = new Series[]
|
|
||||||
{
|
|
||||||
new Series
|
|
||||||
{
|
|
||||||
Asin = parent.Asin,
|
|
||||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
|
||||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
|
|
||||||
Title = parent.TitleWithSubtitle
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
children.Add(parent);
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
}
|
||||||
finally
|
|
||||||
{
|
|
||||||
concurrencySemaphore.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
|
|
||||||
{
|
|
||||||
var childrenIds = parent.Relationships
|
|
||||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
|
||||||
.Select(r => r.Asin)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// fetch children in batches
|
|
||||||
const int batchSize = 20;
|
|
||||||
|
|
||||||
var results = new List<Item>();
|
|
||||||
|
|
||||||
for (var i = 1; ; i++)
|
|
||||||
{
|
|
||||||
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
|
|
||||||
if (!idBatch.Any())
|
|
||||||
break;
|
|
||||||
|
|
||||||
List<Item> childrenBatch;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
|
||||||
#if DEBUG
|
|
||||||
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
|
||||||
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
|
|
||||||
{
|
|
||||||
ParentId = parent.Asin,
|
|
||||||
ParentTitle = parent.Title,
|
|
||||||
BatchNumber = i,
|
|
||||||
ChildIdBatch = idBatch
|
|
||||||
});
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
|
|
||||||
// the service returned no results. probably indicates an error. stop running batches
|
|
||||||
if (!childrenBatch.Any())
|
|
||||||
break;
|
|
||||||
|
|
||||||
results.AddRange(childrenBatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
|
|
||||||
{
|
|
||||||
ParentId = parent.Asin,
|
|
||||||
ParentTitle = parent.Title,
|
|
||||||
ChildCount = childrenIds.Count
|
|
||||||
});
|
|
||||||
|
|
||||||
if (childrenIds.Count != results.Count)
|
|
||||||
{
|
|
||||||
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
|
|
||||||
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
|
|
||||||
throw ex;
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,18 @@ namespace AudibleUtilities
|
|||||||
public interface IValidator
|
public interface IValidator
|
||||||
{
|
{
|
||||||
IEnumerable<Exception> Validate(IEnumerable<Item> items);
|
IEnumerable<Exception> Validate(IEnumerable<Item> items);
|
||||||
|
|
||||||
|
public static IValidator[] GetAllValidators()
|
||||||
|
=> new IValidator[]
|
||||||
|
{
|
||||||
|
new LibraryValidator(),
|
||||||
|
new BookValidator(),
|
||||||
|
new CategoryValidator(),
|
||||||
|
new ContributorValidator(),
|
||||||
|
new SeriesValidator(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public class LibraryValidator : IValidator
|
public class LibraryValidator : IValidator
|
||||||
{
|
{
|
||||||
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
|
||||||
|
|||||||
@@ -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="AudibleApi" Version="4.6.2.1" />
|
<PackageReference Include="AudibleApi" Version="8.2.3.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using AudibleApi.Common;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AudibleUtilities
|
||||||
|
{
|
||||||
|
public class ImportValidationException : AggregateException
|
||||||
|
{
|
||||||
|
public List<Item> Items { get; }
|
||||||
|
public ImportValidationException(List<Item> items, IEnumerable<Exception> exceptions) : base(exceptions)
|
||||||
|
{
|
||||||
|
Items = items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AudibleApi;
|
using AudibleApi;
|
||||||
using AudibleApi.Authorization;
|
using AudibleApi.Authorization;
|
||||||
|
using AudibleApi.Cryptography;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -178,7 +179,7 @@ namespace AudibleUtilities
|
|||||||
LocaleCode = account.Locale.CountryCode,
|
LocaleCode = account.Locale.CountryCode,
|
||||||
RefreshToken = account.IdentityTokens.RefreshToken.Value,
|
RefreshToken = account.IdentityTokens.RefreshToken.Value,
|
||||||
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
|
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
|
||||||
WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()),
|
WebsiteCookies = new(account.IdentityTokens.Cookies),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace DataLayer.Configurations
|
namespace DataLayer.Configurations
|
||||||
{
|
{
|
||||||
@@ -19,40 +20,45 @@ namespace DataLayer.Configurations
|
|||||||
entity.Ignore(nameof(Book.Authors));
|
entity.Ignore(nameof(Book.Authors));
|
||||||
entity.Ignore(nameof(Book.Narrators));
|
entity.Ignore(nameof(Book.Narrators));
|
||||||
entity.Ignore(nameof(Book.AudioFormat));
|
entity.Ignore(nameof(Book.AudioFormat));
|
||||||
//// these don't seem to matter
|
//// these don't seem to matter
|
||||||
//entity.Ignore(nameof(Book.AuthorNames));
|
//entity.Ignore(nameof(Book.AuthorNames));
|
||||||
//entity.Ignore(nameof(Book.NarratorNames));
|
//entity.Ignore(nameof(Book.NarratorNames));
|
||||||
//entity.Ignore(nameof(Book.HasPdfs));
|
//entity.Ignore(nameof(Book.HasPdfs));
|
||||||
|
|
||||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||||
entity
|
entity
|
||||||
.OwnsMany(b => b.Supplements, b_s =>
|
.OwnsMany(b => b.Supplements, b_s =>
|
||||||
{
|
{
|
||||||
b_s.WithOwner(s => s.Book)
|
b_s.WithOwner(s => s.Book)
|
||||||
.HasForeignKey(s => s.BookId);
|
.HasForeignKey(s => s.BookId);
|
||||||
b_s.HasKey(s => s.SupplementId);
|
b_s.HasKey(s => s.SupplementId);
|
||||||
});
|
});
|
||||||
// even though it's owned, we need to map its backing field
|
// even though it's owned, we need to map its backing field
|
||||||
entity
|
entity
|
||||||
.Metadata
|
.Metadata
|
||||||
.FindNavigation(nameof(Book.Supplements))
|
.FindNavigation(nameof(Book.Supplements))
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
|
||||||
// owns it 1:1, store in separate table
|
// owns it 1:1, store in separate table
|
||||||
entity
|
entity
|
||||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||||
{
|
{
|
||||||
b_udi.WithOwner(udi => udi.Book)
|
b_udi.WithOwner(udi => udi.Book)
|
||||||
.HasForeignKey(udi => udi.BookId);
|
.HasForeignKey(udi => udi.BookId);
|
||||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||||
|
|
||||||
// owns it 1:1, store in same table
|
b_udi.Property(udi => udi.LastDownloaded);
|
||||||
b_udi.OwnsOne(udi => udi.Rating);
|
b_udi
|
||||||
});
|
.Property(udi => udi.LastDownloadedVersion)
|
||||||
|
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
||||||
|
|
||||||
entity
|
// owns it 1:1, store in same table
|
||||||
|
b_udi.OwnsOne(udi => udi.Rating);
|
||||||
|
});
|
||||||
|
|
||||||
|
entity
|
||||||
.Metadata
|
.Metadata
|
||||||
.FindNavigation(nameof(Book.ContributorsLink))
|
.FindNavigation(nameof(Book.ContributorsLink))
|
||||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
||||||
@@ -68,6 +74,6 @@ namespace DataLayer.Configurations
|
|||||||
.HasOne(b => b.Category)
|
.HasOne(b => b.Category)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(b => b.CategoryId);
|
.HasForeignKey(b => b.CategoryId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -10,14 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="5.1.0.1" />
|
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="5.0.0.1" />
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.4">
|
||||||
<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.7" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.7">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.4">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ namespace DataLayer
|
|||||||
// book details
|
// book details
|
||||||
public bool IsAbridged { get; private set; }
|
public bool IsAbridged { get; private set; }
|
||||||
public DateTime? DatePublished { get; private set; }
|
public DateTime? DatePublished { get; private set; }
|
||||||
|
public string Language { get; private set; }
|
||||||
|
|
||||||
// non-null. use "empty pattern"
|
// non-null. use "empty pattern"
|
||||||
internal int CategoryId { get; private set; }
|
internal int CategoryId { get; private set; }
|
||||||
@@ -215,11 +216,12 @@ namespace DataLayer
|
|||||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||||
|
|
||||||
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished)
|
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language)
|
||||||
{
|
{
|
||||||
// don't overwrite with default values
|
// don't overwrite with default values
|
||||||
IsAbridged |= isAbridged;
|
IsAbridged |= isAbridged;
|
||||||
DatePublished = datePublished ?? DatePublished;
|
DatePublished = datePublished ?? DatePublished;
|
||||||
|
Language = language?.FirstCharToUpper() ?? Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateCategory(Category category, DbContext context = null)
|
public void UpdateCategory(Category category, DbContext context = null)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ 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; }
|
||||||
|
public bool AbsentFromLastScan { get; set; }
|
||||||
|
|
||||||
private LibraryBook() { }
|
private LibraryBook() { }
|
||||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||||
{
|
{
|
||||||
@@ -22,6 +25,8 @@ namespace DataLayer
|
|||||||
Account = account;
|
Account = account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"{DateAdded:d} {Book}";
|
public void SetAccount(string account) => Account = account;
|
||||||
|
|
||||||
|
public override string ToString() => $"{DateAdded:d} {Book}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ using Dinah.Core;
|
|||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
/// <summary>Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable</summary>
|
/// <summary>Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable</summary>
|
||||||
public class Rating : ValueObject_Static<Rating>
|
public class Rating : ValueObject_Static<Rating>, IComparable<Rating>, IComparable
|
||||||
{
|
{
|
||||||
public float OverallRating { get; private set; }
|
public float OverallRating { get; private set; }
|
||||||
public float PerformanceRating { get; private set; }
|
public float PerformanceRating { get; private set; }
|
||||||
@@ -38,6 +38,16 @@ namespace DataLayer
|
|||||||
yield return StoryRating;
|
yield return StoryRating;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
|
||||||
}
|
|
||||||
|
public int CompareTo(Rating other)
|
||||||
|
{
|
||||||
|
var compare = OverallRating.CompareTo(other.OverallRating);
|
||||||
|
if (compare != 0) return compare;
|
||||||
|
compare = PerformanceRating.CompareTo(other.PerformanceRating);
|
||||||
|
if (compare != 0) return compare;
|
||||||
|
return StoryRating.CompareTo(other.StoryRating);
|
||||||
|
}
|
||||||
|
public int CompareTo(object obj) => obj is Rating second ? CompareTo(second) : -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,12 +20,31 @@ namespace DataLayer
|
|||||||
PartialDownload = 0x1000
|
PartialDownload = 0x1000
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UserDefinedItem
|
public partial class UserDefinedItem
|
||||||
{
|
{
|
||||||
internal int BookId { get; private set; }
|
internal int BookId { get; private set; }
|
||||||
public Book Book { get; private set; }
|
public Book Book { get; private set; }
|
||||||
|
public DateTime? LastDownloaded { get; private set; }
|
||||||
|
public Version LastDownloadedVersion { get; private set; }
|
||||||
|
|
||||||
private UserDefinedItem() { }
|
public void SetLastDownloaded(Version version)
|
||||||
|
{
|
||||||
|
if (LastDownloadedVersion != version)
|
||||||
|
{
|
||||||
|
LastDownloadedVersion = version;
|
||||||
|
OnItemChanged(nameof(LastDownloadedVersion));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version is null)
|
||||||
|
LastDownloaded = null;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LastDownloaded = DateTime.Now;
|
||||||
|
OnItemChanged(nameof(LastDownloaded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserDefinedItem() { }
|
||||||
internal UserDefinedItem(Book book)
|
internal UserDefinedItem(Book book)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||||
@@ -51,18 +70,23 @@ namespace DataLayer
|
|||||||
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
|
||||||
// only legal chars are letters numbers underscores and separating whitespace
|
|
||||||
//
|
/// <summary>
|
||||||
// technically, the only char.s which aren't easily supported are \ [ ]
|
/// only legal chars are letters numbers underscores and separating whitespace
|
||||||
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
///
|
||||||
// it's easy to expand whitelist as needed
|
/// technically, the only char.s which aren't easily supported are \ [ ]
|
||||||
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
/// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
|
||||||
//
|
/// it's easy to expand whitelist as needed
|
||||||
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
/// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
|
||||||
// full list of characters which must be escaped:
|
///
|
||||||
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
/// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
|
||||||
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
|
/// full list of characters which must be escaped:
|
||||||
private static string sanitize(string input)
|
/// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
||||||
|
/// </summary>
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[^\w\d\s_]")]
|
||||||
|
private static partial Regex IllegalCharacterRegex();
|
||||||
|
private static string sanitize(string input)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
return "";
|
return "";
|
||||||
@@ -73,9 +97,9 @@ namespace DataLayer
|
|||||||
// assume a hyphen is supposed to be an underscore
|
// assume a hyphen is supposed to be an underscore
|
||||||
.Replace("-", "_");
|
.Replace("-", "_");
|
||||||
|
|
||||||
var unique = regex
|
var unique = IllegalCharacterRegex()
|
||||||
// turn illegal characters into a space. this will also take care of turning new lines into spaces
|
// turn illegal characters into a space. this will also take care of turning new lines into spaces
|
||||||
.Replace(str, " ")
|
.Replace(str, " ")
|
||||||
// split and remove excess spaces
|
// split and remove excess spaces
|
||||||
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
// de-dup
|
// de-dup
|
||||||
@@ -98,7 +122,11 @@ namespace DataLayer
|
|||||||
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
|
||||||
|
|
||||||
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
|
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
|
||||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
{
|
||||||
|
var changed = Rating.OverallRating != overallRating || Rating.PerformanceRating != performanceRating || Rating.StoryRating != storyRating;
|
||||||
|
Rating.Update(overallRating, performanceRating, storyRating);
|
||||||
|
if (changed) OnItemChanged(nameof(Rating));
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region LiberatedStatuses
|
#region LiberatedStatuses
|
||||||
@@ -146,10 +174,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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ namespace DataLayer
|
|||||||
/// <summary>True if exists and IsLiberated. Else false</summary>
|
/// <summary>True if exists and IsLiberated. Else false</summary>
|
||||||
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
|
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
|
||||||
|
|
||||||
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames());
|
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames(true));
|
||||||
public static bool HasPdf(this Book book) => book.Supplements.Any();
|
public static bool HasPdf(this Book book) => book.Supplements.Any();
|
||||||
public static string SeriesNames(this Book book)
|
public static string SeriesNames(this Book book, bool includeIndex = false)
|
||||||
{
|
{
|
||||||
if (book.SeriesLink is null)
|
if (book.SeriesLink is null)
|
||||||
return "";
|
return "";
|
||||||
@@ -28,7 +28,7 @@ namespace DataLayer
|
|||||||
// first: alphabetical by name
|
// first: alphabetical by name
|
||||||
var withNames = book.SeriesLink
|
var withNames = book.SeriesLink
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
|
||||||
.Select(s => s.Series.Name)
|
.Select(getSeriesNameString)
|
||||||
.OrderBy(a => a)
|
.OrderBy(a => a)
|
||||||
.ToList();
|
.ToList();
|
||||||
// then un-named are alpha by series id
|
// then un-named are alpha by series id
|
||||||
@@ -40,7 +40,12 @@ namespace DataLayer
|
|||||||
|
|
||||||
var all = withNames.Union(nullNames).ToList();
|
var all = withNames.Union(nullNames).ToList();
|
||||||
return string.Join(", ", all);
|
return string.Join(", ", all);
|
||||||
}
|
|
||||||
|
string getSeriesNameString(SeriesBook sb)
|
||||||
|
=> includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1"
|
||||||
|
? $"{sb.Series.Name} (#{sb.Order})"
|
||||||
|
: sb.Series.Name;
|
||||||
|
}
|
||||||
public static string[] CategoriesNames(this Book book)
|
public static string[] CategoriesNames(this Book book)
|
||||||
=> book.Category is null ? new string[0]
|
=> book.Category is null ? new string[0]
|
||||||
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
|
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
|
||||||
@@ -93,10 +98,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230201162454_AddBookLanguage")]
|
||||||
|
partial class AddBookLanguage
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("LengthInMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Locale")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureLarge")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("ContributorId");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
b.ToTable("Contributors");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ContributorId = -1,
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int?>("PdfStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Tags")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("UserDefinedItem", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<int>("UserDefinedItemBookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b2.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.HasKey("UserDefinedItemBookId");
|
||||||
|
|
||||||
|
b2.ToTable("UserDefinedItem");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("UserDefinedItemBookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
|
||||||
|
b1.Navigation("Rating");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("ContributorsLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("ContributorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("SeriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Series", "Series")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBookLanguage : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Language",
|
||||||
|
table: "Books",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Language",
|
||||||
|
table: "Books");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+410
@@ -0,0 +1,410 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230302220539_AddLastDownloadedInfo")]
|
||||||
|
partial class AddLastDownloadedInfo
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("LengthInMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Locale")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureLarge")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("ContributorId");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
b.ToTable("Contributors");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ContributorId = -1,
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<int?>("PdfStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Tags")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("UserDefinedItem", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<int>("UserDefinedItemBookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b2.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.HasKey("UserDefinedItemBookId");
|
||||||
|
|
||||||
|
b2.ToTable("UserDefinedItem");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("UserDefinedItemBookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
|
||||||
|
b1.Navigation("Rating");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("ContributorsLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("ContributorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("SeriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Series", "Series")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLastDownloadedInfo : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "LastDownloaded",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LastDownloadedVersion",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastDownloaded",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastDownloadedVersion",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DataLayer;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(LibationContext))]
|
||||||
|
[Migration("20230308013410_AddAbsentFromLastScan")]
|
||||||
|
partial class AddAbsentFromLastScan
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("LengthInMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Locale")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureLarge")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentCategoryCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
CategoryId = -1,
|
||||||
|
AudibleCategoryId = "",
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("ContributorId");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
b.ToTable("Contributors");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ContributorId = -1,
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("AbsentFromLastScan")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "Category")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<int?>("PdfStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Tags")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("UserDefinedItem", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<int>("UserDefinedItemBookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b2.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.HasKey("UserDefinedItemBookId");
|
||||||
|
|
||||||
|
b2.ToTable("UserDefinedItem");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("UserDefinedItemBookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
|
||||||
|
b1.Navigation("Rating");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Category");
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("ContributorsLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("ContributorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
b.Navigation("ParentCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("SeriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Series", "Series")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAbsentFromLastScan : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AbsentFromLastScan",
|
||||||
|
table: "LibraryBooks",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AbsentFromLastScan",
|
||||||
|
table: "LibraryBooks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.3");
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
@@ -41,6 +41,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<bool>("IsAbridged")
|
b.Property<bool>("IsAbridged")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("LengthInMinutes")
|
b.Property<int>("LengthInMinutes")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -154,12 +157,18 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<int>("BookId")
|
b.Property<int>("BookId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("AbsentFromLastScan")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Account")
|
b.Property<string>("Account")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
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");
|
||||||
@@ -266,6 +275,12 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<int>("BookStatus")
|
b1.Property<int>("BookStatus")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b1.Property<int?>("PdfStatus")
|
b1.Property<int?>("PdfStatus")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -93,8 +107,9 @@ namespace DataLayer
|
|||||||
=> bookList
|
=> bookList
|
||||||
.Where(
|
.Where(
|
||||||
lb =>
|
lb =>
|
||||||
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
!lb.AbsentFromLastScan &&
|
||||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||||
|
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ namespace DtoImporterService
|
|||||||
var productIds = importItems
|
var productIds = importItems
|
||||||
.Select(i => i.DtoItem.ProductId)
|
.Select(i => i.DtoItem.ProductId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToHashSet();
|
||||||
|
|
||||||
Cache = DbContext.Books
|
Cache = DbContext.Books
|
||||||
.GetBooks(b => productIds.Contains(b.AudibleProductId))
|
.GetBooks(b => productIds.Contains(b.AudibleProductId))
|
||||||
@@ -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
|
||||||
@@ -150,9 +152,9 @@ namespace DtoImporterService
|
|||||||
book.ReplacePublisher(publisher);
|
book.ReplacePublisher(publisher);
|
||||||
}
|
}
|
||||||
|
|
||||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
||||||
|
|
||||||
if (item.PdfUrl is not null)
|
if (item.PdfUrl is not null)
|
||||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||||
|
|
||||||
return book;
|
return book;
|
||||||
@@ -172,7 +174,15 @@ 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);
|
// 2023-02-01
|
||||||
|
// updateBook must update language on books which were imported before the migration which added language.
|
||||||
|
// Can eventually delete this
|
||||||
|
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
||||||
|
|
||||||
|
book.UpdateProductRating(
|
||||||
|
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||||
|
(float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0),
|
||||||
|
(float)(item.Rating?.StoryDistribution?.AverageRating ?? 0));
|
||||||
|
|
||||||
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
|
// 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</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ namespace DtoImporterService
|
|||||||
public Item DtoItem { get; set; }
|
public Item DtoItem { get; set; }
|
||||||
public string AccountId { get; set; }
|
public string AccountId { get; set; }
|
||||||
public string LocaleName { get; set; }
|
public string LocaleName { get; set; }
|
||||||
|
public override string ToString()
|
||||||
|
=> DtoItem is null ? base.ToString() : $"[{DtoItem.ProductId}] {DtoItem.Title}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
|
using Dinah.Core;
|
||||||
using Dinah.Core.Collections.Generic;
|
using Dinah.Core.Collections.Generic;
|
||||||
|
|
||||||
namespace DtoImporterService
|
namespace DtoImporterService
|
||||||
@@ -40,34 +41,82 @@ namespace DtoImporterService
|
|||||||
//
|
//
|
||||||
// CURRENT SOLUTION: don't re-insert
|
// CURRENT SOLUTION: don't re-insert
|
||||||
|
|
||||||
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
|
||||||
var newItems = importItems
|
|
||||||
.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
|
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
|
||||||
// just use the first
|
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
|
||||||
var hash = newItems.ToDictionarySafe(dto => dto.DtoItem.ProductId);
|
//There should never be duplicates, but this is defensive.
|
||||||
foreach (var kvp in hash)
|
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
|
||||||
|
|
||||||
|
//If importItems are contains duplicates by asin, keep the Item that's "available"
|
||||||
|
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
|
||||||
|
|
||||||
|
int qtyNew = 0;
|
||||||
|
|
||||||
|
foreach (var item in uniqueImportItems.Values)
|
||||||
{
|
{
|
||||||
var newItem = kvp.Value;
|
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
|
||||||
|
{
|
||||||
|
if (existing.Account != item.AccountId)
|
||||||
|
{
|
||||||
|
//Book is absent from the existing LibraryBook's account. Use the alternate account.
|
||||||
|
existing.SetAccount(item.AccountId);
|
||||||
|
}
|
||||||
|
|
||||||
var libraryBook = new LibraryBook(
|
existing.AbsentFromLastScan = isPlusTitleUnavailable(item);
|
||||||
bookImporter.Cache[newItem.DtoItem.ProductId],
|
|
||||||
newItem.DtoItem.DateAdded,
|
|
||||||
newItem.AccountId);
|
|
||||||
try
|
|
||||||
{
|
|
||||||
DbContext.LibraryBooks.Add(libraryBook);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
else
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
|
var libraryBook = new LibraryBook(
|
||||||
|
bookImporter.Cache[item.DtoItem.ProductId],
|
||||||
|
item.DtoItem.DateAdded,
|
||||||
|
item.AccountId)
|
||||||
|
{
|
||||||
|
AbsentFromLastScan = isPlusTitleUnavailable(item)
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DbContext.LibraryBooks.Add(libraryBook);
|
||||||
|
qtyNew++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var qtyNew = hash.Count;
|
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
|
||||||
|
|
||||||
|
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
|
||||||
|
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
|
||||||
|
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
|
||||||
|
nullBook.AbsentFromLastScan = true;
|
||||||
|
|
||||||
return qtyNew;
|
return qtyNew;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
|
||||||
|
{
|
||||||
|
var dictionary = new Dictionary<TKey, TSource>();
|
||||||
|
|
||||||
|
foreach (TSource newItem in source)
|
||||||
|
{
|
||||||
|
TKey key = keySelector(newItem);
|
||||||
|
|
||||||
|
dictionary[key]
|
||||||
|
= dictionary.TryGetValue(key, out TSource existingItem)
|
||||||
|
? tieBreaker(existingItem, newItem)
|
||||||
|
: newItem;
|
||||||
|
}
|
||||||
|
return dictionary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
|
||||||
|
=> isPlusTitleUnavailable(item1) && !isPlusTitleUnavailable(item2) ? item2 : item1;
|
||||||
|
|
||||||
|
private static bool isPlusTitleUnavailable(ImportItem item)
|
||||||
|
=> item.DtoItem.ContentType is null
|
||||||
|
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,10 +17,14 @@ namespace FileLiberator
|
|||||||
|
|
||||||
protected LameConfig GetLameOptions(Configuration config)
|
protected LameConfig GetLameOptions(Configuration config)
|
||||||
{
|
{
|
||||||
LameConfig lameConfig = new();
|
LameConfig lameConfig = new()
|
||||||
lameConfig.Mode = MPEGMode.Mono;
|
{
|
||||||
|
Mode = MPEGMode.Mono,
|
||||||
|
Quality = config.LameEncoderQuality,
|
||||||
|
OutputSampleRate = (int)config.MaxSampleRate
|
||||||
|
};
|
||||||
|
|
||||||
if (config.LameTargetBitrate)
|
if (config.LameTargetBitrate)
|
||||||
{
|
{
|
||||||
if (config.LameConstantBitrate)
|
if (config.LameConstantBitrate)
|
||||||
lameConfig.BitRate = config.LameBitrate;
|
lameConfig.BitRate = config.LameBitrate;
|
||||||
|
|||||||
@@ -25,13 +25,12 @@ namespace FileLiberator
|
|||||||
|
|
||||||
if (seriesParent is not null)
|
if (seriesParent is not null)
|
||||||
{
|
{
|
||||||
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
|
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
|
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||||
return Templates.Folder.GetFilename(libraryBook.ToDto());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ 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 readonly AaxDecrypter.AverageSpeed averageSpeed = new();
|
||||||
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,10 +42,7 @@ 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;
|
|
||||||
|
|
||||||
fileSize = m4bBook.InputStream.Length;
|
|
||||||
|
|
||||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||||
@@ -64,49 +60,63 @@ namespace FileLiberator
|
|||||||
config.LameMatchSourceBR);
|
config.LameMatchSourceBR);
|
||||||
|
|
||||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||||
var result = await m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
try
|
||||||
m4bBook.InputStream.Close();
|
|
||||||
mp3File.Close();
|
|
||||||
|
|
||||||
if (result == ConversionResult.Failed)
|
|
||||||
{
|
{
|
||||||
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;
|
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
|
||||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
|
||||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
|
||||||
|
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
|
||||||
|
|
||||||
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.FractionCompleted;
|
||||||
|
|
||||||
OnStreamingProgressChanged(
|
OnStreamingProgressChanged(
|
||||||
new DownloadProgress
|
new DownloadProgress
|
||||||
{
|
{
|
||||||
ProgressPercentage = progressPercent,
|
ProgressPercentage = progressPercent,
|
||||||
BytesReceived = (long)(fileSize * progressPercent),
|
BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds,
|
||||||
TotalBytesToReceive = fileSize
|
TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AaxDecrypter;
|
using AaxDecrypter;
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
using AudibleApi;
|
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
@@ -14,185 +13,174 @@ 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 || getFirstAudioFile(entries) == default)
|
||||||
{
|
{
|
||||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
await Task.WhenAll(
|
||||||
FileUtility.SaferDelete(tmpFile.Path);
|
entries
|
||||||
|
.Where(f => f.FileType != FileType.AAXC)
|
||||||
|
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
||||||
|
|
||||||
return abDownloader?.IsCanceled == true ?
|
return
|
||||||
new StatusHandler { "Cancelled" } :
|
abDownloader?.IsCanceled is true
|
||||||
new StatusHandler { "Decrypt failed" };
|
? new StatusHandler { "Cancelled" }
|
||||||
}
|
: new StatusHandler { "Decrypt failed" };
|
||||||
|
}
|
||||||
|
|
||||||
// moves new files from temp dir to final dest.
|
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||||
// This could take a few seconds if moving hundreds of files.
|
|
||||||
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
|
||||||
|
|
||||||
// decrypt failed
|
Task[] finalTasks = new[]
|
||||||
if (!movedAudioFile)
|
{
|
||||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
Task.Run(() => downloadCoverArt(libraryBook)),
|
||||||
|
Task.Run(() => moveFilesToBooksDir(libraryBook, entries)),
|
||||||
|
Task.Run(() => libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)),
|
||||||
|
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
||||||
|
};
|
||||||
|
|
||||||
if (Configuration.Instance.DownloadCoverArt)
|
await Task.WhenAll(finalTasks);
|
||||||
DownloadCoverArt(libraryBook);
|
|
||||||
|
|
||||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
return new StatusHandler();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
OnCompleted(libraryBook);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new StatusHandler();
|
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||||
}
|
{
|
||||||
finally
|
var config = Configuration.Instance;
|
||||||
{
|
|
||||||
OnCompleted(libraryBook);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
downloadValidation(libraryBook);
|
||||||
{
|
|
||||||
var config = Configuration.Instance;
|
|
||||||
|
|
||||||
downloadValidation(libraryBook);
|
var api = await libraryBook.GetApiAsync();
|
||||||
|
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||||
|
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||||
|
|
||||||
var api = await libraryBook.GetApiAsync();
|
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
|
||||||
|
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AaxcDownloadConvertBase converter
|
||||||
|
= config.SplitFilesByChapter ?
|
||||||
|
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
||||||
|
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||||
|
|
||||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
if (config.AllowLibationFixup)
|
||||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||||
else
|
|
||||||
{
|
|
||||||
AaxcDownloadConvertBase converter
|
|
||||||
= config.SplitFilesByChapter ?
|
|
||||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
|
||||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
|
||||||
|
|
||||||
if (config.AllowLibationFixup)
|
abDownloader = converter;
|
||||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
}
|
||||||
|
|
||||||
abDownloader = converter;
|
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||||
}
|
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||||
|
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
|
||||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
|
||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
// REAL WORK DONE HERE
|
||||||
var success = await abDownloader.RunAsync();
|
return await abDownloader.RunAsync();
|
||||||
|
}
|
||||||
|
|
||||||
return success;
|
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||||
}
|
{
|
||||||
|
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
||||||
|
|
||||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
var outputFormat
|
||||||
{
|
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
||||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
? OutputFormat.Mp3
|
||||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
: OutputFormat.M4b;
|
||||||
//These assumptions may be wrong, and only time and bug reports will tell.
|
|
||||||
|
|
||||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
long chapterStartMs
|
||||||
|
= config.StripAudibleBrandAudio
|
||||||
|
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||||
|
: 0;
|
||||||
|
|
||||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
||||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
{
|
||||||
|
AudibleKey = contentLic?.Voucher?.Key,
|
||||||
long chapterStartMs = config.StripAudibleBrandAudio ?
|
AudibleIV = contentLic?.Voucher?.Iv,
|
||||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
|
||||||
|
|
||||||
var dlOptions = new DownloadOptions
|
|
||||||
(
|
|
||||||
libraryBook,
|
|
||||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
|
||||||
Resources.USER_AGENT
|
|
||||||
)
|
|
||||||
{
|
|
||||||
AudibleKey = contentLic?.Voucher?.Key,
|
|
||||||
AudibleIV = contentLic?.Voucher?.Iv,
|
|
||||||
OutputFormat = outputFormat,
|
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),
|
LameConfig = GetLameOptions(config),
|
||||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||||
FixupFile = config.AllowLibationFixup
|
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||||
|
|
||||||
if (config.MergeOpeningAndEndCredits)
|
if (config.MergeOpeningAndEndCredits)
|
||||||
combineCredits(chapters);
|
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 -= chapterStartMs;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
Flatten Audible's new hierarchical chapters, combining children into parents.
|
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||||
|
|
||||||
@@ -267,143 +255,143 @@ namespace FileLiberator
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
||||||
{
|
{
|
||||||
List<AudibleApi.Common.Chapter> chaps = new();
|
List<AudibleApi.Common.Chapter> chaps = new();
|
||||||
|
|
||||||
foreach (var c in chapters)
|
foreach (var c in chapters)
|
||||||
{
|
{
|
||||||
if (c.Chapters is not null)
|
if (c.Chapters is 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);
|
|
||||||
|
|
||||||
var children = flattenChapters(c.Chapters);
|
|
||||||
|
|
||||||
foreach (var child in children)
|
|
||||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
|
||||||
|
|
||||||
chaps.AddRange(children);
|
|
||||||
c.Chapters = null;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
chaps.Add(c);
|
chaps.Add(c);
|
||||||
}
|
else
|
||||||
return chaps;
|
{
|
||||||
}
|
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);
|
||||||
|
|
||||||
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
var children = flattenChapters(c.Chapters);
|
||||||
{
|
|
||||||
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void downloadValidation(LibraryBook libraryBook)
|
foreach (var child in children)
|
||||||
{
|
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||||
string errorString(string field)
|
|
||||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
|
||||||
|
|
||||||
string errorTitle()
|
chaps.AddRange(children);
|
||||||
{
|
c.Chapters = null;
|
||||||
var title
|
}
|
||||||
= (libraryBook.Book.Title.Length > 53)
|
}
|
||||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
return chaps;
|
||||||
: libraryBook.Book.Title;
|
}
|
||||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
|
||||||
return errorBookTitle;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
||||||
throw new Exception(errorString("Account"));
|
{
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
private static void downloadValidation(LibraryBook libraryBook)
|
||||||
throw new Exception(errorString("Locale"));
|
{
|
||||||
}
|
string errorString(string field)
|
||||||
|
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||||
|
|
||||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
string errorTitle()
|
||||||
{
|
{
|
||||||
if (e is not null)
|
var title
|
||||||
OnCoverImageDiscovered(e);
|
= (libraryBook.Book.Title.Length > 53)
|
||||||
else if (Configuration.Instance.AllowLibationFixup)
|
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
: libraryBook.Book.Title;
|
||||||
}
|
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||||
|
return errorBookTitle;
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>Move new files to 'Books' directory</summary>
|
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
throw new Exception(errorString("Account"));
|
||||||
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
|
||||||
{
|
|
||||||
// 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);
|
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||||
|
throw new Exception(errorString("Locale"));
|
||||||
|
}
|
||||||
|
|
||||||
if (getFirstAudio() == default)
|
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||||
return false;
|
{
|
||||||
|
if (e is not null)
|
||||||
|
OnCoverImageDiscovered(e);
|
||||||
|
else if (Configuration.Instance.AllowLibationFixup)
|
||||||
|
abDownloader.SetCoverArt(OnRequestCoverArt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Move new files to 'Books' directory</summary>
|
||||||
|
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||||
|
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||||
|
{
|
||||||
|
// create final directory. move each file into it
|
||||||
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
|
|
||||||
for (var i = 0; i < entries.Count; i++)
|
for (var i = 0; i < entries.Count; i++)
|
||||||
{
|
{
|
||||||
var entry = entries[i];
|
var entry = entries[i];
|
||||||
|
|
||||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||||
|
|
||||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
// 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 };
|
entries[i] = entry with { Path = realDest };
|
||||||
}
|
}
|
||||||
|
|
||||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||||
if (cue != default)
|
if (cue != default)
|
||||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
||||||
|
|
||||||
AudibleFileStorage.Audio.Refresh();
|
AudibleFileStorage.Audio.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||||
}
|
{
|
||||||
|
|
||||||
private void DownloadCoverArt(LibraryBook libraryBook)
|
|
||||||
{
|
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||||
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
if (!Directory.Exists(destinationDir))
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
Directory.CreateDirectory(destinationDir);
|
||||||
|
return destinationDir;
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(coverPath))
|
|
||||||
FileUtility.SaferDelete(coverPath);
|
|
||||||
|
|
||||||
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
|
|
||||||
(libraryBook.Book.PictureId, PictureSize.Native) :
|
|
||||||
(libraryBook.Book.PictureLarge, PictureSize.Native);
|
|
||||||
|
|
||||||
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
|
|
||||||
|
|
||||||
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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
||||||
|
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||||
|
|
||||||
|
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||||
|
|
||||||
|
var coverPath = "[null]";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var destinationDir = 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,45 +3,94 @@ using AAXClean;
|
|||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
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 AudibleKey { get; init; }
|
||||||
public string AudibleIV { get; init; }
|
public string AudibleIV { get; init; }
|
||||||
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
|
public TimeSpan RuntimeLength { get; init; }
|
||||||
public bool TrimOutputToChapterLength { get; init; }
|
public OutputFormat OutputFormat { get; init; }
|
||||||
public bool RetainEncryptedFile { get; init; }
|
public ChapterInfo ChapterInfo { get; init; }
|
||||||
public bool StripUnabridged { get; init; }
|
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||||
public bool CreateCueSheet { get; init; }
|
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||||
public ChapterInfo ChapterInfo { get; init; }
|
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
||||||
public bool FixupFile { get; init; }
|
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
|
||||||
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
public bool CreateCueSheet => config.CreateCueSheet;
|
||||||
public bool Downsample { get; init; }
|
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks;
|
||||||
public bool MatchSourceBitrate { get; init; }
|
public long DownloadSpeedBps => config.DownloadSpeedLimit;
|
||||||
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
|
public bool RetainEncryptedFile => config.RetainAaxFile;
|
||||||
|
public bool FixupFile => config.AllowLibationFixup;
|
||||||
|
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
|
||||||
|
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
|
||||||
|
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
||||||
|
|
||||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||||
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
|
{
|
||||||
|
var baseDir = Path.GetDirectoryName(props.OutputFileName);
|
||||||
|
var extension = Path.GetExtension(props.OutputFileName);
|
||||||
|
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension);
|
||||||
|
}
|
||||||
|
|
||||||
public string GetMultipartTitleName(MultiConvertFileProperties props)
|
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||||
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
|
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
|
||||||
|
|
||||||
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
|
||||||
{
|
{
|
||||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
if (DownloadClipsBookmarks)
|
||||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
{
|
||||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
var format = config.ClipsBookmarksFileFormat;
|
||||||
|
|
||||||
LibraryBookDto = LibraryBook.ToDto();
|
var formatExtension = format.ToString().ToLowerInvariant();
|
||||||
|
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
||||||
|
|
||||||
// no null/empty check for key/iv. unencrypted files do not have them
|
var api = await LibraryBook.GetApiAsync();
|
||||||
}
|
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
|
||||||
}
|
|
||||||
|
switch(format)
|
||||||
|
{
|
||||||
|
case Configuration.ClipBookmarkFormat.CSV:
|
||||||
|
RecordExporter.ToCsv(filePath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Xlsx:
|
||||||
|
RecordExporter.ToXlsx(filePath, records);
|
||||||
|
break;
|
||||||
|
case Configuration.ClipBookmarkFormat.Json:
|
||||||
|
RecordExporter.ToJson(filePath, LibraryBook, records);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Configuration config;
|
||||||
|
private readonly IDisposable cancellation;
|
||||||
|
public void Dispose() => cancellation?.Dispose();
|
||||||
|
|
||||||
|
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
|
||||||
|
{
|
||||||
|
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||||
|
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||||
|
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||||
|
// no null/empty check for key/iv. unencrypted files do not have them
|
||||||
|
|
||||||
|
LibraryBookDto = LibraryBook.ToDto();
|
||||||
|
|
||||||
|
cancellation =
|
||||||
|
config
|
||||||
|
.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</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ namespace FileLiberator
|
|||||||
= (await ProcessAsync(libraryBook))
|
= (await ProcessAsync(libraryBook))
|
||||||
?? new StatusHandler { "Processable should never return a null status" };
|
?? new StatusHandler { "Processable should never return a null status" };
|
||||||
|
|
||||||
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,21 +27,26 @@ 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 = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
||||||
|
IsPodcast = libraryBook.Book.IsEpisodeChild(),
|
||||||
|
|
||||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||||
|
Language = libraryBook.Book.Language
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]
|
|
||||||
@@ -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="5.1.0.1" />
|
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
|
||||||
<PackageReference Include="Polly" Version="7.2.3" />
|
<PackageReference Include="Polly" Version="7.2.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace FileManager
|
|
||||||
{
|
|
||||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
|
||||||
public class FileNamingTemplate : NamingTemplate
|
|
||||||
{
|
|
||||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
|
||||||
public FileNamingTemplate(string template) : base(template) { }
|
|
||||||
|
|
||||||
/// <summary>Generate a valid path for this file or directory</summary>
|
|
||||||
public LongPath GetFilePath(ReplacementCharacters replacements, bool returnFirstExisting = false)
|
|
||||||
{
|
|
||||||
string fileName =
|
|
||||||
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
|
|
||||||
FileUtility.RemoveLastCharacter(Template) :
|
|
||||||
Template;
|
|
||||||
|
|
||||||
List<string> pathParts = new();
|
|
||||||
|
|
||||||
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, replacements));
|
|
||||||
|
|
||||||
while (!string.IsNullOrEmpty(fileName))
|
|
||||||
{
|
|
||||||
var file = Path.GetFileName(fileName);
|
|
||||||
|
|
||||||
if (Path.IsPathRooted(Template) && file == string.Empty)
|
|
||||||
{
|
|
||||||
pathParts.Add(fileName);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
pathParts.Add(file);
|
|
||||||
fileName = Path.GetDirectoryName(fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pathParts.Reverse();
|
|
||||||
var fileNamePart = pathParts[^1];
|
|
||||||
pathParts.Remove(fileNamePart);
|
|
||||||
|
|
||||||
LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray());
|
|
||||||
|
|
||||||
//If file already exists, GetValidFilename will append " (n)" to the filename.
|
|
||||||
//This could cause the filename length to exceed MaxFilenameLength, so reduce
|
|
||||||
//allowable filename length by 5 chars, allowing for up to 99 duplicates.
|
|
||||||
return FileUtility.GetValidFilename(Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - 5)), replacements, returnFirstExisting);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
|
|
||||||
{
|
|
||||||
List<StringBuilder> filenameParts = new();
|
|
||||||
//Build the filename in parts, replacing replacement parameters with
|
|
||||||
//their values, and storing the parts in a list.
|
|
||||||
while (!string.IsNullOrEmpty(filename))
|
|
||||||
{
|
|
||||||
int openIndex = filename.IndexOf('<');
|
|
||||||
int closeIndex = filename.IndexOf('>');
|
|
||||||
|
|
||||||
if (openIndex == 0 && closeIndex > 0)
|
|
||||||
{
|
|
||||||
var key = filename[..(closeIndex + 1)];
|
|
||||||
|
|
||||||
if (paramReplacements.ContainsKey(key))
|
|
||||||
filenameParts.Add(new StringBuilder(paramReplacements[key]));
|
|
||||||
else
|
|
||||||
filenameParts.Add(new StringBuilder(key));
|
|
||||||
|
|
||||||
filename = filename[(closeIndex + 1)..];
|
|
||||||
}
|
|
||||||
else if (openIndex > 0 && closeIndex > openIndex)
|
|
||||||
{
|
|
||||||
var other = filename[..openIndex];
|
|
||||||
filenameParts.Add(new StringBuilder(other));
|
|
||||||
filename = filename[openIndex..];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
filenameParts.Add(new StringBuilder(filename));
|
|
||||||
filename = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Remove 1 character from the end of the longest filename part until
|
|
||||||
//the total filename is less than max filename length
|
|
||||||
while (filenameParts.Sum(p => p.Length) > maxFilenameLength)
|
|
||||||
{
|
|
||||||
int maxLength = filenameParts.Max(p => p.Length);
|
|
||||||
var maxEntry = filenameParts.First(p => p.Length == maxLength);
|
|
||||||
|
|
||||||
maxEntry.Remove(maxLength - 1, 1);
|
|
||||||
}
|
|
||||||
return string.Join("", filenameParts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string formatValue(object value, ReplacementCharacters replacements)
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
return "";
|
|
||||||
|
|
||||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
|
||||||
// Esp important for file templates.
|
|
||||||
return replacements.ReplaceInvalidFilenameChars(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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using Dinah.Core;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace FileManager
|
||||||
|
{
|
||||||
|
public sealed class LogArchiver : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public Encoding Encoding { get; set; }
|
||||||
|
public string FileName { get; }
|
||||||
|
private readonly ZipArchive archive;
|
||||||
|
|
||||||
|
public LogArchiver(string filename) : this(filename, Encoding.UTF8) { }
|
||||||
|
public LogArchiver(string filename, Encoding encoding)
|
||||||
|
{
|
||||||
|
FileName = ArgumentValidator.EnsureNotNull(filename, nameof(filename));
|
||||||
|
Encoding = ArgumentValidator.EnsureNotNull(encoding, nameof(encoding));
|
||||||
|
archive = new ZipArchive(File.Open(FileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite), ZipArchiveMode.Update, false, Encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteOlderThan(DateTime cutoffDate)
|
||||||
|
=> DeleteEntries(archive.Entries.Where(e => e.LastWriteTime < cutoffDate).ToList());
|
||||||
|
|
||||||
|
public void DeleteOldestN(int quantity)
|
||||||
|
=> DeleteEntries(archive.Entries.OrderBy(e => e.LastWriteTime).Take(quantity).ToList());
|
||||||
|
|
||||||
|
public void DeleteAllButNewestN(int quantity)
|
||||||
|
=> DeleteEntries(archive.Entries.OrderByDescending(e => e.LastWriteTime).Skip(quantity).ToList());
|
||||||
|
|
||||||
|
private void DeleteEntries(List<ZipArchiveEntry> entries)
|
||||||
|
{
|
||||||
|
foreach (var e in entries)
|
||||||
|
e.Delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddFileAsync(string name, JObject contents, string comment = null)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
|
||||||
|
await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddFileAsync(string name, string contents, string comment = null)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
|
||||||
|
await AddFileAsync(name, Encoding.GetBytes(contents), comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string comment = null)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(name, nameof(name));
|
||||||
|
|
||||||
|
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
|
||||||
|
return Task.Run(() => AddfileInternal(name, contents.Span, comment));
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly object lockObj = new();
|
||||||
|
private void AddfileInternal(string name, ReadOnlySpan<byte> contents, string comment)
|
||||||
|
{
|
||||||
|
lock (lockObj)
|
||||||
|
{
|
||||||
|
var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize);
|
||||||
|
|
||||||
|
entry.Comment = comment;
|
||||||
|
using var entryStream = entry.Open();
|
||||||
|
entryStream.Write(contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,38 +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;
|
||||||
private const int MAX_PATH = 260;
|
public static readonly int MaxPathLength;
|
||||||
private const string LONG_PATH_PREFIX = "\\\\?\\";
|
private const int WIN_MAX_PATH = 260;
|
||||||
|
private const string WIN_LONG_PATH_PREFIX = @"\\?\";
|
||||||
public string Path { get; init; }
|
internal static readonly bool IsWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||||
public override string ToString() => Path;
|
internal static readonly bool IsLinux = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||||
|
internal static readonly bool IsOSX = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||||
private static readonly PlatformID PlatformID = Environment.OSVersion.Platform;
|
|
||||||
|
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
|
static LongPath()
|
||||||
|
{
|
||||||
|
if (IsWindows)
|
||||||
|
{
|
||||||
|
MaxPathLength = short.MaxValue;
|
||||||
|
MaxDirectoryLength = MaxPathLength - 13;
|
||||||
|
}
|
||||||
|
else if (IsOSX)
|
||||||
|
{
|
||||||
|
MaxPathLength = 1024;
|
||||||
|
MaxDirectoryLength = MaxPathLength - MaxFilenameLength;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
MaxPathLength = 4096;
|
||||||
|
MaxDirectoryLength = MaxPathLength - MaxFilenameLength;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConstructor]
|
||||||
|
private LongPath(string 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(string filename)
|
||||||
|
=> IsWindows ? filename.Length
|
||||||
|
: Encoding.UTF8.GetByteCount(filename);
|
||||||
|
|
||||||
public static implicit operator LongPath(string path)
|
public static implicit operator LongPath(string path)
|
||||||
{
|
{
|
||||||
if (PlatformID is PlatformID.Unix) return new LongPath { Path = path };
|
|
||||||
|
|
||||||
if (path is null) return null;
|
if (path is null) return null;
|
||||||
|
|
||||||
|
if (!IsWindows) return new LongPath(path);
|
||||||
|
|
||||||
//File I/O functions in the Windows API convert "/" to "\" as part of converting
|
//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
|
||||||
@@ -50,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +100,7 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (PlatformID is PlatformID.Unix) return Path;
|
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
|
||||||
@@ -103,7 +140,7 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (PlatformID is PlatformID.Unix) return Path;
|
if (!IsWindows) return Path;
|
||||||
if (Path is null) return null;
|
if (Path is null) return null;
|
||||||
|
|
||||||
StringBuilder longPathBuffer = new(MaxPathLength);
|
StringBuilder longPathBuffer = new(MaxPathLength);
|
||||||
@@ -117,13 +154,16 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (PlatformID is PlatformID.Unix) return Path;
|
if (!IsWindows) return Path;
|
||||||
return
|
return
|
||||||
Path?.StartsWith(LONG_PATH_PREFIX) == true ? Path.Remove(0, LONG_PATH_PREFIX.Length)
|
Path?.StartsWith(WIN_LONG_PATH_PREFIX) == true ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
|
||||||
:Path;
|
:Path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Path;
|
||||||
|
|
||||||
|
|
||||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||||
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);
|
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace FileManager
|
|
||||||
{
|
|
||||||
public class MetadataNamingTemplate : NamingTemplate
|
|
||||||
{
|
|
||||||
public MetadataNamingTemplate(string template) : base(template) { }
|
|
||||||
|
|
||||||
public string GetTagContents()
|
|
||||||
{
|
|
||||||
var tagValue = Template;
|
|
||||||
|
|
||||||
foreach (var r in ParameterReplacements)
|
|
||||||
tagValue = tagValue.Replace($"<{formatKey(r.Key)}>", r.Value?.ToString() ?? "");
|
|
||||||
|
|
||||||
return tagValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using Dinah.Core;
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace FileManager
|
|
||||||
{
|
|
||||||
public class NamingTemplate
|
|
||||||
{
|
|
||||||
/// <summary>Proposed full name. May contain optional html-styled template tags. Eg: <name></summary>
|
|
||||||
public string Template { get; }
|
|
||||||
|
|
||||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
|
||||||
public NamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
|
||||||
|
|
||||||
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary>
|
|
||||||
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
|
|
||||||
|
|
||||||
/// <summary>Convenience method</summary>
|
|
||||||
public void AddParameterReplacement(string key, object value)
|
|
||||||
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
|
|
||||||
=> ParameterReplacements.Add(key, value);
|
|
||||||
|
|
||||||
protected static string formatKey(string key)
|
|
||||||
=> key
|
|
||||||
.Replace("<", "")
|
|
||||||
.Replace(">", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
|
internal interface IClosingPropertyTag : IPropertyTag
|
||||||
|
{
|
||||||
|
/// <summary>The <see cref="Regex"/> used to match the closing <see cref="IPropertyTag.TemplateTag"/> in template strings.</summary>
|
||||||
|
public Regex NameCloseMatcher { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determine if the template string starts with <see cref="IPropertyTag.TemplateTag"/>'s closing tag signature,
|
||||||
|
/// and if it does output the matching tag's <see cref="ITemplateTag"/>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="templateString">Template string</param>
|
||||||
|
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
||||||
|
/// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param>
|
||||||
|
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
||||||
|
bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ConditionalTagCollection<TClass> : TagCollection
|
||||||
|
{
|
||||||
|
public ConditionalTagCollection(bool caseSensative = true) :base(typeof(TClass), caseSensative) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Register a conditional tag.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="propertyGetter">A Func to get the condition's <see cref="bool"/> value from <see cref="TClass"/></param>
|
||||||
|
public void Add(ITemplateTag templateTag, Func<TClass, bool> propertyGetter)
|
||||||
|
{
|
||||||
|
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
|
||||||
|
|
||||||
|
AddPropertyTag(new ConditionalTag(templateTag, Options, expr));
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ConditionalTag : TagBase, IClosingPropertyTag
|
||||||
|
{
|
||||||
|
public Regex NameCloseMatcher { get; }
|
||||||
|
|
||||||
|
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
|
||||||
|
: base(templateTag, conditionExpression)
|
||||||
|
{
|
||||||
|
NameMatcher = new Regex($"^<(!)?{templateTag.TagName}->", options);
|
||||||
|
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag)
|
||||||
|
{
|
||||||
|
var match = NameCloseMatcher.Match(templateString);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
exactName = match.Value;
|
||||||
|
propertyTag = this;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
exactName = null;
|
||||||
|
propertyTag = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ValueExpression) : ValueExpression;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
|
public interface ITemplateTag
|
||||||
|
{
|
||||||
|
string TagName { get; }
|
||||||
|
}
|
||||||
Loaded 100 of 547 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user