mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-08-07 04:42:17 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0503ee1404 | ||
|
|
303192c6c3 | ||
|
|
6e21e96aa2 | ||
|
|
d1d0a7e487 | ||
|
|
2fd8ea91e1 | ||
|
|
92ee0b2e6d | ||
|
|
f0b5ae1cdc | ||
|
|
eb659cc7d7 | ||
|
|
cdd5f229d3 | ||
|
|
29edfb7c3f | ||
|
|
c0cb454d45 | ||
|
|
970a77c9e9 | ||
|
|
3488c8e0f5 | ||
|
|
5133720cc8 | ||
|
|
4150746f45 | ||
|
|
3a95e1e72f | ||
|
|
c74e26e1af | ||
|
|
ae54c95d46 | ||
|
|
56e6bd164b | ||
|
|
9b28bdceaa | ||
|
|
b7f7d9004d | ||
|
|
1be0991e62 | ||
|
|
ff8a2e59c5 | ||
|
|
453904261b | ||
|
|
f9340db90a | ||
|
|
5cd329dd26 | ||
|
|
b2a882b79d | ||
|
|
75df78a2f7 | ||
|
|
3ad52cbecc | ||
|
|
27b2fe741c | ||
|
|
d19fe2250c | ||
|
|
d16d0c8de2 | ||
|
|
c213d5d9f6 | ||
|
|
c73a023572 | ||
|
|
67389917fd | ||
|
|
b3264d5f42 | ||
|
|
962d9b550f | ||
|
|
91ce7272ae | ||
|
|
2f64ca6856 | ||
|
|
cfe5db436c | ||
|
|
3653fc8094 | ||
|
|
662c0ec871 | ||
|
|
44d39eabdb | ||
|
|
a0550d5c97 | ||
|
|
14eaca6d45 | ||
|
|
93ccc206ef | ||
|
|
d3f0fd711e | ||
|
|
3f4604e877 | ||
|
|
c316709af8 | ||
|
|
221d5c7f1c | ||
|
|
5a86a1a27b | ||
|
|
dc5e55de68 | ||
|
|
ee37864a42 | ||
|
|
efe347667c | ||
|
|
f27a18bdbb | ||
|
|
d1834659d9 | ||
|
|
7842b521d7 | ||
|
|
0822f0229d | ||
|
|
26aee4d29d | ||
|
|
17a80a23a8 | ||
|
|
e26fc9ca62 | ||
|
|
a03ccf1143 | ||
|
|
bb8dd615db | ||
|
|
9022a2889f | ||
|
|
ef049a3b02 | ||
|
|
77409750aa | ||
|
|
1702130b01 | ||
|
|
b6d1a7e3ba | ||
|
|
2907ba5c13 | ||
|
|
6df6c79ac8 | ||
|
|
3a9ca5d827 | ||
|
|
e1e663e327 | ||
|
|
4b00d5fd84 | ||
|
|
02dbf8aad0 | ||
|
|
8326389f5c | ||
|
|
34535b3ce1 | ||
|
|
7e5366ab95 | ||
|
|
690de9bc5c | ||
|
|
c976aa2bb2 | ||
|
|
27f659285d | ||
|
|
423a5e7720 | ||
|
|
9152e12fe1 | ||
|
|
f471c53139 | ||
|
|
66d055bb90 | ||
|
|
2bbb35363a | ||
|
|
1d3687cf9e | ||
|
|
daf925157f | ||
|
|
40eec9e674 | ||
|
|
6f0782053e | ||
|
|
04ad033ba0 | ||
|
|
f12f8ba3ee | ||
|
|
cc6feb21ff | ||
|
|
c4f2ec428d | ||
|
|
59689cb647 | ||
|
|
d7f3758ebc | ||
|
|
49775b019c | ||
|
|
e55e969349 | ||
|
|
42a93bfac1 | ||
|
|
f86c77a546 | ||
|
|
88c35e2a56 | ||
|
|
b405e8b6b2 | ||
|
|
92d283187d | ||
|
|
51b8cfe71f | ||
|
|
c80da5357b | ||
|
|
736d7c4a5f | ||
|
|
f175b7592e | ||
|
|
415e6e7bc6 | ||
|
|
d6a413e8d9 | ||
|
|
3049de6246 | ||
|
|
fb9b4eb77e | ||
|
|
e65b6c76a8 | ||
|
|
167a021eb1 | ||
|
|
ff3ac2d6fd | ||
|
|
f733079a49 | ||
|
|
893d68190d | ||
|
|
97f94d8782 | ||
|
|
4b2ce0c2d1 | ||
|
|
ee00417c6f | ||
|
|
768afd8ecd | ||
|
|
32c3fa85ce | ||
|
|
6986c8f018 | ||
|
|
f69c2b1cfc | ||
|
|
b11675c36a | ||
|
|
379c2ed62d | ||
|
|
7c8489b52f | ||
|
|
c61a863edd | ||
|
|
1d54f32ef3 | ||
|
|
fabe4afd94 | ||
|
|
61efa3c0c1 | ||
|
|
fe70daf0bc | ||
|
|
34033e7947 | ||
|
|
e8c63e9a6e | ||
|
|
9315165f80 | ||
|
|
ce624399ba | ||
|
|
63e9700c4a | ||
|
|
914e574bf8 | ||
|
|
b94f9bbc15 | ||
|
|
4e34834c35 | ||
|
|
3211b2dc85 | ||
|
|
ea6adeb58f | ||
|
|
90eccbf2f6 | ||
|
|
668cd7dba8 | ||
|
|
c08b2b575c | ||
|
|
07eaa48e10 | ||
|
|
3cf5fc1d99 | ||
|
|
15ad753fa1 | ||
|
|
75b984bdb2 | ||
|
|
f586d1d59f | ||
|
|
cb91a591f0 | ||
|
|
0c0c556c6a | ||
|
|
ff63b73c09 | ||
|
|
c1d56adbd2 | ||
|
|
bcd99fd208 | ||
|
|
d1df10d060 | ||
|
|
1fa415628f | ||
|
|
a83fe9e532 | ||
|
|
f85462ffec | ||
|
|
156349c293 | ||
|
|
5976706e40 | ||
|
|
1e40180f0c | ||
|
|
7d09728e6b | ||
|
|
4899ef3007 | ||
|
|
296c2b43eb | ||
|
|
932472cb91 | ||
|
|
1bf86b05ec | ||
|
|
5d5e3a6671 | ||
|
|
9720a573c7 | ||
|
|
1cf01aa92a | ||
|
|
4df9e5abbf | ||
|
|
9243aa47e7 | ||
|
|
c69f41a2a6 | ||
|
|
27c74e52ca | ||
|
|
bfa7f5cca9 | ||
|
|
22a3dcbc1f | ||
|
|
ec9d11cf52 | ||
|
|
fbc29dfb0a | ||
|
|
03d30ff6af | ||
|
|
ecfe0dc033 | ||
|
|
f2d475a9b0 | ||
|
|
86124fc609 | ||
|
|
db2b10d2a4 | ||
|
|
83402028fd | ||
|
|
423b5312f7 | ||
|
|
3be7d8e825 | ||
|
|
29803c6ba0 | ||
|
|
bb05847b25 | ||
|
|
5219ad53e1 | ||
|
|
30aa691aae | ||
|
|
83fa73cef5 | ||
|
|
2195574422 | ||
|
|
74ce408c8b | ||
|
|
85be15b843 | ||
|
|
b4b85cd485 | ||
|
|
0093968537 | ||
|
|
1b09b1fd48 | ||
|
|
ac87d70613 | ||
|
|
a5d98364fa | ||
|
|
ca0e639a19 | ||
|
|
b0e3022988 | ||
|
|
6765c2bfa7 | ||
|
|
94d3742317 | ||
|
|
bd3e833dc1 | ||
|
|
a386ace0e6 | ||
|
|
8221d7e202 | ||
|
|
fa92946d20 | ||
|
|
6d13325c4f | ||
|
|
7a9c6720c7 | ||
|
|
697f797509 | ||
|
|
ec9854212a | ||
|
|
46f6ba1710 | ||
|
|
7347244f0a | ||
|
|
c29c4c470c | ||
|
|
ee51fd9da6 | ||
|
|
2c4705de6e | ||
|
|
b4aa220051 | ||
|
|
4ab6da132b | ||
|
|
b006429a53 | ||
|
|
54d157d244 | ||
|
|
a4dfdf80e4 | ||
|
|
d8c90bc745 | ||
|
|
46accddd2d | ||
|
|
f40ecbc07e | ||
|
|
536982cb5f | ||
|
|
ea3d96329b | ||
|
|
e87fcbb16f | ||
|
|
541cf79b6f | ||
|
|
55fa82f92e | ||
|
|
4a0c2b2180 | ||
|
|
c77fe5d561 | ||
|
|
359d082ffd | ||
|
|
017bdba404 | ||
|
|
d4bf13b3fd | ||
|
|
87b695b2de | ||
|
|
222b16113e | ||
|
|
75c07c3209 | ||
|
|
e640edee7f | ||
|
|
6c48fc1f5e | ||
|
|
e5708a382b | ||
|
|
da9cb3371f | ||
|
|
91d0f8020e | ||
|
|
156726ca95 | ||
|
|
3dad4c194b | ||
|
|
6025a7538a | ||
|
|
824f65baae | ||
|
|
9372a7318b | ||
|
|
ddd032c16d | ||
|
|
9aaf523240 | ||
|
|
8cbdeb38fa | ||
|
|
a9258a1811 | ||
|
|
0dbc42c407 | ||
|
|
2c91de1b3b | ||
|
|
607cd07b74 | ||
|
|
64d080336c | ||
|
|
fd510861c6 | ||
|
|
3fdfbb9e26 | ||
|
|
3e74898dac | ||
|
|
d6fe3013ab | ||
|
|
265794bae0 | ||
|
|
7586f7a159 | ||
|
|
5dfddfb549 | ||
|
|
98bb06378a | ||
|
|
429367d21c | ||
|
|
ea9e36fd76 | ||
|
|
fe534b335b | ||
|
|
6db3a8fbf3 | ||
|
|
48c69a1339 | ||
|
|
1ab882f327 | ||
|
|
019b110a8a | ||
|
|
9e14169e15 | ||
|
|
e08a68219d | ||
|
|
af24c6e07b | ||
|
|
e31847e669 | ||
|
|
c4f55d2ad1 | ||
|
|
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 |
No files matched your search
@@ -15,23 +15,35 @@ on:
|
|||||||
description: 'Skip running unit tests'
|
description: 'Skip running unit tests'
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
|
runs_on:
|
||||||
|
type: string
|
||||||
|
description: 'The GitHub hosted runner to use'
|
||||||
|
required: true
|
||||||
|
OS:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
The operating system targeted by the build.
|
||||||
|
|
||||||
|
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
|
||||||
|
required: true
|
||||||
|
architecture:
|
||||||
|
type: string
|
||||||
|
description: 'CPU architecture targeted by the build.'
|
||||||
|
required: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_CONFIGURATION: 'Release'
|
DOTNET_CONFIGURATION: 'Release'
|
||||||
DOTNET_VERSION: '7.0.x'
|
DOTNET_VERSION: '8.0.x'
|
||||||
RELEASE_NAME: 'chardonnay'
|
RELEASE_NAME: 'chardonnay'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
name: '${{ inputs.OS }}-${{ inputs.architecture }}'
|
||||||
strategy:
|
runs-on: ${{ inputs.runs_on }}
|
||||||
matrix:
|
|
||||||
os: [Linux, MacOS]
|
|
||||||
arch: [x64, arm64]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
env:
|
env:
|
||||||
@@ -45,62 +57,76 @@ jobs:
|
|||||||
then
|
then
|
||||||
version="${inputVersion}"
|
version="${inputVersion}"
|
||||||
else
|
else
|
||||||
version="$(grep -oP '(?<=<Version>).*(?=</Version)' ./Source/AppScaffolding/AppScaffolding.csproj)"
|
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||||
fi
|
fi
|
||||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Unit test
|
- name: Unit test
|
||||||
if: ${{ inputs.run_unit_tests }}
|
if: ${{ inputs.run_unit_tests }}
|
||||||
working-directory: ./Source
|
working-directory: ./Source
|
||||||
run: dotnet test
|
run: dotnet test
|
||||||
|
|
||||||
- name: Publish
|
- name: Publish
|
||||||
|
id: publish
|
||||||
working-directory: ./Source
|
working-directory: ./Source
|
||||||
run: |
|
run: |
|
||||||
os=${{ matrix.os }}
|
if [[ "${{ inputs.OS }}" == "MacOS" ]]
|
||||||
RUNTIME_IDENTIFIER="$(echo ${os,} | sed 's/macOS/osx/')-${{ matrix.arch }}"
|
then
|
||||||
echo "$RUNTIME_IDENTIFIER"
|
display_os="macOS"
|
||||||
|
RUNTIME_ID="osx-${{ inputs.architecture }}"
|
||||||
|
else
|
||||||
|
display_os="Linux"
|
||||||
|
RUNTIME_ID="linux-${{ inputs.architecture }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
|
||||||
|
|
||||||
|
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Runtime Identifier: $RUNTIME_ID"
|
||||||
|
echo "Output Directory: $OUTPUT"
|
||||||
|
|
||||||
dotnet publish \
|
dotnet publish \
|
||||||
LibationAvalonia/LibationAvalonia.csproj \
|
LibationAvalonia/LibationAvalonia.csproj \
|
||||||
--runtime "$RUNTIME_IDENTIFIER" \
|
--runtime $RUNTIME_ID \
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
--output $OUTPUT \
|
||||||
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
dotnet publish \
|
dotnet publish \
|
||||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj \
|
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
|
||||||
--runtime "$RUNTIME_IDENTIFIER" \
|
--runtime $RUNTIME_ID \
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
--output $OUTPUT \
|
||||||
-p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
|
||||||
dotnet publish \
|
dotnet publish \
|
||||||
LibationCli/LibationCli.csproj \
|
LibationCli/LibationCli.csproj \
|
||||||
--runtime "$RUNTIME_IDENTIFIER" \
|
--runtime $RUNTIME_ID \
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
--output $OUTPUT \
|
||||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
dotnet publish \
|
dotnet publish \
|
||||||
HangoverAvalonia/HangoverAvalonia.csproj \
|
HangoverAvalonia/HangoverAvalonia.csproj \
|
||||||
--runtime "$RUNTIME_IDENTIFIER" \
|
--runtime $RUNTIME_ID \
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
|
--output $OUTPUT \
|
||||||
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||||
|
|
||||||
- name: Build bundle
|
- name: Build bundle
|
||||||
id: bundle
|
id: bundle
|
||||||
working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }}
|
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
|
||||||
run: |
|
run: |
|
||||||
BUNDLE_DIR=$(pwd)
|
BUNDLE_DIR=$(pwd)
|
||||||
echo "Bundle dir: ${BUNDLE_DIR}"
|
echo "Bundle dir: ${BUNDLE_DIR}"
|
||||||
cd ..
|
cd ..
|
||||||
SCRIPT=../../../Scripts/Bundle_${{ matrix.os }}.sh
|
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
|
||||||
chmod +rx ${SCRIPT}
|
chmod +rx ${SCRIPT}
|
||||||
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ matrix.arch }}"
|
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
|
||||||
artifact=$(ls ./bundle)
|
artifact=$(ls ./bundle)
|
||||||
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Publish bundle
|
- name: Publish bundle
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.bundle.outputs.artifact }}
|
name: ${{ steps.bundle.outputs.artifact }}
|
||||||
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -18,10 +18,11 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_CONFIGURATION: 'Release'
|
DOTNET_CONFIGURATION: 'Release'
|
||||||
DOTNET_VERSION: '7.0.x'
|
DOTNET_VERSION: '8.0.x'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
name: '${{ matrix.os }}-${{ matrix.release_name }}'
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -34,9 +35,9 @@ jobs:
|
|||||||
release_name: classic
|
release_name: classic
|
||||||
prefix: Classic-
|
prefix: Classic-
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v4
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
env:
|
env:
|
||||||
@@ -69,11 +70,12 @@ jobs:
|
|||||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
-p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
LibationCli/LibationCli.csproj `
|
LibationCli/LibationCli.csproj `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
||||||
|
-p:DefineConstants="${{ matrix.release_name }}" `
|
||||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||||
@@ -105,9 +107,9 @@ jobs:
|
|||||||
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
||||||
|
|
||||||
- name: Publish artifact
|
- name: Publish artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.zip.outputs.artifact }}.zip
|
name: ${{ steps.zip.outputs.artifact }}.zip
|
||||||
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
retention-days: 7
|
||||||
@@ -17,14 +17,34 @@ on:
|
|||||||
default: true
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
windows:
|
windows:
|
||||||
uses: ./.github/workflows/build-windows.yml
|
uses: ./.github/workflows/build-windows.yml
|
||||||
with:
|
with:
|
||||||
version_override: ${{ inputs.version_override }}
|
version_override: ${{ inputs.version_override }}
|
||||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
OS: [Redhat, Debian]
|
||||||
|
architecture: [x64, arm64]
|
||||||
uses: ./.github/workflows/build-linux.yml
|
uses: ./.github/workflows/build-linux.yml
|
||||||
with:
|
with:
|
||||||
version_override: ${{ inputs.version_override }}
|
version_override: ${{ inputs.version_override }}
|
||||||
|
runs_on: ubuntu-latest
|
||||||
|
OS: ${{ matrix.OS }}
|
||||||
|
architecture: ${{ matrix.architecture }}
|
||||||
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
|
|
||||||
|
macos:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
architecture: [x64, arm64]
|
||||||
|
uses: ./.github/workflows/build-linux.yml
|
||||||
|
with:
|
||||||
|
version_override: ${{ inputs.version_override }}
|
||||||
|
runs_on: macos-latest
|
||||||
|
OS: MacOS
|
||||||
|
architecture: ${{ matrix.architecture }}
|
||||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
@@ -24,22 +24,22 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.docker_username }}
|
username: ${{ secrets.docker_username }}
|
||||||
password: ${{ secrets.docker_token }}
|
password: ${{ secrets.docker_token }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
||||||
|
|||||||
@@ -39,21 +39,21 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
id: release
|
id: release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
name: Libation v${{ needs.prerelease.outputs.version }}
|
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||||
body: <Put a body here>
|
body: <Put a body here>
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
- name: Upload release assets
|
- name: Upload release assets
|
||||||
uses: dwenegar/upload-release-assets@v1
|
uses: dwenegar/upload-release-assets@v2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
with:
|
with:
|
||||||
|
|||||||
+8
-6
@@ -1,8 +1,10 @@
|
|||||||
{
|
{
|
||||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
||||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm",
|
||||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
||||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
||||||
|
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm",
|
||||||
|
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
||||||
}
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
# Dockerfile
|
# Dockerfile
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 as build-env
|
||||||
|
|
||||||
COPY Source /Source
|
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
|
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
|
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
|
||||||
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/runtime:7.0
|
FROM mcr.microsoft.com/dotnet/runtime:8.0
|
||||||
|
|
||||||
ENV SLEEP_TIME "30m"
|
ENV SLEEP_TIME "30m"
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
|||||||
|
|
||||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
* 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.
|
||||||
|
|
||||||
|
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
|
||||||
|
|
||||||
|
* Adds the `TCOM` (`@wrt` in M4B files) metadata tag for the narrators.
|
||||||
|
* Sets the `©gen` metadata tag for the genres.
|
||||||
|
* Unescapes the copyright symbol (replace `©` with `©`)
|
||||||
|
* Replaces the recording copyright `(P)` string with `℗`
|
||||||
|
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
|
||||||
|
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
|
||||||
|
|
||||||
### 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.
|
||||||
|
|||||||
+13
-1
@@ -4,12 +4,24 @@
|
|||||||
...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**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Disclaimer
|
||||||
|
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||||
|
|
||||||
### Setup
|
### 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 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:
|
In Settings.json, make the following changes:
|
||||||
* Change `Books` to `/data`
|
* Change `Books` to `/data`
|
||||||
* Change `InProgress` to `/tmp`
|
* Change `InProgress` to `/tmp` *
|
||||||
|
|
||||||
|
*You may have to paste the following at the end of your your Settings.json file if `InProgess` is not present:
|
||||||
|
|
||||||
|
```
|
||||||
|
"InProgress": "/tmp"
|
||||||
|
```
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
### Running
|
### Running
|
||||||
Once the configuration files are copied and edited, the docker image can be run with the following command.
|
Once the configuration files are copied and edited, the docker image can be run with the following command.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## [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/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Frequently Asked Questions
|
||||||
|
|
||||||
|
## Q: What's the difference between 'Classic' and 'Chardonnay'?
|
||||||
|
|
||||||
|
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
||||||
|
|
||||||
|
* **Classic** is Windows only. Its older 'grey boxes' look has a compact design which allows for more information on the screen. Notably, Classic was written using an older, more mature technology which has built-in support for screenreaders.
|
||||||
|
|
||||||
|
* **Chardonnay** is available for Windows, Mac, and Linux. Its modern design has a more open look and feel.
|
||||||
|
|
||||||
|
## Q: Now that I've downloaded my books, how can I listen to them?
|
||||||
|
|
||||||
|
**A:** You can use any app which plays m4b files (or mp3 files if you used that setting). Here are just a few ideas. Disclaimer: I have no affiliation with any of these companies:
|
||||||
|
|
||||||
|
* iOS: [BookPlayer](https://apps.apple.com/us/app/bookplayer/id1138219998)
|
||||||
|
* iOS: [Bound](https://apps.apple.com/us/app/bound-audiobook-player/id1041727137)
|
||||||
|
* Android: [Smart AudioBook Player](https://play.google.com/store/apps/details?id=ak.alizandro.smartaudiobookplayer&hl=en_US&gl=US)
|
||||||
|
* Android: [Listen](https://play.google.com/store/apps/details?id=ru.litres.android.audio&hl=en_US&gl=US)
|
||||||
|
* Desktop: [VLC](https://www.videolan.org/)
|
||||||
|
* Windows Desktop: [Audibly](https://github.com/rstewa/Audibly) -- a desktop player build specifically for audiobooks
|
||||||
|
|
||||||
|
Self-hosting online:
|
||||||
|
|
||||||
|
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
|
||||||
|
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
|
||||||
|
|
||||||
|
## Q: How do I use Libation with a South Africa account?
|
||||||
|
|
||||||
|
**A:** Like many countries, amazon gives South Africa it's own amazon site. [Unlike many other regions](https://www.audible.com/ep/country-selector) there is not South Africa specific audible site. Use `US` for your region -- ie: audible.com.
|
||||||
|
|
||||||
|
(Not exactly a *frequently* asked question but it's come up more than once)
|
||||||
@@ -33,7 +33,7 @@ Classic is Windows only. It has an older look because it's built with older, dul
|
|||||||
|
|
||||||
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.
|
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)
|
* [Linux](InstallOnLinux.md)
|
||||||
* [MacOS](InstallOnMac.md)
|
* [MacOS](InstallOnMac.md)
|
||||||
|
|
||||||
### Create Accounts
|
### Create Accounts
|
||||||
|
|||||||
@@ -4,18 +4,27 @@
|
|||||||
...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**.
|
||||||
|
|
||||||
|
|
||||||
### 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.
|
# Install and Run Libation on Ubuntu
|
||||||
|
|
||||||
Run this command in your terminal to dowbnload and install Libation, replacing the url with the Latest Libation .deb package url:
|
New Libation releases are automatically packed into .deb and .rpm package and are available from the Libation repository's releases page.
|
||||||
|
|
||||||
```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.
|
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
|
||||||
|
|
||||||
|
- Debian
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
- Redhat and CentOS
|
||||||
|
```Console
|
||||||
|
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||||
|
sudo yum install ./libation.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
If your desktop uses gtk, 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.
|
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,52 @@
|
|||||||
...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**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Run Libation on MacOS
|
# Run Libation on MacOS
|
||||||
This walkthrough should get you up and running with Libation on your Mac.
|
This walkthrough should get you up and running with Libation on your Mac.
|
||||||
|
|
||||||
|
## Supports macOS 10.15 (Catalina) and above
|
||||||
|
|
||||||
## Install Libation
|
## Install Libation
|
||||||
|
|
||||||
- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it.
|
- 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.
|
- Move the extracted Libation app bundle to your applications folder.
|
||||||
- Open a terminal (Go > Utilities > Terminal)
|
- Right-click on Libation and then click on open
|
||||||
- Copy/paste/run the following command (you'll be prompted to enter your password)
|
- The first time, it will not immediately show you an option to open it. Just dismiss the dialog and do the same thing again (right-click -> open) then you will get an option to run the unsigned application. This takes about 10 seconds.
|
||||||
|
|
||||||
|
## If this doesn't work
|
||||||
|
|
||||||
|
You can add Libation as a safe app without touching Gatekeeper.
|
||||||
|
|
||||||
|
- Copy/paste/run the following command. Adjust the file path to the Libation.app on your computer if necessary.
|
||||||
|
|
||||||
|
```Console
|
||||||
|
xattr -r -d com.apple.quarantine ~/Downloads/Libation.app
|
||||||
|
```
|
||||||
|
- Close the terminal and use Libation!
|
||||||
|
|
||||||
|
## If this still doesn't work
|
||||||
|
|
||||||
|
- Copy/paste/run the following command (you'll be prompted to enter your Mac password)
|
||||||
|
|
||||||
```Console
|
```Console
|
||||||
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
|
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!
|
|
||||||
|
* Close the terminal and use Libation!
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If Libation fails to start after completing the above steps, try the following:
|
||||||
|
|
||||||
|
1. Right-click the Libation app in your applications folder and select _Show Package Contents_
|
||||||
|
2. Open the `Contents` folder and then the `MacOS` folder.
|
||||||
|
3. Find the file named `Libation`, right-click it, and then select _Open_.
|
||||||
|
|
||||||
|
Libation _should_ launch, and you should now be able to open Libation by just double-clicking the app bundle in your applications folder.
|
||||||
|
|
||||||
|
|
||||||
## Running Hangover
|
## Running Hangover
|
||||||
|
|
||||||
@@ -25,7 +58,7 @@ Libation comes with a recovery app called Hangover. You can start it by running
|
|||||||
open /Applications/Libation.app --args hangover
|
open /Applications/Libation.app --args hangover
|
||||||
```
|
```
|
||||||
|
|
||||||
## Runnign LibationCli
|
## Running 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:
|
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
|
```Console
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
## [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/mcrackan?locale.x=en_us)
|
||||||
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Naming Templates
|
# 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.
|
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.
|
||||||
|
|
||||||
@@ -11,7 +18,7 @@ These templates apply to both GUI and CLI.
|
|||||||
- [Tag Formatters](#tag-formatters)
|
- [Tag Formatters](#tag-formatters)
|
||||||
- [Text Formatters](#text-formatters)
|
- [Text Formatters](#text-formatters)
|
||||||
- [Name List Formatters](#name-list-formatters)
|
- [Name List Formatters](#name-list-formatters)
|
||||||
- [Integer Formatters](#integer-formatters)
|
- [Number Formatters](#number-formatters)
|
||||||
- [Date Formatters](#date-formatters)
|
- [Date Formatters](#date-formatters)
|
||||||
|
|
||||||
|
|
||||||
@@ -25,29 +32,32 @@ These tags will be replaced in the template with the audiobook's values.
|
|||||||
|Tag|Description|Type|
|
|Tag|Description|Type|
|
||||||
|-|-|-|
|
|-|-|-|
|
||||||
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||||
|\<title\>|Full title|Text|
|
|\<title\>|Full title with subtitle|Text|
|
||||||
|\<title short\>|Title. Stop at first colon|Text|
|
|\<title short\>|Title. Stop at first colon|Text|
|
||||||
|
|\<audible title\>|Audible's title (does not include subtitle)|Text|
|
||||||
|
|\<audible subtitle\>|Audible's subtitle|Text|
|
||||||
|\<author\>|Author(s)|Name List|
|
|\<author\>|Author(s)|Name List|
|
||||||
|\<first author\>|First author|Text|
|
|\<first author\>|First author|Text|
|
||||||
|\<narrator\>|Narrator(s)|Name List|
|
|\<narrator\>|Narrator(s)|Name List|
|
||||||
|\<first narrator\>|First narrator|Text|
|
|\<first narrator\>|First narrator|Text|
|
||||||
|\<series\>|Name of series|Text|
|
|\<series\>|Name of series|Text|
|
||||||
|\<series#\>|Number order in series|Text|
|
|\<series#\>|Number order in series|Number|
|
||||||
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|
|\<bitrate\>|File's original bitrate (Kbps)|Number|
|
||||||
|\<samplerate\>|File's original audio sample rate|Integer|
|
|\<samplerate\>|File's original audio sample rate|Number|
|
||||||
|\<channels\>|Number of audio channels|Integer|
|
|\<channels\>|Number of audio channels|Number|
|
||||||
|\<account\>|Audible account of this book|Text|
|
|\<account\>|Audible account of this book|Text|
|
||||||
|
|\<account nickname\>|Audible account nickname of this book|Text|
|
||||||
|\<locale\>|Region/country|Text|
|
|\<locale\>|Region/country|Text|
|
||||||
|\<year\>|Year published|Integer|
|
|\<year\>|Year published|Number|
|
||||||
|\<language\>|Book's language|Text|
|
|\<language\>|Book's language|Text|
|
||||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||||
|\<file date\>|File creation date/time.|DateTime|
|
|\<file date\>|File creation date/time.|DateTime|
|
||||||
|\<pub date\>|Audiobook publication date|DateTime|
|
|\<pub date\>|Audiobook publication date|DateTime|
|
||||||
|\<date added\>|Date the book added to your Audible account|DateTime|
|
|\<date added\>|Date the book added to your Audible account|DateTime|
|
||||||
|\<ch count\> **‡**|Number of chapters|Integer|
|
|\<ch count\> **‡**|Number of chapters|Number|
|
||||||
|\<ch title\> **‡**|Chapter title|Text|
|
|\<ch title\> **‡**|Chapter title|Text|
|
||||||
|\<ch#\> **‡**|Chapter number|Integer|
|
|\<ch#\> **‡**|Chapter number|Number|
|
||||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number|
|
||||||
|
|
||||||
**†** Does not support custom formatting
|
**†** Does not support custom formatting
|
||||||
|
|
||||||
@@ -63,6 +73,9 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
|
|||||||
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|
|\<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 podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||||
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||||
|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|
||||||
|
|
||||||
|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -74,7 +87,7 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|
|||||||
|
|
||||||
|
|
||||||
# Tag Formatters
|
# 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**, **Name List**, **Number**, 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
|
## Text Formatters
|
||||||
|Formatter|Description|Example Usage|Example Result|
|
|Formatter|Description|Example Usage|Example Result|
|
||||||
@@ -85,15 +98,18 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|
|||||||
## Name List Formatters
|
## Name List Formatters
|
||||||
|Formatter|Description|Example Usage|Example Result|
|
|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|
|
|separator()|Speficy the text used to join<br>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|
|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using<br>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})`<br>`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|
|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>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|
|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||||
|
|
||||||
## Integer Formatters
|
## Number Formatters
|
||||||
|
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).
|
||||||
|Formatter|Description|Example Usage|Example Result|
|
|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|
|
|\[integer\]|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||||
|
|0|Replaces the zero with the corresponding digit if one<br>is present; otherwise, zero appears in the result string.|\<series#\[000.0\]\>|001.0|
|
||||||
|
|#|Replaces the "#" symbol with the corresponding digit if one<br> is present; otherwise, no digit appears in the result string|\<series#\[00.##\]\>|01|
|
||||||
|
|
||||||
## Date Formatters
|
## 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).
|
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).
|
||||||
|
|||||||
Binary file not shown.
File renamed without changes.
File renamed without changes.
+38
-27
@@ -1,28 +1,39 @@
|
|||||||
<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">
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 524 524" enable-background="new 0 0 524 524">
|
||||||
<path id="glass" d=
|
<defs>
|
||||||
"M139,2
|
<g id="glass">
|
||||||
A 192,200 0 0 0 103,84
|
<path fill-rule="evenodd" d=
|
||||||
A 222,334 41 0 0 241,320
|
"M262,8
|
||||||
V478
|
h-117
|
||||||
H160
|
a 192,200 0 0 0 -36,82
|
||||||
A 16,16 0 0 0 160,510
|
a 222,334 41 0 0 138,236
|
||||||
H352
|
v158
|
||||||
A16 16 0 0 0 352,478
|
h-81
|
||||||
H271
|
a 16,16 0 0 0 0,32
|
||||||
V320
|
h192
|
||||||
A 222,334 -41 0 0 409,84
|
a 16 16 0 0 0 0,-32
|
||||||
A 192,200 0 0 0 373,2
|
h-81
|
||||||
M355,32
|
v-158
|
||||||
A 192,200 0 0 1 381,127
|
a 222,334 -41 0 0 138,-236
|
||||||
A 187.5,334 -35 0 1 256,286
|
a 192,200 0 0 0 -36,-82
|
||||||
A 187.5,334 35 0 1 131,127
|
h-117
|
||||||
A 192,200 0 0 1 157,32
|
m-99,30
|
||||||
H355
|
a 192,200 0 0 0 -26,95
|
||||||
z" />
|
a 187.5,334 35 0 0 125,159
|
||||||
|
a 187.5,334 -35 0 0 125,-159
|
||||||
<path id="wine-level" d=
|
a 192,200 0 0 0 -26,-95
|
||||||
"M146,128
|
h-198
|
||||||
A 168,300 35 0 0 256,270
|
z"/>
|
||||||
A 168,300 -35 0 0 366,128
|
</g>
|
||||||
z"/>
|
<g id="wine-level">
|
||||||
|
<path d=
|
||||||
|
"M158,136
|
||||||
|
a 168,305 35 0 0 104,136
|
||||||
|
a 168,305 -35 0 0 104,-136
|
||||||
|
z"/>
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
<use href="#glass" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
|
||||||
|
<use href="#wine-level" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
|
||||||
|
<use href="#glass" fill="Black" />
|
||||||
|
<use href="#wine-level" fill="Black" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 585 B After Width: | Height: | Size: 968 B |
@@ -5,6 +5,8 @@
|
|||||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...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
|
||||||
|
|
||||||
- [Audible audiobook manager](#audible-audiobook-manager)
|
- [Audible audiobook manager](#audible-audiobook-manager)
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
- [Custom File Naming](Documentation/NamingTemplates.md)
|
- [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)
|
- [Docker](Documentation/Docker.md)
|
||||||
|
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
||||||
|
|
||||||
## Getting started
|
## Getting started
|
||||||
|
|
||||||
@@ -49,12 +52,12 @@
|
|||||||
* Customizable saved filters for common searches
|
* Customizable saved filters for common searches
|
||||||
* Open source
|
* Open source
|
||||||
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
|
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
|
||||||
|
* Fully supported in Windows, Mac, and Linux
|
||||||
|
|
||||||
<a name="theBad"/>
|
<a name="theBad"/>
|
||||||
|
|
||||||
### The bad
|
### The bad
|
||||||
|
|
||||||
* 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
|
||||||
|
|
||||||
|
|||||||
@@ -88,41 +88,27 @@ cp $FOLDER_EXEC/Libation.desktop $FOLDER_DESKTOP/Libation.desktop
|
|||||||
|
|
||||||
echo "Creating pre-install file..."
|
echo "Creating pre-install file..."
|
||||||
echo "#!/bin/bash
|
echo "#!/bin/bash
|
||||||
|
|
||||||
# Pre-install script, removes previous installation program files and sym links
|
# Pre-install script, removes previous installation program files and sym links
|
||||||
|
|
||||||
echo \"Removing previously created symlinks...\"
|
echo \"Removing previously created symlinks...\"
|
||||||
|
|
||||||
rm /usr/bin/libation
|
rm /usr/bin/libation
|
||||||
rm /usr/bin/hangover
|
rm /usr/bin/hangover
|
||||||
rm /usr/bin/libationcli
|
rm /usr/bin/libationcli
|
||||||
|
|
||||||
echo \"Removing previously installed Libation files...\"
|
echo \"Removing previously installed Libation files...\"
|
||||||
|
|
||||||
rm -r /usr/lib/libation
|
rm -r /usr/lib/libation
|
||||||
|
|
||||||
# making sure it won't stop installation
|
# making sure it won't stop installation
|
||||||
exit 0
|
exit 0
|
||||||
" >> $FOLDER_DEBIAN/preinst
|
" >> $FOLDER_DEBIAN/preinst
|
||||||
|
|
||||||
echo "Creating post-install file..."
|
echo "Creating post-install file..."
|
||||||
echo "#!/bin/bash
|
echo "#!/bin/bash
|
||||||
|
|
||||||
gtk-update-icon-cache -f /usr/share/icons/hicolor/
|
gtk-update-icon-cache -f /usr/share/icons/hicolor/
|
||||||
|
|
||||||
ln -s /usr/lib/libation/Libation /usr/bin/libation
|
ln -s /usr/lib/libation/Libation /usr/bin/libation
|
||||||
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
|
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
|
||||||
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
|
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
|
||||||
|
|
||||||
# Increase the maximum number of inotify instances
|
# Increase the maximum number of inotify instances
|
||||||
|
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
|
||||||
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
|
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
|
||||||
fi
|
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
|
" >> $FOLDER_DEBIAN/postinst
|
||||||
|
|
||||||
echo "Creating control file..."
|
echo "Creating control file..."
|
||||||
@@ -139,6 +125,11 @@ echo "Changing permissions for pre- and post-install files..."
|
|||||||
chmod +x "$FOLDER_DEBIAN/preinst"
|
chmod +x "$FOLDER_DEBIAN/preinst"
|
||||||
chmod +x "$FOLDER_DEBIAN/postinst"
|
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
|
DEB_FILE=Libation.${VERSION}-linux-chardonnay-${ARCH}.deb
|
||||||
echo "Creating $DEB_FILE"
|
echo "Creating $DEB_FILE"
|
||||||
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
|
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
|
||||||
@@ -149,4 +140,4 @@ mv $DEB_FILE ./bundle/$DEB_FILE
|
|||||||
|
|
||||||
rm -r "$BIN_DIR"
|
rm -r "$BIN_DIR"
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
@@ -65,6 +65,9 @@ if [ $? -ne 0 ]
|
|||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Make fileicon executable..."
|
||||||
|
chmod +x $BUNDLE_MACOS/fileicon
|
||||||
|
|
||||||
echo "Moving icon..."
|
echo "Moving icon..."
|
||||||
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||||
|
|
||||||
@@ -96,6 +99,9 @@ done
|
|||||||
|
|
||||||
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
|
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
|
||||||
|
|
||||||
|
echo "Signing executables in: $BUNDLE"
|
||||||
|
codesign --force --deep -s - $BUNDLE
|
||||||
|
|
||||||
echo "Creating app bundle: $APP_FILE"
|
echo "Creating app bundle: $APP_FILE"
|
||||||
tar -czvf $APP_FILE $BUNDLE
|
tar -czvf $APP_FILE $BUNDLE
|
||||||
|
|
||||||
@@ -105,4 +111,4 @@ mv $APP_FILE ./bundle/$APP_FILE
|
|||||||
|
|
||||||
rm -r $BUNDLE
|
rm -r $BUNDLE
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
BASEDIR=$(pwd)
|
||||||
|
|
||||||
|
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" == "x64" ]]
|
||||||
|
then
|
||||||
|
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
||||||
|
ARCH_RPM="x86_64"
|
||||||
|
ARCH="amd64"
|
||||||
|
else
|
||||||
|
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
||||||
|
ARCH_RPM="aarch64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
notinstalled=('libcoreclrtraceptprovider.so' 'libation_glass.svg' 'Libation.desktop')
|
||||||
|
|
||||||
|
mkdir -p ~/rpmbuild/SPECS
|
||||||
|
mkdir ~/rpmbuild/BUILD
|
||||||
|
mkdir ~/rpmbuild/RPMS
|
||||||
|
|
||||||
|
echo "Name: libation
|
||||||
|
Version: ${VERSION}
|
||||||
|
Release: 1
|
||||||
|
Summary: Liberate your Audible Library
|
||||||
|
|
||||||
|
License: GPLv3+
|
||||||
|
URL: https://github.com/rmcrackan/Libation
|
||||||
|
Source0: https://github.com/rmcrackan/Libation
|
||||||
|
|
||||||
|
Requires: bash
|
||||||
|
|
||||||
|
|
||||||
|
%define __os_install_post %{nil}
|
||||||
|
|
||||||
|
%description
|
||||||
|
Liberate your Audible Library
|
||||||
|
|
||||||
|
%install
|
||||||
|
mkdir -p %{buildroot}%{_libdir}/%{name}
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/scalable/apps
|
||||||
|
mkdir -p %{buildroot}%{_datadir}/applications
|
||||||
|
|
||||||
|
if test -f 'libcoreclrtraceptprovider.so'; then
|
||||||
|
rm 'libcoreclrtraceptprovider.so'
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
install -m 666 libation_glass.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/libation.svg
|
||||||
|
install -m 666 Libation.desktop %{buildroot}%{_datadir}/applications/Libation.desktop
|
||||||
|
|
||||||
|
rm libation_glass.svg
|
||||||
|
rm Libation.desktop
|
||||||
|
|
||||||
|
install * %{buildroot}%{_libdir}/%{name}/
|
||||||
|
|
||||||
|
%post
|
||||||
|
|
||||||
|
if [ \$1 -eq 1 ] ; then
|
||||||
|
# Initial installation
|
||||||
|
|
||||||
|
ln -s %{_libdir}/%{name}/Libation %{_bindir}/libation
|
||||||
|
ln -s %{_libdir}/%{name}/Hangover %{_bindir}/hangover
|
||||||
|
ln -s %{_libdir}/%{name}/LibationCli %{_bindir}/libationcli
|
||||||
|
|
||||||
|
gtk-update-icon-cache -f %{_datadir}/icons/hicolor/
|
||||||
|
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
|
||||||
|
%postun
|
||||||
|
if [ \$1 -eq 0 ] ; then
|
||||||
|
# Uninstall
|
||||||
|
rm %{_bindir}/libation
|
||||||
|
rm %{_bindir}/hangover
|
||||||
|
rm %{_bindir}/libationcli
|
||||||
|
fi
|
||||||
|
|
||||||
|
%files
|
||||||
|
%{_datadir}/icons/hicolor/scalable/apps/libation.svg
|
||||||
|
%{_datadir}/applications/Libation.desktop" >> ~/rpmbuild/SPECS/libation.spec
|
||||||
|
|
||||||
|
|
||||||
|
cd "$BIN_DIR"
|
||||||
|
|
||||||
|
for f in *; do
|
||||||
|
if [[ " ${delfiles[*]} " =~ " ${f} " ]]; then
|
||||||
|
echo "Deleting $f"
|
||||||
|
elif [[ ! " ${notinstalled[*]} " =~ " ${f} " ]]; then
|
||||||
|
echo "%{_libdir}/%{name}/${f}" >> ~/rpmbuild/SPECS/libation.spec
|
||||||
|
cp $f ~/rpmbuild/BUILD/
|
||||||
|
else
|
||||||
|
cp $f ~/rpmbuild/BUILD/
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cd ~/rpmbuild/SPECS/
|
||||||
|
rpmbuild -bb --target $ARCH_RPM libation.spec
|
||||||
|
|
||||||
|
cd $BASEDIR
|
||||||
|
RPM_FILE=$(ls ~/rpmbuild/RPMS/${ARCH_RPM})
|
||||||
|
|
||||||
|
mkdir bundle
|
||||||
|
|
||||||
|
mv ~/rpmbuild/RPMS/${ARCH_RPM}/$RPM_FILE "./bundle/Libation.${VERSION}-linux-chardonnay-${ARCH}.rpm"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.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="1.0.0" />
|
<PackageReference Include="AAXClean.Codecs" Version="1.1.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ namespace AaxDecrypter
|
|||||||
protected bool Step_GetMetadata()
|
protected bool Step_GetMetadata()
|
||||||
{
|
{
|
||||||
AaxFile = new AaxFile(InputFileStream);
|
AaxFile = new AaxFile(InputFileStream);
|
||||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
|
||||||
|
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null)
|
||||||
|
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||||
|
else
|
||||||
|
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||||
|
|
||||||
if (DownloadOptions.StripUnabridged)
|
if (DownloadOptions.StripUnabridged)
|
||||||
{
|
{
|
||||||
@@ -43,20 +47,41 @@ namespace AaxDecrypter
|
|||||||
if (DownloadOptions.FixupFile)
|
if (DownloadOptions.FixupFile)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
||||||
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
|
AaxFile.AppleTags.AppleListBox.EditOrAddTag("©wrt", AaxFile.AppleTags.Narrator);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
||||||
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||||
|
|
||||||
|
//Add audiobook shelf tags
|
||||||
|
//https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
|
||||||
|
const string tagDomain = "com.pilabor.tone";
|
||||||
|
|
||||||
|
AaxFile.AppleTags.Title = DownloadOptions.Title;
|
||||||
|
|
||||||
|
if (DownloadOptions.Subtitle is string subtitle)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
|
||||||
|
|
||||||
|
if (DownloadOptions.Publisher is string publisher)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
|
||||||
|
|
||||||
|
if (DownloadOptions.Language is string language)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
|
||||||
|
|
||||||
|
if (DownloadOptions.AudibleProductId is string asin)
|
||||||
|
{
|
||||||
|
AaxFile.AppleTags.Asin = asin;
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddTag("asin", asin);
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DownloadOptions.SeriesName is string series)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
|
||||||
|
|
||||||
|
if (DownloadOptions.SeriesNumber is float part)
|
||||||
|
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
//Finishing configuring lame encoder.
|
OnInitialized();
|
||||||
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
|
||||||
MpegUtil.ConfigureLameOptions(
|
|
||||||
AaxFile,
|
|
||||||
DownloadOptions.LameConfig,
|
|
||||||
DownloadOptions.Downsample,
|
|
||||||
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]");
|
||||||
@@ -66,5 +91,7 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected virtual void OnInitialized() { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,18 @@ namespace AaxDecrypter
|
|||||||
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
//Finishing configuring lame encoder.
|
||||||
|
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||||
|
MpegUtil.ConfigureLameOptions(
|
||||||
|
AaxFile,
|
||||||
|
DownloadOptions.LameConfig,
|
||||||
|
DownloadOptions.Downsample,
|
||||||
|
DownloadOptions.MatchSourceBitrate,
|
||||||
|
chapters: null);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
|
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using AAXClean.Codecs;
|
|||||||
using Dinah.Core.Net.Http;
|
using Dinah.Core.Net.Http;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -26,6 +25,18 @@ namespace AaxDecrypter
|
|||||||
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
//Finishing configuring lame encoder.
|
||||||
|
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
|
||||||
|
MpegUtil.ConfigureLameOptions(
|
||||||
|
AaxFile,
|
||||||
|
DownloadOptions.LameConfig,
|
||||||
|
DownloadOptions.Downsample,
|
||||||
|
DownloadOptions.MatchSourceBitrate,
|
||||||
|
DownloadOptions.ChapterInfo);
|
||||||
|
}
|
||||||
|
|
||||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
FileUtility.SaferDelete(OutputFileName);
|
FileUtility.SaferDelete(OutputFileName);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Dinah.Core.Net.Http;
|
|||||||
using Dinah.Core.StepRunner;
|
using Dinah.Core.StepRunner;
|
||||||
using FileManager;
|
using FileManager;
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
@@ -119,11 +118,7 @@ namespace AaxDecrypter
|
|||||||
public abstract Task CancelAsync();
|
public abstract Task CancelAsync();
|
||||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||||
|
|
||||||
public virtual void SetCoverArt(byte[] coverArt)
|
public virtual void SetCoverArt(byte[] coverArt) { }
|
||||||
{
|
|
||||||
if (coverArt is not null)
|
|
||||||
OnRetrievedCoverArt(coverArt);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void OnRetrievedTitle(string title)
|
protected void OnRetrievedTitle(string title)
|
||||||
=> RetrievedTitle?.Invoke(this, title);
|
=> RetrievedTitle?.Invoke(this, title);
|
||||||
@@ -183,7 +178,6 @@ namespace AaxDecrypter
|
|||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||||
!string.IsNullOrEmpty(DownloadOptions.AudibleIV) &&
|
|
||||||
DownloadOptions.RetainEncryptedFile)
|
DownloadOptions.RetainEncryptedFile)
|
||||||
{
|
{
|
||||||
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||||
@@ -192,7 +186,11 @@ namespace AaxDecrypter
|
|||||||
//Write aax decryption key
|
//Write aax decryption key
|
||||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||||
FileUtility.SaferDelete(keyPath);
|
FileUtility.SaferDelete(keyPath);
|
||||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
|
||||||
|
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
|
||||||
|
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
||||||
|
else
|
||||||
|
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||||
|
|
||||||
OnFileCreated(aaxPath);
|
OnFileCreated(aaxPath);
|
||||||
OnFileCreated(keyPath);
|
OnFileCreated(keyPath);
|
||||||
@@ -225,6 +223,7 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
|
||||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ namespace AaxDecrypter
|
|||||||
long DownloadSpeedBps { get; }
|
long DownloadSpeedBps { get; }
|
||||||
ChapterInfo ChapterInfo { get; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool FixupFile { get; }
|
bool FixupFile { get; }
|
||||||
NAudio.Lame.LameConfig LameConfig { get; }
|
string AudibleProductId { get; }
|
||||||
|
string Title { get; }
|
||||||
|
string Subtitle { get; }
|
||||||
|
string Publisher { get; }
|
||||||
|
string Language { get; }
|
||||||
|
string SeriesName { get; }
|
||||||
|
float? SeriesNumber { get; }
|
||||||
|
NAudio.Lame.LameConfig LameConfig { get; }
|
||||||
bool Downsample { get; }
|
bool Downsample { get; }
|
||||||
bool MatchSourceBitrate { get; }
|
bool MatchSourceBitrate { get; }
|
||||||
bool MoveMoovToBeginning { get; }
|
bool MoveMoovToBeginning { get; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
|
using AAXClean.Codecs;
|
||||||
using NAudio.Lame;
|
using NAudio.Lame;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
@@ -6,7 +7,13 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
public static class MpegUtil
|
public static class MpegUtil
|
||||||
{
|
{
|
||||||
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
|
private const string TagDomain = "com.pilabor.tone";
|
||||||
|
public static void ConfigureLameOptions(
|
||||||
|
Mp4File mp4File,
|
||||||
|
LameConfig lameConfig,
|
||||||
|
bool downsample,
|
||||||
|
bool matchSourceBitrate,
|
||||||
|
ChapterInfo chapters)
|
||||||
{
|
{
|
||||||
double bitrateMultiple = 1;
|
double bitrateMultiple = 1;
|
||||||
|
|
||||||
@@ -36,6 +43,27 @@ namespace AaxDecrypter
|
|||||||
else if (lameConfig.VBR == VBRMode.ABR)
|
else if (lameConfig.VBR == VBRMode.ABR)
|
||||||
lameConfig.ABRRateKbps = kbps;
|
lameConfig.ABRRateKbps = kbps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Setup metadata tags
|
||||||
|
lameConfig.ID3 = mp4File.AppleTags.ToIDTags();
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
|
||||||
|
lameConfig.ID3.Subtitle = subtitle;
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "LANGUAGE") is string lang)
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("LANGUAGE", lang);
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SERIES") is string series)
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("SERIES", series);
|
||||||
|
|
||||||
|
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("PART", part);
|
||||||
|
|
||||||
|
if (chapters?.Count > 0)
|
||||||
|
{
|
||||||
|
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
|
||||||
|
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,6 @@ namespace AaxDecrypter
|
|||||||
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
|
||||||
|
|
||||||
@@ -41,6 +40,9 @@ namespace AaxDecrypter
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
|
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public Task DownloadTask { get; private set; }
|
||||||
|
|
||||||
private long _speedLimit = 0;
|
private long _speedLimit = 0;
|
||||||
/// <summary>bytes per second</summary>
|
/// <summary>bytes per second</summary>
|
||||||
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
|
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
|
||||||
@@ -52,7 +54,6 @@ namespace AaxDecrypter
|
|||||||
private FileStream _readFile { get; }
|
private FileStream _readFile { get; }
|
||||||
private CancellationTokenSource _cancellationSource { get; } = new();
|
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||||
private EventWaitHandle _downloadedPiece { get; set; }
|
private EventWaitHandle _downloadedPiece { get; set; }
|
||||||
private Task _backgroundDownloadTask { get; set; }
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -126,9 +127,11 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
|
||||||
|
|
||||||
|
if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath))
|
||||||
|
throw new ArgumentException($"New uri to the same file must have the same file name.");
|
||||||
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 (_backgroundDownloadTask is not null)
|
if (DownloadTask 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;
|
||||||
@@ -141,7 +144,7 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||||
{
|
{
|
||||||
_backgroundDownloadTask = Task.CompletedTask;
|
DownloadTask = Task.CompletedTask;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +170,8 @@ namespace AaxDecrypter
|
|||||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||||
|
|
||||||
//Download the file in the background.
|
//Download the file in the background.
|
||||||
_backgroundDownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
|
||||||
|
DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||||
@@ -234,7 +238,6 @@ namespace AaxDecrypter
|
|||||||
_writeFile.Close();
|
_writeFile.Close();
|
||||||
_downloadedPiece.Set();
|
_downloadedPiece.Set();
|
||||||
OnUpdate();
|
OnUpdate();
|
||||||
DownloadCompleted?.Invoke(this, null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +259,7 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (_backgroundDownloadTask is null)
|
if (DownloadTask is null)
|
||||||
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
||||||
return ContentLength;
|
return ContentLength;
|
||||||
}
|
}
|
||||||
@@ -280,7 +283,7 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
public override int Read(byte[] buffer, int offset, int count)
|
public override int Read(byte[] buffer, int offset, int count)
|
||||||
{
|
{
|
||||||
if (_backgroundDownloadTask is null)
|
if (DownloadTask is null)
|
||||||
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
|
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);
|
||||||
@@ -306,7 +309,7 @@ namespace AaxDecrypter
|
|||||||
private void WaitToPosition(long requiredPosition)
|
private void WaitToPosition(long requiredPosition)
|
||||||
{
|
{
|
||||||
while (WritePosition < requiredPosition
|
while (WritePosition < requiredPosition
|
||||||
&& _backgroundDownloadTask?.IsCompleted is false
|
&& DownloadTask?.IsCompleted is false
|
||||||
&& !IsCancelled)
|
&& !IsCancelled)
|
||||||
{
|
{
|
||||||
_downloadedPiece.WaitOne(50);
|
_downloadedPiece.WaitOne(50);
|
||||||
@@ -326,7 +329,7 @@ namespace AaxDecrypter
|
|||||||
if (disposing && !disposed)
|
if (disposing && !disposed)
|
||||||
{
|
{
|
||||||
_cancellationSource.Cancel();
|
_cancellationSource.Cancel();
|
||||||
_backgroundDownloadTask?.GetAwaiter().GetResult();
|
DownloadTask?.GetAwaiter().GetResult();
|
||||||
_downloadedPiece?.Dispose();
|
_downloadedPiece?.Dispose();
|
||||||
_cancellationSource?.Dispose();
|
_cancellationSource?.Dispose();
|
||||||
_readFile.Dispose();
|
_readFile.Dispose();
|
||||||
|
|||||||
@@ -26,11 +26,7 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
TaskCompletionSource completionSource = new();
|
await InputFileStream.DownloadTask;
|
||||||
|
|
||||||
InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
|
|
||||||
|
|
||||||
await completionSource.Task;
|
|
||||||
|
|
||||||
if (IsCanceled)
|
if (IsCanceled)
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
<?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>net7.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Version>9.4.0.1</Version>
|
<Version>11.4.1.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Octokit" Version="5.0.0" />
|
<PackageReference Include="Octokit" Version="11.0.1" />
|
||||||
|
<!-- Do not remove unused Serilog.Sinks -->
|
||||||
|
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||||
@@ -18,4 +23,4 @@
|
|||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -9,9 +9,9 @@ using Dinah.Core;
|
|||||||
using Dinah.Core.IO;
|
using Dinah.Core.IO;
|
||||||
using Dinah.Core.Logging;
|
using Dinah.Core.Logging;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
{
|
{
|
||||||
@@ -43,21 +43,6 @@ namespace AppScaffolding
|
|||||||
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||||
public static Variety Variety { get; private set; }
|
public static Variety Variety { get; private set; }
|
||||||
|
|
||||||
public static void SetReleaseIdentifier(Variety varietyType)
|
|
||||||
{
|
|
||||||
Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
|
|
||||||
|
|
||||||
var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
|
|
||||||
|
|
||||||
if (Enum.IsDefined(releaseID))
|
|
||||||
ReleaseIdentifier = releaseID;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ReleaseIdentifier = ReleaseIdentifier.None;
|
|
||||||
Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppScaffolding
|
// AppScaffolding
|
||||||
private static Assembly _executingAssembly;
|
private static Assembly _executingAssembly;
|
||||||
private static Assembly ExecutingAssembly
|
private static Assembly ExecutingAssembly
|
||||||
@@ -75,13 +60,15 @@ namespace AppScaffolding
|
|||||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||||
.Max(a => a.Version);
|
.Max(a => a.Version);
|
||||||
|
|
||||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||||
public static Configuration RunPreConfigMigrations()
|
public static Configuration RunPreConfigMigrations()
|
||||||
{
|
{
|
||||||
// must occur before access to Configuration instance
|
// must occur before access to Configuration instance
|
||||||
// // outdated. kept here as an example of what belongs in this area
|
// // outdated. kept here as an example of what belongs in this area
|
||||||
// // 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 //
|
||||||
@@ -103,8 +90,14 @@ namespace AppScaffolding
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||||
public static void RunPostMigrationScaffolding(Configuration config)
|
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
||||||
{
|
{
|
||||||
|
Variety = Enum.IsDefined(variety) ? variety : Variety.None;
|
||||||
|
|
||||||
|
var releaseID = (ReleaseIdentifier)((int)variety | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
|
||||||
|
|
||||||
|
ReleaseIdentifier = Enum.IsDefined(releaseID) ? releaseID : ReleaseIdentifier.None;
|
||||||
|
|
||||||
ensureSerilogConfig(config);
|
ensureSerilogConfig(config);
|
||||||
configureLogging(config);
|
configureLogging(config);
|
||||||
logStartupState(config);
|
logStartupState(config);
|
||||||
@@ -116,18 +109,28 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
private static void ensureSerilogConfig(Configuration config)
|
private static void ensureSerilogConfig(Configuration config)
|
||||||
{
|
{
|
||||||
if (config.GetObject("Serilog") is not null)
|
if (config.GetObject("Serilog") is JObject serilog)
|
||||||
|
{
|
||||||
|
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink)
|
||||||
|
{
|
||||||
|
fileSink["Name"] = "ZipFile";
|
||||||
|
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var serilogObj = new JObject
|
var serilogObj = new JObject
|
||||||
{
|
{
|
||||||
{ "MinimumLevel", "Information" },
|
{ "MinimumLevel", "Information" },
|
||||||
{ "WriteTo", new JArray
|
{ "WriteTo", new JArray
|
||||||
{
|
{
|
||||||
|
// ABOUT SINKS
|
||||||
|
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||||
|
|
||||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
{ "Name", "File" },
|
{ "Name", "ZipFile" },
|
||||||
{ "Args",
|
{ "Args",
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
@@ -227,12 +230,20 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
// 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
|
|
||||||
|
static int fileCount(FileManager.LongPath longPath)
|
||||||
|
{
|
||||||
|
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||||
|
catch { return -1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||||
{
|
{
|
||||||
AppName = EntryAssembly.GetName().Name,
|
AppName = EntryAssembly.GetName().Name,
|
||||||
Version = BuildVersion.ToString(),
|
Version = BuildVersion.ToString(),
|
||||||
ReleaseIdentifier,
|
ReleaseIdentifier,
|
||||||
Configuration.OS,
|
Configuration.OS,
|
||||||
|
Environment.OSVersion,
|
||||||
InteropFactory.InteropFunctionsType,
|
InteropFactory.InteropFunctionsType,
|
||||||
Mode = mode,
|
Mode = mode,
|
||||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||||
@@ -242,6 +253,7 @@ 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.AutoScan,
|
||||||
config.BetaOptIn,
|
config.BetaOptIn,
|
||||||
config.UseCoverAsFolderIcon,
|
config.UseCoverAsFolderIcon,
|
||||||
config.LibationFiles,
|
config.LibationFiles,
|
||||||
@@ -250,10 +262,12 @@ namespace AppScaffolding
|
|||||||
config.InProgress,
|
config.InProgress,
|
||||||
|
|
||||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||||
DownloadsInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
|
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
|
||||||
|
|
||||||
AudibleFileStorage.DecryptInProgressDirectory,
|
AudibleFileStorage.DecryptInProgressDirectory,
|
||||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
|
||||||
|
|
||||||
|
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (InteropFactory.InteropFunctionsType is null)
|
if (InteropFactory.InteropFunctionsType is null)
|
||||||
@@ -321,7 +335,18 @@ namespace AppScaffolding
|
|||||||
//Download the release index
|
//Download the release index
|
||||||
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());
|
|
||||||
|
string regexPattern;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using NPOI.XWPF.UserModel;
|
using System;
|
||||||
using System;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AppScaffolding
|
namespace AppScaffolding
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="30.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||||
<PackageReference Include="NPOI" Version="2.6.0" />
|
<PackageReference Include="NPOI" Version="2.7.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ApplicationServices
|
|||||||
{
|
{
|
||||||
public class BulkSetDownloadStatus
|
public class BulkSetDownloadStatus
|
||||||
{
|
{
|
||||||
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
|
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
|
||||||
|
|
||||||
public int Count => actionSets.Count;
|
public int Count => actionSets.Count;
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ namespace ApplicationServices
|
|||||||
var bookExistsList = _libraryBooks
|
var bookExistsList = _libraryBooks
|
||||||
.Select(libraryBook => new
|
.Select(libraryBook => new
|
||||||
{
|
{
|
||||||
libraryBook.Book,
|
LibraryBook = libraryBook,
|
||||||
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -41,8 +41,8 @@ namespace ApplicationServices
|
|||||||
if (_setDownloaded)
|
if (_setDownloaded)
|
||||||
{
|
{
|
||||||
var books2change = bookExistsList
|
var books2change = bookExistsList
|
||||||
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
.Where(a => a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||||
.Select(a => a.Book)
|
.Select(a => a.LibraryBook)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (books2change.Any())
|
if (books2change.Any())
|
||||||
@@ -55,8 +55,8 @@ namespace ApplicationServices
|
|||||||
if (_setNotDownloaded)
|
if (_setNotDownloaded)
|
||||||
{
|
{
|
||||||
var books2change = bookExistsList
|
var books2change = bookExistsList
|
||||||
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
.Where(a => !a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||||
.Select(a => a.Book)
|
.Select(a => a.LibraryBook)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (books2change.Any())
|
if (books2change.Any())
|
||||||
@@ -72,7 +72,7 @@ namespace ApplicationServices
|
|||||||
public void Execute()
|
public void Execute()
|
||||||
{
|
{
|
||||||
foreach (var a in actionSets)
|
foreach (var a in actionSets)
|
||||||
a.Books.UpdateBookStatus(a.newStatus);
|
a.LibraryBooks.UpdateBookStatus(a.newStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AudibleApi;
|
using AudibleApi;
|
||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
|
using Dinah.Core.Logging;
|
||||||
using DtoImporterService;
|
using DtoImporterService;
|
||||||
|
using FileManager;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using static DtoImporterService.PerfLogger;
|
using static DtoImporterService.PerfLogger;
|
||||||
|
|
||||||
@@ -16,7 +20,7 @@ namespace ApplicationServices
|
|||||||
public static class LibraryCommands
|
public static class LibraryCommands
|
||||||
{
|
{
|
||||||
public static event EventHandler<int> ScanBegin;
|
public static event EventHandler<int> ScanBegin;
|
||||||
public static event EventHandler ScanEnd;
|
public static event EventHandler<int> ScanEnd;
|
||||||
|
|
||||||
public static bool Scanning { get; private set; }
|
public static bool Scanning { get; private set; }
|
||||||
private static object _lock { get; } = new();
|
private static object _lock { get; } = new();
|
||||||
@@ -90,8 +94,9 @@ namespace ApplicationServices
|
|||||||
{
|
{
|
||||||
stop();
|
stop();
|
||||||
var putBreakPointHere = logOutput;
|
var putBreakPointHere = logOutput;
|
||||||
ScanEnd?.Invoke(null, null);
|
ScanEnd?.Invoke(null, 0);
|
||||||
}
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region FULL LIBRARY scan and import
|
#region FULL LIBRARY scan and import
|
||||||
@@ -102,7 +107,8 @@ namespace ApplicationServices
|
|||||||
if (accounts is null || accounts.Length == 0)
|
if (accounts is null || accounts.Length == 0)
|
||||||
return (0, 0);
|
return (0, 0);
|
||||||
|
|
||||||
try
|
int newCount = 0;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@@ -113,9 +119,16 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
var libraryOptions = new LibraryOptions
|
var libraryOptions = new LibraryOptions
|
||||||
{
|
{
|
||||||
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
|
ResponseGroups
|
||||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||||
|
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||||
|
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||||
|
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||||
|
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||||
|
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||||
|
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||||
|
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||||
};
|
};
|
||||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
@@ -128,7 +141,7 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
Log.Logger.Information("Begin long-running import");
|
Log.Logger.Information("Begin long-running import");
|
||||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||||
var newCount = await importIntoDbAsync(importItems);
|
newCount = await importIntoDbAsync(importItems);
|
||||||
logTime($"post {nameof(importIntoDbAsync)}");
|
logTime($"post {nameof(importIntoDbAsync)}");
|
||||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||||
|
|
||||||
@@ -161,29 +174,99 @@ namespace ApplicationServices
|
|||||||
{
|
{
|
||||||
stop();
|
stop();
|
||||||
var putBreakPointHere = logOutput;
|
var putBreakPointHere = logOutput;
|
||||||
ScanEnd?.Invoke(null, null);
|
ScanEnd?.Invoke(null, newCount);
|
||||||
|
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(item, "item");
|
||||||
|
ArgumentValidator.EnsureNotNull(accountId, "accountId");
|
||||||
|
ArgumentValidator.EnsureNotNull(localeName, "localeName");
|
||||||
|
|
||||||
|
var importItem = new ImportItem
|
||||||
|
{
|
||||||
|
DtoItem = item,
|
||||||
|
AccountId = accountId,
|
||||||
|
LocaleName = localeName
|
||||||
|
};
|
||||||
|
|
||||||
|
var importItems = new List<ImportItem> { importItem };
|
||||||
|
var validator = new LibraryValidator();
|
||||||
|
var exceptions = validator.Validate(importItems.Select(i => i.DtoItem));
|
||||||
|
|
||||||
|
if (exceptions?.Any() ?? false)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(new AggregateException(exceptions), "Error validating library book. {@DebugInfo}", new { item, accountId, localeName });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
|
var bookImporter = new BookImporter(context);
|
||||||
|
await Task.Run(() => bookImporter.Import(importItems));
|
||||||
|
var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId));
|
||||||
|
|
||||||
|
if (book is null)
|
||||||
|
{
|
||||||
|
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
|
||||||
|
context.LibraryBooks.Add(book);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
book.AbsentFromLastScan = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int qtyChanged = await Task.Run(() => SaveContext(context));
|
||||||
|
if (qtyChanged > 0)
|
||||||
|
await Task.Run(finalizeLibrarySizeChange);
|
||||||
|
return qtyChanged;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
||||||
{
|
{
|
||||||
var tasks = new List<Task<List<ImportItem>>>();
|
var tasks = new List<Task<List<ImportItem>>>();
|
||||||
foreach (var account in accounts)
|
|
||||||
{
|
|
||||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
|
||||||
var apiExtended = await apiExtendedfunc(account);
|
|
||||||
|
|
||||||
// add scanAccountAsync as a TASK: do not await
|
await using LogArchiver archiver
|
||||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
|
= Log.Logger.IsDebugEnabled()
|
||||||
|
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
|
||||||
|
: default;
|
||||||
|
|
||||||
|
archiver?.DeleteAllButNewestN(20);
|
||||||
|
|
||||||
|
foreach (var account in accounts)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||||
|
var apiExtended = await apiExtendedfunc(account);
|
||||||
|
|
||||||
|
// add scanAccountAsync as a TASK: do not await
|
||||||
|
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
//Catch to allow other accounts to continue scanning.
|
||||||
|
Log.Logger.Error(ex, "Failed to scan account");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// import library in parallel
|
// import library in parallel
|
||||||
var arrayOfLists = await Task.WhenAll(tasks);
|
var arrayOfLists = await Task.WhenAll(tasks);
|
||||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||||
return importItems;
|
return importItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
|
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||||
|
|
||||||
@@ -194,11 +277,44 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||||
|
|
||||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
try
|
||||||
|
{
|
||||||
|
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
||||||
|
|
||||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||||
|
|
||||||
|
await logDtoItemsAsync(dtoItems);
|
||||||
|
|
||||||
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
|
||||||
|
}
|
||||||
|
catch(ImportValidationException ex)
|
||||||
|
{
|
||||||
|
await logDtoItemsAsync(ex.Items, ex.InnerExceptions.ToArray());
|
||||||
|
//If ImportValidationException is thrown, all Dto items get logged as part of the exception
|
||||||
|
throw new AggregateException(ex.InnerExceptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception> exceptions = null)
|
||||||
|
{
|
||||||
|
if (archiver is not null)
|
||||||
|
{
|
||||||
|
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json";
|
||||||
|
var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson)));
|
||||||
|
|
||||||
|
var scanFile = new JObject
|
||||||
|
{
|
||||||
|
{ "Account", account.MaskedLogEntry },
|
||||||
|
{ "ScannedDateTime", DateTime.Now.ToString("u") },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (exceptions?.Any() is true)
|
||||||
|
scanFile.Add("Exceptions", JArray.FromObject(exceptions));
|
||||||
|
|
||||||
|
scanFile.Add("Items", items);
|
||||||
|
|
||||||
|
await archiver.AddFileAsync(fileName, scanFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||||
@@ -242,18 +358,16 @@ namespace ApplicationServices
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region remove/restore books
|
#region remove/restore books
|
||||||
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||||
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
|
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
|
||||||
private static int removeBooks(List<string> idsToRemove)
|
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (idsToRemove is null || !idsToRemove.Any())
|
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
var libBooks = context.GetLibrary_Flat_NoTracking();
|
|
||||||
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
|
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
foreach (var lb in removeLibraryBooks)
|
foreach (var lb in removeLibraryBooks)
|
||||||
@@ -275,7 +389,7 @@ namespace ApplicationServices
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
|
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -303,6 +417,31 @@ namespace ApplicationServices
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
|
context.LibraryBooks.RemoveRange(libraryBooks);
|
||||||
|
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
|
||||||
|
|
||||||
|
var qtyChanges = context.SaveChanges();
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
finalizeLibrarySizeChange();
|
||||||
|
|
||||||
|
return qtyChanges;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, "Error restoring books");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
// call this whenever books are added or removed from library
|
// call this whenever books are added or removed from library
|
||||||
@@ -314,25 +453,25 @@ namespace ApplicationServices
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
|
||||||
|
|
||||||
#region Update book details
|
#region Update book details
|
||||||
public static int UpdateUserDefinedItem(
|
public static int UpdateUserDefinedItem(
|
||||||
this Book book,
|
this LibraryBook lb,
|
||||||
string tags = null,
|
string tags = null,
|
||||||
LiberatedStatus? bookStatus = null,
|
LiberatedStatus? bookStatus = null,
|
||||||
LiberatedStatus? pdfStatus = null,
|
LiberatedStatus? pdfStatus = null,
|
||||||
Rating rating = null)
|
Rating rating = null)
|
||||||
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||||
|
|
||||||
public static int UpdateUserDefinedItem(
|
public static int UpdateUserDefinedItem(
|
||||||
this IEnumerable<Book> books,
|
this IEnumerable<LibraryBook> lb,
|
||||||
string tags = null,
|
string tags = null,
|
||||||
LiberatedStatus? bookStatus = null,
|
LiberatedStatus? bookStatus = null,
|
||||||
LiberatedStatus? pdfStatus = null,
|
LiberatedStatus? pdfStatus = null,
|
||||||
Rating rating = null)
|
Rating rating = null)
|
||||||
=> updateUserDefinedItem(
|
=> updateUserDefinedItem(
|
||||||
books,
|
lb,
|
||||||
udi => {
|
udi => {
|
||||||
// blank tags are expected. null tags are not
|
// blank tags are expected. null tags are not
|
||||||
if (tags is not null)
|
if (tags is not null)
|
||||||
@@ -346,66 +485,54 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
if (rating is not null)
|
if (rating is not null)
|
||||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion)
|
||||||
|
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
|
||||||
|
|
||||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
|
||||||
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
|
||||||
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
|
|
||||||
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
|
||||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
|
|
||||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
|
||||||
=> book.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
|
||||||
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
|
|
||||||
=> books.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
|
||||||
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
||||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||||
|
|
||||||
public static int UpdateTags(this Book book, string tags)
|
|
||||||
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
|
||||||
public static int UpdateTags(this IEnumerable<Book> books, string tags)
|
|
||||||
=> books.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
|
||||||
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
||||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||||
|
|
||||||
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||||
=> libraryBook.Book.updateUserDefinedItem(action);
|
=> libraryBook.updateUserDefinedItem(action);
|
||||||
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||||
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
|
=> libraryBooks.updateUserDefinedItem(action);
|
||||||
|
|
||||||
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
|
private static int updateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) => new[] { libraryBook }.updateUserDefinedItem(action);
|
||||||
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(action);
|
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||||
|
|
||||||
private static int updateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => new[] { book }.updateUserDefinedItem(action);
|
|
||||||
private static int updateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (books is null || !books.Any())
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
foreach (var book in books)
|
foreach (var book in libraryBooks)
|
||||||
action?.Invoke(book.UserDefinedItem);
|
action?.Invoke(book.Book.UserDefinedItem);
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
foreach (var book in books)
|
foreach (var book in libraryBooks)
|
||||||
{
|
{
|
||||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
context.Attach(book.Book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
context.Attach(book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
context.Attach(book.Book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
var qtyChanges = context.SaveChanges();
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
|
||||||
|
|
||||||
return qtyChanges;
|
return qtyChanges;
|
||||||
}
|
}
|
||||||
@@ -428,40 +555,74 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||||
|
|
||||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
|
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
|
||||||
{
|
{
|
||||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||||
public bool HasPendingBooks => PendingBooks > 0;
|
public bool HasPendingBooks => PendingBooks > 0;
|
||||||
|
|
||||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
|
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError + booksUnavailable);
|
||||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
|
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded + pdfsUnavailable);
|
||||||
}
|
|
||||||
public static LibraryStats GetCounts()
|
public string StatusString => HasPdfResults ? $"{toBookStatusString()} | {toPdfStatusString()}" : toBookStatusString();
|
||||||
|
|
||||||
|
private string toBookStatusString()
|
||||||
|
{
|
||||||
|
if (!HasBookResults) return "No books. Begin by importing your library";
|
||||||
|
|
||||||
|
if (!HasPendingBooks && booksError + booksUnavailable == 0) return $"All {"book".PluralizeWithCount(booksFullyBackedUp)} backed up";
|
||||||
|
|
||||||
|
var sb = new StringBuilder($"BACKUPS: No progress: {booksNoProgress} In process: {booksDownloadedOnly} Fully backed up: {booksFullyBackedUp}");
|
||||||
|
|
||||||
|
if (booksError > 0)
|
||||||
|
sb.Append($" Errors: {booksError}");
|
||||||
|
if (booksUnavailable > 0)
|
||||||
|
sb.Append($" Unavailable: {booksUnavailable}");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string toPdfStatusString()
|
||||||
|
{
|
||||||
|
if (pdfsNotDownloaded + pdfsUnavailable == 0) return $"All {pdfsDownloaded} PDFs downloaded";
|
||||||
|
|
||||||
|
var sb = new StringBuilder($"PDFs: NOT d/l'ed: {pdfsNotDownloaded} Downloaded: {pdfsDownloaded}");
|
||||||
|
|
||||||
|
if (pdfsUnavailable > 0)
|
||||||
|
sb.Append($" Unavailable: {pdfsUnavailable}");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
|
||||||
{
|
{
|
||||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
|
|
||||||
var results = libraryBooks
|
var results = libraryBooks
|
||||||
.AsParallel()
|
.AsParallel()
|
||||||
.Select(lb => Liberated_Status(lb.Book))
|
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
|
||||||
.ToList();
|
.ToList();
|
||||||
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
|
|
||||||
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
|
|
||||||
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
|
|
||||||
var booksError = results.Count(r => r == LiberatedStatus.Error);
|
|
||||||
|
|
||||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
|
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
|
||||||
|
var booksDownloadedOnly = results.Count(r => !r.absent && r.status == LiberatedStatus.PartialDownload);
|
||||||
|
var booksNoProgress = results.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
|
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
|
||||||
|
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
|
||||||
|
|
||||||
var boolResults = libraryBooks
|
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||||
|
|
||||||
|
var pdfResults = libraryBooks
|
||||||
.AsParallel()
|
.AsParallel()
|
||||||
.Where(lb => lb.Book.HasPdf())
|
.Where(lb => lb.Book.HasPdf())
|
||||||
.Select(lb => Pdf_Status(lb.Book))
|
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
|
||||||
.ToList();
|
.ToList();
|
||||||
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
|
|
||||||
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
|
|
||||||
|
|
||||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
|
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
|
||||||
|
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
|
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
|
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||||
|
|
||||||
|
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,9 @@ namespace ApplicationServices
|
|||||||
[Name("Title")]
|
[Name("Title")]
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
|
|
||||||
|
[Name("Subtitle")]
|
||||||
|
public string Subtitle { get; set; }
|
||||||
|
|
||||||
[Name("Authors")]
|
[Name("Authors")]
|
||||||
public string AuthorNames { get; set; }
|
public string AuthorNames { get; set; }
|
||||||
|
|
||||||
@@ -106,6 +109,15 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
[Name("Language")]
|
[Name("Language")]
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
|
||||||
|
[Name("LastDownloaded")]
|
||||||
|
public DateTime? LastDownloaded { get; set; }
|
||||||
|
|
||||||
|
[Name("LastDownloadedVersion")]
|
||||||
|
public string LastDownloadedVersion { get; set; }
|
||||||
|
|
||||||
|
[Name("IsFinished")]
|
||||||
|
public bool IsFinished { get; set; }
|
||||||
}
|
}
|
||||||
public static class LibToDtos
|
public static class LibToDtos
|
||||||
{
|
{
|
||||||
@@ -117,6 +129,7 @@ namespace ApplicationServices
|
|||||||
AudibleProductId = a.Book.AudibleProductId,
|
AudibleProductId = a.Book.AudibleProductId,
|
||||||
Locale = a.Book.Locale,
|
Locale = a.Book.Locale,
|
||||||
Title = a.Book.Title,
|
Title = a.Book.Title,
|
||||||
|
Subtitle = a.Book.Subtitle,
|
||||||
AuthorNames = a.Book.AuthorNames(),
|
AuthorNames = a.Book.AuthorNames(),
|
||||||
NarratorNames = a.Book.NarratorNames(),
|
NarratorNames = a.Book.NarratorNames(),
|
||||||
LengthInMinutes = a.Book.LengthInMinutes,
|
LengthInMinutes = a.Book.LengthInMinutes,
|
||||||
@@ -131,7 +144,7 @@ namespace ApplicationServices
|
|||||||
PictureId = a.Book.PictureId,
|
PictureId = a.Book.PictureId,
|
||||||
IsAbridged = a.Book.IsAbridged,
|
IsAbridged = a.Book.IsAbridged,
|
||||||
DatePublished = a.Book.DatePublished,
|
DatePublished = a.Book.DatePublished,
|
||||||
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "",
|
CategoriesNames = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "",
|
||||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||||
@@ -140,8 +153,11 @@ namespace ApplicationServices
|
|||||||
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
|
Language = a.Book.Language,
|
||||||
}).ToList();
|
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||||
|
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||||
|
IsFinished = a.Book.UserDefinedItem.IsFinished
|
||||||
|
}).ToList();
|
||||||
}
|
}
|
||||||
public static class LibraryExporter
|
public static class LibraryExporter
|
||||||
{
|
{
|
||||||
@@ -189,6 +205,7 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.AudibleProductId),
|
nameof(ExportDto.AudibleProductId),
|
||||||
nameof(ExportDto.Locale),
|
nameof(ExportDto.Locale),
|
||||||
nameof(ExportDto.Title),
|
nameof(ExportDto.Title),
|
||||||
|
nameof(ExportDto.Subtitle),
|
||||||
nameof(ExportDto.AuthorNames),
|
nameof(ExportDto.AuthorNames),
|
||||||
nameof(ExportDto.NarratorNames),
|
nameof(ExportDto.NarratorNames),
|
||||||
nameof(ExportDto.LengthInMinutes),
|
nameof(ExportDto.LengthInMinutes),
|
||||||
@@ -212,7 +229,10 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.PdfStatus),
|
nameof(ExportDto.PdfStatus),
|
||||||
nameof(ExportDto.ContentType),
|
nameof(ExportDto.ContentType),
|
||||||
nameof(ExportDto.AudioFormat),
|
nameof(ExportDto.AudioFormat),
|
||||||
nameof(ExportDto.Language)
|
nameof(ExportDto.Language),
|
||||||
|
nameof(ExportDto.LastDownloaded),
|
||||||
|
nameof(ExportDto.LastDownloadedVersion),
|
||||||
|
nameof(ExportDto.IsFinished)
|
||||||
};
|
};
|
||||||
var col = 0;
|
var col = 0;
|
||||||
foreach (var c in columns)
|
foreach (var c in columns)
|
||||||
@@ -238,13 +258,14 @@ 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);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||||
|
row.CreateCell(col++).SetCellValue(dto.Subtitle);
|
||||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||||
@@ -281,6 +302,16 @@ namespace ApplicationServices
|
|||||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||||
|
|
||||||
|
if (dto.LastDownloaded.HasValue)
|
||||||
|
{
|
||||||
|
dateCell = row.CreateCell(col);
|
||||||
|
dateCell.CellStyle = dateStyle;
|
||||||
|
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
||||||
|
row.CreateCell(++col).SetCellValue(dto.IsFinished);
|
||||||
|
|
||||||
rowIndex++;
|
rowIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
var recordsObj = new JObject
|
var recordsObj = new JObject
|
||||||
{
|
{
|
||||||
{ "title", libraryBook.Book.Title},
|
{ "title", libraryBook.Book.TitleWithSubtitle},
|
||||||
{ "asin", libraryBook.Book.AudibleProductId},
|
{ "asin", libraryBook.Book.AudibleProductId},
|
||||||
{ "exportTime", DateTime.Now},
|
{ "exportTime", DateTime.Now},
|
||||||
{ "records", JArray.FromObject(recordsEx) }
|
{ "records", JArray.FromObject(recordsEx) }
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ namespace ApplicationServices
|
|||||||
#region Update
|
#region Update
|
||||||
private static bool isUpdating;
|
private static bool isUpdating;
|
||||||
|
|
||||||
public static void UpdateBooks(IEnumerable<Book> books)
|
public static void UpdateBooks(IEnumerable<LibraryBook> books)
|
||||||
{
|
{
|
||||||
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
||||||
// I did not benchmark before choosing the number here
|
// I did not benchmark before choosing the number here
|
||||||
@@ -49,10 +49,10 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||||
|
|
||||||
internal static void UpdateUserDefinedItems(Book book) => performSafeCommand(e =>
|
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
||||||
{
|
{
|
||||||
e.UpdateLiberatedStatus(book);
|
e.UpdateLiberatedStatus(book);
|
||||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
e.UpdateTags(book.Book.AudibleProductId, book.Book.UserDefinedItem.Tags);
|
||||||
e.UpdateUserRatings(book);
|
e.UpdateUserRatings(book);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,205 @@ 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.Rating | CatalogOptions.ResponseGroupOptions.Media
|
||||||
|
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
|
||||||
|
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
|
||||||
|
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
|
||||||
|
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
|
||||||
|
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);
|
if (parent.PurchaseDate == default)
|
||||||
|
{
|
||||||
|
parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default);
|
||||||
|
|
||||||
children = new() { parent };
|
if (parent.PurchaseDate == default)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent);
|
||||||
|
parent.PurchaseDate = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var parentAsins = parent.Relationships
|
int lastEpNum = -1, dupeCount = 0;
|
||||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
|
||||||
.Select(p => p.Asin);
|
{
|
||||||
|
string sequence;
|
||||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
if (child.EpisodeNumber is null)
|
||||||
|
{
|
||||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||||
if (numSeriesParents != 1)
|
sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
|
||||||
{
|
|
||||||
//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
|
else
|
||||||
{
|
{
|
||||||
children = await getEpisodeChildrenAsync(parent);
|
//multipart episodes may have the same episode number
|
||||||
if (!children.Any())
|
if (child.EpisodeNumber == lastEpNum)
|
||||||
return new();
|
dupeCount++;
|
||||||
|
else
|
||||||
|
lastEpNum = child.EpisodeNumber.Value;
|
||||||
|
|
||||||
|
sequence = (lastEpNum + dupeCount).ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
//A series parent will always have exactly 1 Series
|
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||||
parent.Series = new Series[]
|
child.PurchaseDate = parent.PurchaseDate;
|
||||||
|
// parent is essentially a series
|
||||||
|
child.Series = new[]
|
||||||
{
|
{
|
||||||
new Series
|
new Series
|
||||||
{
|
{
|
||||||
Asin = parent.Asin,
|
Asin = parent.Asin,
|
||||||
Sequence = "-1",
|
Sequence = sequence,
|
||||||
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)
|
||||||
@@ -79,8 +90,10 @@ namespace AudibleUtilities
|
|||||||
var distinct = items.GetSeriesDistinct();
|
var distinct = items.GetSeriesDistinct();
|
||||||
if (distinct.Any(s => s.SeriesId is null))
|
if (distinct.Any(s => s.SeriesId is null))
|
||||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
|
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
|
||||||
if (distinct.Any(s => s.SeriesName is null))
|
|
||||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
|
//// unfortunately, a user has a series with no name
|
||||||
|
//if (distinct.Any(s => s.SeriesName is null))
|
||||||
|
// exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
|
||||||
|
|
||||||
return exceptions;
|
return exceptions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="8.0.0.1" />
|
<PackageReference Include="AudibleApi" Version="9.2.0.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;
|
||||||
@@ -42,6 +43,9 @@ namespace AudibleUtilities
|
|||||||
[JsonProperty("locale_code")]
|
[JsonProperty("locale_code")]
|
||||||
public string LocaleCode { get; private set; }
|
public string LocaleCode { get; private set; }
|
||||||
|
|
||||||
|
[JsonProperty("with_username")]
|
||||||
|
public bool WithUsername { get; private set; }
|
||||||
|
|
||||||
[JsonProperty("activation_bytes")]
|
[JsonProperty("activation_bytes")]
|
||||||
public string ActivationBytes { get; private set; }
|
public string ActivationBytes { get; private set; }
|
||||||
|
|
||||||
@@ -67,7 +71,8 @@ namespace AudibleUtilities
|
|||||||
}
|
}
|
||||||
|
|
||||||
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
|
||||||
[JsonIgnore] public Locale Locale => Localization.Get(LocaleCode);
|
[JsonIgnore]
|
||||||
|
public Locale Locale => Localization.Locales.Where(l => l.WithUsername == WithUsername).Single(l => l.CountryCode == LocaleCode);
|
||||||
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
|
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
|
||||||
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
|
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
|
||||||
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
|
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
|
||||||
@@ -176,9 +181,10 @@ namespace AudibleUtilities
|
|||||||
DevicePrivateKey = account.IdentityTokens.PrivateKey,
|
DevicePrivateKey = account.IdentityTokens.PrivateKey,
|
||||||
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
|
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
|
||||||
LocaleCode = account.Locale.CountryCode,
|
LocaleCode = account.Locale.CountryCode,
|
||||||
|
WithUsername = account.Locale.WithUsername,
|
||||||
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DataLayer.Configurations
|
||||||
|
{
|
||||||
|
internal class BookCategoryConfig : IEntityTypeConfiguration<BookCategory>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<BookCategory> entity)
|
||||||
|
{
|
||||||
|
entity.HasKey(bc => new { bc.BookId, bc.CategoryLadderId });
|
||||||
|
|
||||||
|
entity.HasIndex(bc => bc.BookId);
|
||||||
|
entity.HasIndex(bc => bc.CategoryLadderId);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.HasOne(bc => bc.Book)
|
||||||
|
.WithMany(b => b.CategoriesLink)
|
||||||
|
.HasForeignKey(bc => bc.BookId);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.HasOne(bc => bc.CategoryLadder)
|
||||||
|
.WithMany(c => c.BooksLink)
|
||||||
|
.HasForeignKey(bc => bc.CategoryLadderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,55 +20,41 @@ 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
|
entity.Ignore(nameof(Book.TitleWithSubtitle));
|
||||||
//entity.Ignore(nameof(Book.AuthorNames));
|
entity.Ignore(b => b.Categories);
|
||||||
//entity.Ignore(nameof(Book.NarratorNames));
|
|
||||||
//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
|
|
||||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
|
||||||
{
|
|
||||||
b_udi.WithOwner(udi => udi.Book)
|
|
||||||
.HasForeignKey(udi => udi.BookId);
|
|
||||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
|
||||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
|
||||||
|
|
||||||
// owns it 1:1, store in same table
|
|
||||||
b_udi.OwnsOne(udi => udi.Rating);
|
|
||||||
});
|
|
||||||
|
|
||||||
entity
|
|
||||||
.Metadata
|
|
||||||
.FindNavigation(nameof(Book.ContributorsLink))
|
|
||||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
|
||||||
|
|
||||||
entity
|
entity
|
||||||
.Metadata
|
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||||
.FindNavigation(nameof(Book.SeriesLink))
|
{
|
||||||
// PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field
|
b_udi.WithOwner(udi => udi.Book)
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
.HasForeignKey(udi => udi.BookId);
|
||||||
|
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||||
|
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||||
|
|
||||||
entity
|
b_udi.Property(udi => udi.LastDownloaded);
|
||||||
.HasOne(b => b.Category)
|
b_udi
|
||||||
.WithMany()
|
.Property(udi => udi.LastDownloadedVersion)
|
||||||
.HasForeignKey(b => b.CategoryId);
|
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
||||||
}
|
|
||||||
|
// owns it 1:1, store in same table
|
||||||
|
b_udi.OwnsOne(udi => udi.Rating);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,11 @@ 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.Ignore(c => c.CategoryLadders);
|
||||||
entity.HasData(Category.GetEmpty());
|
|
||||||
|
entity
|
||||||
|
.HasMany(e => e._categoryLadders)
|
||||||
|
.WithMany(e => e._categories);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DataLayer.Configurations
|
||||||
|
{
|
||||||
|
internal class CategoryLadderConfig : IEntityTypeConfiguration<CategoryLadder>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<CategoryLadder> entity)
|
||||||
|
{
|
||||||
|
entity.HasKey(cl => cl.CategoryLadderId);
|
||||||
|
|
||||||
|
entity.Ignore(cl => cl.Categories);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.HasMany(cl => cl._categories)
|
||||||
|
.WithMany(c => c._categoryLadders);
|
||||||
|
|
||||||
|
entity
|
||||||
|
.Metadata
|
||||||
|
.FindNavigation(nameof(CategoryLadder.BooksLink))
|
||||||
|
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -10,14 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
|
<PackageReference Include="Dinah.Core" Version="8.0.0.1" />
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="8.0.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.3">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5">
|
||||||
<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="7.0.3" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.5">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ namespace DataLayer
|
|||||||
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
|
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
|
||||||
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
|
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
|
||||||
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
|
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
|
||||||
}
|
AAX_22_32 = LC_32_22050_stereo,
|
||||||
|
AAX_22_64 = LC_64_22050_stereo,
|
||||||
|
AAX_44_64 = LC_64_44100_stereo,
|
||||||
|
AAX_44_128 = LC_128_44100_stereo
|
||||||
|
}
|
||||||
|
|
||||||
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -34,6 +35,9 @@ namespace DataLayer
|
|||||||
// immutable
|
// immutable
|
||||||
public string AudibleProductId { get; private set; }
|
public string AudibleProductId { get; private set; }
|
||||||
public string Title { get; private set; }
|
public string Title { get; private set; }
|
||||||
|
public string Subtitle { get; private set; }
|
||||||
|
private string _titleWithSubtitle;
|
||||||
|
public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}";
|
||||||
public string Description { get; private set; }
|
public string Description { get; private set; }
|
||||||
public int LengthInMinutes { get; private set; }
|
public int LengthInMinutes { get; private set; }
|
||||||
public ContentType ContentType { get; private set; }
|
public ContentType ContentType { get; private set; }
|
||||||
@@ -52,10 +56,6 @@ namespace DataLayer
|
|||||||
public DateTime? DatePublished { get; private set; }
|
public DateTime? DatePublished { get; private set; }
|
||||||
public string Language { get; private set; }
|
public string Language { get; private set; }
|
||||||
|
|
||||||
// non-null. use "empty pattern"
|
|
||||||
internal int CategoryId { get; private set; }
|
|
||||||
public Category Category { get; private set; }
|
|
||||||
|
|
||||||
// is owned, not optional 1:1
|
// is owned, not optional 1:1
|
||||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
public UserDefinedItem UserDefinedItem { get; private set; }
|
||||||
|
|
||||||
@@ -70,12 +70,12 @@ namespace DataLayer
|
|||||||
public Book(
|
public Book(
|
||||||
AudibleProductId audibleProductId,
|
AudibleProductId audibleProductId,
|
||||||
string title,
|
string title,
|
||||||
|
string subtitle,
|
||||||
string description,
|
string description,
|
||||||
int lengthInMinutes,
|
int lengthInMinutes,
|
||||||
ContentType contentType,
|
ContentType contentType,
|
||||||
IEnumerable<Contributor> authors,
|
IEnumerable<Contributor> authors,
|
||||||
IEnumerable<Contributor> narrators,
|
IEnumerable<Contributor> narrators,
|
||||||
Category category,
|
|
||||||
string localeName)
|
string localeName)
|
||||||
{
|
{
|
||||||
// validate
|
// validate
|
||||||
@@ -83,7 +83,7 @@ namespace DataLayer
|
|||||||
var productId = audibleProductId.Id;
|
var productId = audibleProductId.Id;
|
||||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
|
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
|
||||||
|
|
||||||
// assign as soon as possible. stuff below relies on this
|
// assign as soon as possible. stuff below relies on this
|
||||||
AudibleProductId = productId;
|
AudibleProductId = productId;
|
||||||
Locale = localeName;
|
Locale = localeName;
|
||||||
|
|
||||||
@@ -91,14 +91,13 @@ namespace DataLayer
|
|||||||
|
|
||||||
// non-ef-ctor init.s
|
// non-ef-ctor init.s
|
||||||
UserDefinedItem = new UserDefinedItem(this);
|
UserDefinedItem = new UserDefinedItem(this);
|
||||||
_contributorsLink = new HashSet<BookContributor>();
|
ContributorsLink = new HashSet<BookContributor>();
|
||||||
|
CategoriesLink = new HashSet<BookCategory>();
|
||||||
_seriesLink = new HashSet<SeriesBook>();
|
_seriesLink = new HashSet<SeriesBook>();
|
||||||
_supplements = new HashSet<Supplement>();
|
_supplements = new HashSet<Supplement>();
|
||||||
|
|
||||||
Category = category;
|
|
||||||
|
|
||||||
// simple assigns
|
// simple assigns
|
||||||
Title = title.Trim() ?? "";
|
UpdateTitle(title, subtitle);
|
||||||
Description = description?.Trim() ?? "";
|
Description = description?.Trim() ?? "";
|
||||||
LengthInMinutes = lengthInMinutes;
|
LengthInMinutes = lengthInMinutes;
|
||||||
ContentType = contentType;
|
ContentType = contentType;
|
||||||
@@ -106,22 +105,24 @@ namespace DataLayer
|
|||||||
// assigns with biz logic
|
// assigns with biz logic
|
||||||
ReplaceAuthors(authors);
|
ReplaceAuthors(authors);
|
||||||
ReplaceNarrators(narrators);
|
ReplaceNarrators(narrators);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region contributors, authors, narrators
|
public void UpdateTitle(string title, string subtitle)
|
||||||
// use uninitialised backing fields - this means we can detect if the collection was loaded
|
{
|
||||||
private HashSet<BookContributor> _contributorsLink;
|
Title = title?.Trim() ?? "";
|
||||||
// i'd like this to be internal but migration throws this exception when i try:
|
Subtitle = subtitle?.Trim() ?? "";
|
||||||
// Value cannot be null.
|
_titleWithSubtitle = null;
|
||||||
// Parameter name: property
|
}
|
||||||
public IEnumerable<BookContributor> ContributorsLink
|
|
||||||
=> _contributorsLink?
|
|
||||||
.OrderBy(bc => bc.Order)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
|
public void UpdateLengthInMinutes(int lengthInMinutes)
|
||||||
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
|
=> LengthInMinutes = lengthInMinutes;
|
||||||
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
|
|
||||||
|
#region contributors, authors, narrators
|
||||||
|
internal HashSet<BookContributor> ContributorsLink { get; private set; }
|
||||||
|
|
||||||
|
public IEnumerable<Contributor> Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList();
|
||||||
|
public IEnumerable<Contributor> Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList();
|
||||||
|
public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name;
|
||||||
|
|
||||||
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
|
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
|
||||||
=> replaceContributors(authors, Role.Author, context);
|
=> replaceContributors(authors, Role.Author, context);
|
||||||
@@ -134,47 +135,70 @@ namespace DataLayer
|
|||||||
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
|
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
|
||||||
|
|
||||||
// the edge cases of doing local-loaded vs remote-only got weird. just load it
|
// the edge cases of doing local-loaded vs remote-only got weird. just load it
|
||||||
if (_contributorsLink is null)
|
if (ContributorsLink is null)
|
||||||
getEntry(context).Collection(s => s.ContributorsLink).Load();
|
getEntry(context).Collection(s => s.ContributorsLink).Load();
|
||||||
|
|
||||||
|
var isIdentical
|
||||||
|
= ContributorsLink
|
||||||
|
.ByRole(role)
|
||||||
|
.Select(c => c.Contributor)
|
||||||
|
.SequenceEqual(newContributors);
|
||||||
|
|
||||||
var roleContributions = getContributions(role);
|
|
||||||
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
|
|
||||||
if (isIdentical)
|
if (isIdentical)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_contributorsLink.RemoveWhere(bc => bc.Role == role);
|
ContributorsLink.RemoveWhere(bc => bc.Role == role);
|
||||||
addNewContributors(newContributors, role);
|
addNewContributors(newContributors, role);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
|
||||||
{
|
{
|
||||||
byte order = 0;
|
byte order = 0;
|
||||||
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
|
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
|
||||||
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
|
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
|
||||||
_contributorsLink.UnionWith(newContributions);
|
ContributorsLink.UnionWith(newContributions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<BookContributor> getContributions(Role role)
|
|
||||||
=> ContributorsLink
|
|
||||||
.Where(a => a.Role == role)
|
|
||||||
.OrderBy(a => a.Order)
|
|
||||||
.ToList();
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
|
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
||||||
|
|
||||||
|
var entry = context.Entry(this);
|
||||||
|
|
||||||
|
if (!entry.IsKeySet)
|
||||||
|
throw new InvalidOperationException("Could not load a valid Book from database");
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region categories
|
||||||
|
internal HashSet<BookCategory> CategoriesLink { get; private set; }
|
||||||
|
|
||||||
|
private ReadOnlyCollection<BookCategory> _categoriesReadOnly;
|
||||||
|
public ReadOnlyCollection<BookCategory> Categories
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true)
|
||||||
|
_categoriesReadOnly = CategoriesLink.ToList().AsReadOnly();
|
||||||
|
return _categoriesReadOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public void SetCategoryLadders(IEnumerable<CategoryLadder> ladders)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(context, nameof(context));
|
ArgumentValidator.EnsureNotNull(ladders, nameof(ladders));
|
||||||
|
|
||||||
var entry = context.Entry(this);
|
//Replace all existing category ladders.
|
||||||
|
//Some books make have duplocate ladders
|
||||||
if (!entry.IsKeySet)
|
CategoriesLink.Clear();
|
||||||
throw new InvalidOperationException("Could not load a valid Book from database");
|
CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l)));
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region series
|
#region series
|
||||||
private HashSet<SeriesBook> _seriesLink;
|
private HashSet<SeriesBook> _seriesLink;
|
||||||
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
|
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
|
||||||
|
|
||||||
public void UpsertSeries(Series series, string order, DbContext context = null)
|
public void UpsertSeries(Series series, string order, DbContext context = null)
|
||||||
@@ -224,15 +248,6 @@ namespace DataLayer
|
|||||||
Language = language?.FirstCharToUpper() ?? Language;
|
Language = language?.FirstCharToUpper() ?? Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateCategory(Category category, DbContext context = null)
|
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
||||||
{
|
|
||||||
// since category is never null, nullity means it hasn't been loaded
|
|
||||||
if (Category is null)
|
|
||||||
getEntry(context).Reference(s => s.Category).Load();
|
|
||||||
|
|
||||||
Category = category;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using Dinah.Core;
|
||||||
|
|
||||||
|
namespace DataLayer
|
||||||
|
{
|
||||||
|
public class BookCategory
|
||||||
|
{
|
||||||
|
internal int BookId { get; private set; }
|
||||||
|
internal int CategoryLadderId { get; private set; }
|
||||||
|
|
||||||
|
public Book Book { get; private set; }
|
||||||
|
public CategoryLadder CategoryLadder { get; private set; }
|
||||||
|
private BookCategory() { }
|
||||||
|
|
||||||
|
internal BookCategory(Book book, CategoryLadder categoriesList)
|
||||||
|
{
|
||||||
|
Book = ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||||
|
CategoryLadder = ArgumentValidator.EnsureNotNull(categoriesList, nameof(categoriesList));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
public class AudibleCategoryId
|
public class AudibleCategoryId
|
||||||
@@ -15,20 +15,29 @@ namespace DataLayer
|
|||||||
Id = id;
|
Id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Category
|
public class Category
|
||||||
{
|
{
|
||||||
// Empty is a special case. use private ctor w/o validation
|
|
||||||
public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" };
|
|
||||||
|
|
||||||
internal int CategoryId { get; private set; }
|
internal int CategoryId { get; private set; }
|
||||||
public string AudibleCategoryId { get; private set; }
|
public string? AudibleCategoryId { get; private set; }
|
||||||
|
|
||||||
public string Name { get; private set; }
|
public string? Name { get; internal set; }
|
||||||
public Category ParentCategory { get; private set; }
|
|
||||||
|
|
||||||
private Category() { }
|
internal List<CategoryLadder> _categoryLadders = new();
|
||||||
|
private ReadOnlyCollection<CategoryLadder>? _categoryLaddersReadOnly;
|
||||||
|
public ReadOnlyCollection<CategoryLadder> CategoryLadders
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_categoryLaddersReadOnly?.SequenceEqual(_categoryLadders) is not true)
|
||||||
|
_categoryLaddersReadOnly = _categoryLadders.AsReadOnly();
|
||||||
|
return _categoryLaddersReadOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Category() { }
|
||||||
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
|
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
|
||||||
public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null)
|
public Category(AudibleCategoryId audibleSeriesId, string name)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
|
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
|
||||||
var id = audibleSeriesId.Id;
|
var id = audibleSeriesId.Id;
|
||||||
@@ -37,15 +46,6 @@ namespace DataLayer
|
|||||||
|
|
||||||
AudibleCategoryId = id;
|
AudibleCategoryId = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
|
|
||||||
UpdateParentCategory(parentCategory);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateParentCategory(Category parentCategory)
|
|
||||||
{
|
|
||||||
// don't overwrite with null but not an error
|
|
||||||
if (parentCategory is not null)
|
|
||||||
ParentCategory = parentCategory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
|
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Dinah.Core;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace DataLayer
|
||||||
|
{
|
||||||
|
public class CategoryLadder : IEquatable<CategoryLadder>
|
||||||
|
{
|
||||||
|
internal int CategoryLadderId { get; private set; }
|
||||||
|
|
||||||
|
internal List<Category> _categories;
|
||||||
|
private ReadOnlyCollection<Category>? _categoriesReadOnly;
|
||||||
|
public ReadOnlyCollection<Category> Categories
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_categoriesReadOnly?.SequenceEqual(_categories) is not true)
|
||||||
|
_categoriesReadOnly = _categories.AsReadOnly();
|
||||||
|
return _categoriesReadOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private HashSet<BookCategory>? _booksLink;
|
||||||
|
public IEnumerable<BookCategory>? BooksLink => _booksLink?.ToList();
|
||||||
|
private CategoryLadder() { _categories = new(); }
|
||||||
|
public CategoryLadder(List<Category> categories)
|
||||||
|
{
|
||||||
|
ArgumentValidator.EnsureNotNull(categories, nameof(categories));
|
||||||
|
ArgumentValidator.EnsureGreaterThan(categories.Count, nameof(categories), 0);
|
||||||
|
_booksLink = new HashSet<BookCategory>();
|
||||||
|
_categories = categories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override int GetHashCode()
|
||||||
|
{
|
||||||
|
HashCode hashCode = default;
|
||||||
|
foreach (var category in _categories)
|
||||||
|
hashCode.Add(category.AudibleCategoryId);
|
||||||
|
return hashCode.ToHashCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Equals(CategoryLadder? other)
|
||||||
|
=> other?._categories is not null
|
||||||
|
&& Equals(other._categories.Select(c => c.AudibleCategoryId));
|
||||||
|
|
||||||
|
public bool Equals(IEnumerable<string?>? categoryIds)
|
||||||
|
=> categoryIds is not null
|
||||||
|
&& _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds);
|
||||||
|
|
||||||
|
public override bool Equals(object? obj)
|
||||||
|
=> obj is CategoryLadder other && Equals(other);
|
||||||
|
|
||||||
|
public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ namespace DataLayer
|
|||||||
public string Account { get; private set; }
|
public string Account { get; private set; }
|
||||||
|
|
||||||
public bool IsDeleted { get; 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)
|
||||||
@@ -24,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -24,8 +24,27 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
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));
|
||||||
@@ -103,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
|
||||||
@@ -172,7 +195,23 @@ namespace DataLayer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region IsFinished
|
||||||
|
private bool _isFinished;
|
||||||
|
public bool IsFinished
|
||||||
|
{
|
||||||
|
get => _isFinished;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value != _isFinished)
|
||||||
|
{
|
||||||
|
_isFinished = value;
|
||||||
|
OnItemChanged(nameof(IsFinished));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
public override string ToString() => $"{Book} {Rating} {Tags}";
|
public override string ToString() => $"{Book} {Rating} {Tags}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ using System.Threading.Tasks;
|
|||||||
namespace DataLayer
|
namespace DataLayer
|
||||||
{
|
{
|
||||||
public static class EntityExtensions
|
public static class EntityExtensions
|
||||||
{
|
{
|
||||||
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title);
|
public static IEnumerable<BookContributor> ByRole(this IEnumerable<BookContributor> contributors, Role role)
|
||||||
|
=> contributors
|
||||||
|
.Where(a => a.Role == role)
|
||||||
|
.OrderBy(a => a.Order);
|
||||||
|
|
||||||
|
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
|
||||||
|
|
||||||
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
|
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
|
||||||
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
|
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
|
||||||
@@ -18,9 +23,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 +33,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,15 +45,37 @@ namespace DataLayer
|
|||||||
|
|
||||||
var all = withNames.Union(nullNames).ToList();
|
var all = withNames.Union(nullNames).ToList();
|
||||||
return string.Join(", ", all);
|
return string.Join(", ", all);
|
||||||
}
|
|
||||||
public static string[] CategoriesNames(this Book book)
|
string getSeriesNameString(SeriesBook sb)
|
||||||
=> book.Category is null ? new string[0]
|
=> includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1"
|
||||||
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
|
? $"{sb.Series.Name} (#{sb.Order})"
|
||||||
: new[] { book.Category.ParentCategory.Name, book.Category.Name };
|
: sb.Series.Name;
|
||||||
public static string[] CategoriesIds(this Book book)
|
}
|
||||||
=> book.Category is null ? null
|
|
||||||
: book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId }
|
public static string[] LowestCategoryNames(this Book book)
|
||||||
: new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId };
|
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
|
||||||
|
: book
|
||||||
|
.CategoriesLink
|
||||||
|
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
|
||||||
|
.Where(c => c is not null)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public static string[] AllCategoryNames(this Book book)
|
||||||
|
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
|
||||||
|
: book
|
||||||
|
.CategoriesLink
|
||||||
|
.SelectMany(cl => cl.CategoryLadder.Categories)
|
||||||
|
.Select(c => c.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
public static string[] AllCategoryIds(this Book book)
|
||||||
|
=> book.CategoriesLink?.Any() is not true ? null
|
||||||
|
: book
|
||||||
|
.CategoriesLink
|
||||||
|
.SelectMany(cl => cl.CategoryLadder.Categories)
|
||||||
|
.Select(c => c.AudibleCategoryId)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
|
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
|
||||||
{
|
{
|
||||||
@@ -57,7 +84,7 @@ namespace DataLayer
|
|||||||
|
|
||||||
max = Math.Max(max, 1);
|
max = Math.Max(max, 1);
|
||||||
|
|
||||||
var titles = libraryBooks.Select(lb => "- " + lb.Book.Title).ToList();
|
var titles = libraryBooks.Select(lb => "- " + lb.Book.TitleWithSubtitle).ToList();
|
||||||
var titlesAgg = titles.Take(max).Aggregate((a, b) => $"{a}\r\n{b}");
|
var titlesAgg = titles.Take(max).Aggregate((a, b) => $"{a}\r\n{b}");
|
||||||
if (titles.Count == max + 1)
|
if (titles.Count == max + 1)
|
||||||
titlesAgg += $"\r\n\r\nand 1 other";
|
titlesAgg += $"\r\n\r\nand 1 other";
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ namespace DataLayer
|
|||||||
public DbSet<Contributor> Contributors { get; private set; }
|
public DbSet<Contributor> Contributors { get; private set; }
|
||||||
public DbSet<Series> Series { get; private set; }
|
public DbSet<Series> Series { get; private set; }
|
||||||
public DbSet<Category> Categories { get; private set; }
|
public DbSet<Category> Categories { get; private set; }
|
||||||
|
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
|
||||||
|
|
||||||
public static LibationContext Create(string connectionString)
|
public static LibationContext Create(string connectionString)
|
||||||
{
|
{
|
||||||
@@ -39,13 +40,15 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.ApplyConfiguration(new BookConfig());
|
modelBuilder.ApplyConfiguration(new BookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new ContributorConfig());
|
modelBuilder.ApplyConfiguration(new ContributorConfig());
|
||||||
modelBuilder.ApplyConfiguration(new BookContributorConfig());
|
modelBuilder.ApplyConfiguration(new BookContributorConfig());
|
||||||
modelBuilder.ApplyConfiguration(new LibraryBookConfig());
|
modelBuilder.ApplyConfiguration(new LibraryBookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new SeriesConfig());
|
modelBuilder.ApplyConfiguration(new SeriesConfig());
|
||||||
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
|
||||||
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
modelBuilder.ApplyConfiguration(new CategoryConfig());
|
||||||
|
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
|
||||||
|
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
+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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
// <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("20230626171442_AddBookSubtitle")]
|
||||||
|
partial class AddBookSubtitle
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
|
||||||
|
|
||||||
|
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>("Subtitle")
|
||||||
|
.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,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBookSubtitle : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Subtitle",
|
||||||
|
table: "Books",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Subtitle",
|
||||||
|
table: "Books");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
// <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("20230718214617_AddCategoryLadder")]
|
||||||
|
partial class AddCategoryLadder
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("_categoriesCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryCategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("LengthInMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Locale")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureLarge")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Subtitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "CategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("BookCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryLadders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("ContributorId");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
b.ToTable("Contributors");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ContributorId = -1,
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("AbsentFromLastScan")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoriesCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<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("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("CategoriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("CategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("CategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("ContributorsLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("ContributorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("SeriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Series", "Series")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("CategoriesLink");
|
||||||
|
|
||||||
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCategoryLadder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Books_Categories_CategoryId",
|
||||||
|
table: "Books");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_Categories_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Books_CategoryId",
|
||||||
|
table: "Books");
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Categories",
|
||||||
|
keyColumn: "CategoryId",
|
||||||
|
keyValue: -1);
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ParentCategoryCategoryId",
|
||||||
|
table: "Categories");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CategoryId",
|
||||||
|
table: "Books");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CategoryLadders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
.Annotation("Sqlite:Autoincrement", true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "BookCategory",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
BookId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_BookCategory_Books_BookId",
|
||||||
|
column: x => x.BookId,
|
||||||
|
principalTable: "Books",
|
||||||
|
principalColumn: "BookId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_BookCategory_CategoryLadders_CategoryLadderId",
|
||||||
|
column: x => x.CategoryLadderId,
|
||||||
|
principalTable: "CategoryLadders",
|
||||||
|
principalColumn: "CategoryLadderId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CategoryCategoryLadder",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
_categoriesCategoryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
_categoryLaddersCategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId",
|
||||||
|
column: x => x._categoriesCategoryId,
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCategoryLadderId",
|
||||||
|
column: x => x._categoryLaddersCategoryLadderId,
|
||||||
|
principalTable: "CategoryLadders",
|
||||||
|
principalColumn: "CategoryLadderId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BookCategory_BookId",
|
||||||
|
table: "BookCategory",
|
||||||
|
column: "BookId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_BookCategory_CategoryLadderId",
|
||||||
|
table: "BookCategory",
|
||||||
|
column: "CategoryLadderId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
|
||||||
|
table: "CategoryCategoryLadder",
|
||||||
|
column: "_categoryLaddersCategoryLadderId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "BookCategory");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CategoryCategoryLadder");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CategoryLadders");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ParentCategoryCategoryId",
|
||||||
|
table: "Categories",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CategoryId",
|
||||||
|
table: "Books",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Categories",
|
||||||
|
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
|
||||||
|
values: new object[] { -1, "", "", null });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories",
|
||||||
|
column: "ParentCategoryCategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Books_CategoryId",
|
||||||
|
table: "Books",
|
||||||
|
column: "CategoryId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Books_Categories_CategoryId",
|
||||||
|
table: "Books",
|
||||||
|
column: "CategoryId",
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "CategoryId",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_Categories_Categories_ParentCategoryCategoryId",
|
||||||
|
table: "Categories",
|
||||||
|
column: "ParentCategoryCategoryId",
|
||||||
|
principalTable: "Categories",
|
||||||
|
principalColumn: "CategoryId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
// <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("20240911114741_MyComment")]
|
||||||
|
partial class MyComment
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("_categoriesCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryCategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleProductId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ContentType")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DatePublished")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAbridged")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("LengthInMinutes")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Locale")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PictureLarge")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Subtitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<long>("_audioFormat")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
|
b.ToTable("Books");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "CategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("BookCategory");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<byte>("Order")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "ContributorId", "Role");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("ContributorId");
|
||||||
|
|
||||||
|
b.ToTable("BookContributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Category", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleCategoryId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
|
b.ToTable("Categories");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryLadders");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("ContributorId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleContributorId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("ContributorId");
|
||||||
|
|
||||||
|
b.HasIndex("Name");
|
||||||
|
|
||||||
|
b.ToTable("Contributors");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
ContributorId = -1,
|
||||||
|
Name = ""
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("AbsentFromLastScan")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Account")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateAdded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId");
|
||||||
|
|
||||||
|
b.ToTable("LibraryBooks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("AudibleSeriesId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId");
|
||||||
|
|
||||||
|
b.HasIndex("AudibleSeriesId");
|
||||||
|
|
||||||
|
b.ToTable("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("SeriesId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Order")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("SeriesId", "BookId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("SeriesId");
|
||||||
|
|
||||||
|
b.ToTable("SeriesBook");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Category", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoriesCategoryId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Books");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("SupplementId")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Url")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("SupplementId");
|
||||||
|
|
||||||
|
b1.HasIndex("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("Supplement");
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<int>("BookStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsFinished")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<int?>("PdfStatus")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<string>("Tags")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.HasKey("BookId");
|
||||||
|
|
||||||
|
b1.ToTable("UserDefinedItem", (string)null);
|
||||||
|
|
||||||
|
b1.WithOwner("Book")
|
||||||
|
.HasForeignKey("BookId");
|
||||||
|
|
||||||
|
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||||
|
{
|
||||||
|
b2.Property<int>("UserDefinedItemBookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b2.Property<float>("OverallRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("PerformanceRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.Property<float>("StoryRating")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b2.HasKey("UserDefinedItemBookId");
|
||||||
|
|
||||||
|
b2.ToTable("UserDefinedItem");
|
||||||
|
|
||||||
|
b2.WithOwner()
|
||||||
|
.HasForeignKey("UserDefinedItemBookId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b1.Navigation("Book");
|
||||||
|
|
||||||
|
b1.Navigation("Rating");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Rating");
|
||||||
|
|
||||||
|
b.Navigation("Supplements");
|
||||||
|
|
||||||
|
b.Navigation("UserDefinedItem");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("CategoriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("CategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("CategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("ContributorsLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("ContributorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Contributor");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("SeriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.Series", "Series")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("SeriesId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("Series");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("CategoriesLink");
|
||||||
|
|
||||||
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
|
b.Navigation("SeriesLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Series", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DataLayer.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MyComment : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsFinished",
|
||||||
|
table: "UserDefinedItem",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsFinished",
|
||||||
|
table: "UserDefinedItem");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,22 @@ 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", "7.0.2");
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
|
||||||
|
|
||||||
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("_categoriesCategoryId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryCategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
@@ -26,9 +41,6 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("AudibleProductId")
|
b.Property<string>("AudibleProductId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int>("CategoryId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.Property<int>("ContentType")
|
b.Property<int>("ContentType")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -56,6 +68,9 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("PictureLarge")
|
b.Property<string>("PictureLarge")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Subtitle")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Title")
|
b.Property<string>("Title")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -66,11 +81,26 @@ namespace DataLayer.Migrations
|
|||||||
|
|
||||||
b.HasIndex("AudibleProductId");
|
b.HasIndex("AudibleProductId");
|
||||||
|
|
||||||
b.HasIndex("CategoryId");
|
|
||||||
|
|
||||||
b.ToTable("Books");
|
b.ToTable("Books");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("BookId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("CategoryLadderId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("BookId", "CategoryLadderId");
|
||||||
|
|
||||||
|
b.HasIndex("BookId");
|
||||||
|
|
||||||
|
b.HasIndex("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("BookCategory");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("BookId")
|
b.Property<int>("BookId")
|
||||||
@@ -106,24 +136,22 @@ namespace DataLayer.Migrations
|
|||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<int?>("ParentCategoryCategoryId")
|
|
||||||
.HasColumnType("INTEGER");
|
|
||||||
|
|
||||||
b.HasKey("CategoryId");
|
b.HasKey("CategoryId");
|
||||||
|
|
||||||
b.HasIndex("AudibleCategoryId");
|
b.HasIndex("AudibleCategoryId");
|
||||||
|
|
||||||
b.HasIndex("ParentCategoryCategoryId");
|
|
||||||
|
|
||||||
b.ToTable("Categories");
|
b.ToTable("Categories");
|
||||||
|
});
|
||||||
|
|
||||||
b.HasData(
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
new
|
{
|
||||||
{
|
b.Property<int>("CategoryLadderId")
|
||||||
CategoryId = -1,
|
.ValueGeneratedOnAdd()
|
||||||
AudibleCategoryId = "",
|
.HasColumnType("INTEGER");
|
||||||
Name = ""
|
|
||||||
});
|
b.HasKey("CategoryLadderId");
|
||||||
|
|
||||||
|
b.ToTable("CategoryLadders");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
@@ -157,6 +185,9 @@ 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");
|
||||||
|
|
||||||
@@ -210,14 +241,23 @@ namespace DataLayer.Migrations
|
|||||||
b.ToTable("SeriesBook");
|
b.ToTable("SeriesBook");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DataLayer.Category", "Category")
|
b.HasOne("DataLayer.Category", null)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CategoryId")
|
.HasForeignKey("_categoriesCategoryId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
|
{
|
||||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||||
{
|
{
|
||||||
b1.Property<int>("BookId")
|
b1.Property<int>("BookId")
|
||||||
@@ -272,6 +312,15 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<int>("BookStatus")
|
b1.Property<int>("BookStatus")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsFinished")
|
||||||
|
.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");
|
||||||
|
|
||||||
@@ -312,8 +361,6 @@ namespace DataLayer.Migrations
|
|||||||
b1.Navigation("Rating");
|
b1.Navigation("Rating");
|
||||||
});
|
});
|
||||||
|
|
||||||
b.Navigation("Category");
|
|
||||||
|
|
||||||
b.Navigation("Rating");
|
b.Navigation("Rating");
|
||||||
|
|
||||||
b.Navigation("Supplements");
|
b.Navigation("Supplements");
|
||||||
@@ -321,6 +368,25 @@ namespace DataLayer.Migrations
|
|||||||
b.Navigation("UserDefinedItem");
|
b.Navigation("UserDefinedItem");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
|
.WithMany("CategoriesLink")
|
||||||
|
.HasForeignKey("BookId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||||
|
.WithMany("BooksLink")
|
||||||
|
.HasForeignKey("CategoryLadderId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Book");
|
||||||
|
|
||||||
|
b.Navigation("CategoryLadder");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DataLayer.Book", "Book")
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
@@ -340,15 +406,6 @@ namespace DataLayer.Migrations
|
|||||||
b.Navigation("Contributor");
|
b.Navigation("Contributor");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Category", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("ParentCategoryCategoryId");
|
|
||||||
|
|
||||||
b.Navigation("ParentCategory");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DataLayer.Book", "Book")
|
b.HasOne("DataLayer.Book", "Book")
|
||||||
@@ -381,11 +438,18 @@ namespace DataLayer.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("CategoriesLink");
|
||||||
|
|
||||||
b.Navigation("ContributorsLink");
|
b.Navigation("ContributorsLink");
|
||||||
|
|
||||||
b.Navigation("SeriesLink");
|
b.Navigation("SeriesLink");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("BooksLink");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BooksLink");
|
b.Navigation("BooksLink");
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ namespace DataLayer
|
|||||||
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
|
||||||
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||||
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||||
.Include(b => b.Category).ThenInclude(c => c.ParentCategory);
|
.Include(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
|
||||||
|
|
||||||
public static bool IsProduct(this Book book)
|
public static bool IsProduct(this Book book)
|
||||||
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;
|
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace DataLayer
|
||||||
|
{
|
||||||
|
public static class CategoryQueries
|
||||||
|
{
|
||||||
|
public static IQueryable<CategoryLadder> GetCategoryLadders(this LibationContext context)
|
||||||
|
=> context.CategoryLadders.Include(c => c._categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ namespace DataLayer
|
|||||||
// 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)
|
||||||
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
|
||||||
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
|
.Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
|
||||||
|
|
||||||
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(libraryBooks.FindChildren);
|
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
|
||||||
@@ -107,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))
|
||||||
@@ -99,32 +99,18 @@ namespace DtoImporterService
|
|||||||
.Select(n => contributorImporter.Cache[n.Name])
|
.Select(n => contributorImporter.Cache[n.Name])
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
|
||||||
// absence of categories is also possible
|
|
||||||
|
|
||||||
// CATEGORY HACK: only use the 1st 2 categories
|
|
||||||
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
|
|
||||||
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
|
|
||||||
var lastCategory
|
|
||||||
= item.Categories.Length == 0 ? ""
|
|
||||||
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
|
|
||||||
// 2+
|
|
||||||
: item.Categories[1].CategoryId;
|
|
||||||
|
|
||||||
var category = categoryImporter.Cache[lastCategory];
|
|
||||||
|
|
||||||
Book book;
|
Book book;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
book = DbContext.Books.Add(new Book(
|
book = DbContext.Books.Add(new Book(
|
||||||
new AudibleProductId(item.ProductId),
|
new AudibleProductId(item.ProductId),
|
||||||
item.TitleWithSubtitle,
|
item.Title,
|
||||||
|
item.Subtitle,
|
||||||
item.Description,
|
item.Description,
|
||||||
item.LengthInMinutes,
|
item.LengthInMinutes,
|
||||||
contentType,
|
contentType,
|
||||||
authors,
|
authors,
|
||||||
narrators,
|
narrators,
|
||||||
category,
|
|
||||||
importItem.LocaleName)
|
importItem.LocaleName)
|
||||||
).Entity;
|
).Entity;
|
||||||
Cache.Add(book.AudibleProductId, book);
|
Cache.Add(book.AudibleProductId, book);
|
||||||
@@ -139,7 +125,6 @@ namespace DtoImporterService
|
|||||||
contentType,
|
contentType,
|
||||||
QtyAuthors = authors?.Count,
|
QtyAuthors = authors?.Count,
|
||||||
QtyNarrators = narrators?.Count,
|
QtyNarrators = narrators?.Count,
|
||||||
Category = category?.Name,
|
|
||||||
importItem.LocaleName
|
importItem.LocaleName
|
||||||
});
|
});
|
||||||
throw;
|
throw;
|
||||||
@@ -164,6 +149,11 @@ namespace DtoImporterService
|
|||||||
{
|
{
|
||||||
var item = importItem.DtoItem;
|
var item = importItem.DtoItem;
|
||||||
|
|
||||||
|
book.UpdateLengthInMinutes(item.LengthInMinutes);
|
||||||
|
|
||||||
|
// Update the book titles, since formatting can change
|
||||||
|
book.UpdateTitle(item.Title, item.Subtitle);
|
||||||
|
|
||||||
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
|
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
|
||||||
book.AudioFormat = codec;
|
book.AudioFormat = codec;
|
||||||
|
|
||||||
@@ -174,6 +164,9 @@ namespace DtoImporterService
|
|||||||
if (item.PictureLarge is not null)
|
if (item.PictureLarge is not null)
|
||||||
book.PictureLarge = item.PictureLarge;
|
book.PictureLarge = item.PictureLarge;
|
||||||
|
|
||||||
|
if (item.IsFinished is not null)
|
||||||
|
book.UserDefinedItem.IsFinished = item.IsFinished.Value;
|
||||||
|
|
||||||
// 2023-02-01
|
// 2023-02-01
|
||||||
// updateBook must update language on books which were imported before the migration which added language.
|
// updateBook must update language on books which were imported before the migration which added language.
|
||||||
// Can eventually delete this
|
// Can eventually delete this
|
||||||
@@ -197,6 +190,19 @@ namespace DtoImporterService
|
|||||||
book.UpsertSeries(series, seriesEntry.Sequence);
|
book.UpsertSeries(series, seriesEntry.Sequence);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.CategoryLadders is not null)
|
||||||
|
{
|
||||||
|
var ladders = new List<DataLayer.CategoryLadder>();
|
||||||
|
foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0))
|
||||||
|
{
|
||||||
|
var categoryIds = ladder.Select(l => l.CategoryId).ToList();
|
||||||
|
ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)));
|
||||||
|
}
|
||||||
|
//Set all ladders at once so ladders that have been
|
||||||
|
//removed by audible can be removed from the DB
|
||||||
|
book.SetCategoryLadders(ladders);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DataLayer.ContentType GetContentType(Item item)
|
private static DataLayer.ContentType GetContentType(Item item)
|
||||||
|
|||||||
@@ -12,76 +12,86 @@ namespace DtoImporterService
|
|||||||
{
|
{
|
||||||
protected override IValidator Validator => new CategoryValidator();
|
protected override IValidator Validator => new CategoryValidator();
|
||||||
|
|
||||||
public Dictionary<string, Category> Cache { get; private set; } = new();
|
private Dictionary<string, Category> CategoryCache { get; set; } = new();
|
||||||
|
public HashSet<DataLayer.CategoryLadder> LadderCache { get; private set; } = new();
|
||||||
|
|
||||||
public CategoryImporter(LibationContext context) : base(context) { }
|
public CategoryImporter(LibationContext context) : base(context) { }
|
||||||
|
|
||||||
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
protected override int DoImport(IEnumerable<ImportItem> importItems)
|
||||||
{
|
{
|
||||||
// get distinct
|
|
||||||
var categoryIds = importItems
|
|
||||||
.Select(i => i.DtoItem)
|
|
||||||
.GetCategoriesDistinct()
|
|
||||||
.Select(c => c.CategoryId)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// load db existing => .Local
|
// load db existing => .Local
|
||||||
loadLocal_categories(categoryIds);
|
loadLocal_categories();
|
||||||
|
|
||||||
// upsert
|
// upsert
|
||||||
var categoryPairs = importItems
|
//Import item may not have no (null) categories
|
||||||
.Select(i => i.DtoItem)
|
var categoryLadders = importItems
|
||||||
.GetCategoryPairsDistinct()
|
.Where(i => i.DtoItem.CategoryLadders is not null)
|
||||||
|
.SelectMany(i => i.DtoItem.CategoryLadders)
|
||||||
|
.Select(cl => cl?.Ladder)
|
||||||
|
.Where(l => l?.Length > 0)
|
||||||
.ToList();
|
.ToList();
|
||||||
var qtyNew = upsertCategories(categoryPairs);
|
|
||||||
|
var qtyNew = upsertCategories(categoryLadders);
|
||||||
return qtyNew;
|
return qtyNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadLocal_categories(List<string> categoryIds)
|
private void loadLocal_categories()
|
||||||
{
|
{
|
||||||
// must include default/empty/missing
|
|
||||||
categoryIds.Add(Category.GetEmpty().AudibleCategoryId);
|
|
||||||
|
|
||||||
// load existing => local
|
// load existing => local
|
||||||
Cache = DbContext.Categories
|
LadderCache = DbContext.GetCategoryLadders().ToHashSet();
|
||||||
.Where(c => categoryIds.Contains(c.AudibleCategoryId))
|
CategoryCache = LadderCache.SelectMany(cl => cl.Categories).ToDictionarySafe(c => c.AudibleCategoryId);
|
||||||
.ToDictionarySafe(c => c.AudibleCategoryId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// only use after loading contributors => local
|
// only use after loading contributors => local
|
||||||
private int upsertCategories(List<Ladder[]> categoryPairs)
|
private int upsertCategories(List<Ladder[]> ladders)
|
||||||
{
|
{
|
||||||
var qtyNew = 0;
|
var qtyNew = 0;
|
||||||
|
|
||||||
foreach (var pair in categoryPairs)
|
foreach (var ladder in ladders)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < pair.Length; i++)
|
var categories = new List<Category>(ladder.Length);
|
||||||
|
|
||||||
|
for (var i = 0; i < ladder.Length; i++)
|
||||||
{
|
{
|
||||||
// CATEGORY HACK: not yet supported: depth beyond 0 and 1
|
var id = ladder[i].CategoryId;
|
||||||
if (i > 1)
|
var name = ladder[i].CategoryName;
|
||||||
break;
|
|
||||||
|
|
||||||
var id = pair[i].CategoryId;
|
if (!CategoryCache.TryGetValue(id, out var category))
|
||||||
var name = pair[i].CategoryName;
|
|
||||||
|
|
||||||
Category parentCategory = null;
|
|
||||||
if (i == 1)
|
|
||||||
Cache.TryGetValue(pair[0].CategoryId, out parentCategory);
|
|
||||||
|
|
||||||
if (!Cache.TryGetValue(id, out var category))
|
|
||||||
{
|
{
|
||||||
category = addCategory(id, name);
|
category = addCategory(id, name);
|
||||||
qtyNew++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
category.UpdateParentCategory(parentCategory);
|
categories.Add(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
var categoryLadder = new DataLayer.CategoryLadder(categories);
|
||||||
|
if (!LadderCache.Contains(categoryLadder))
|
||||||
|
{
|
||||||
|
addCategoryLadder(categoryLadder);
|
||||||
|
qtyNew++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return qtyNew;
|
return qtyNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DataLayer.CategoryLadder addCategoryLadder(DataLayer.CategoryLadder categoryList)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entityEntry = DbContext.CategoryLadders.Add(categoryList);
|
||||||
|
var entity = entityEntry.Entity;
|
||||||
|
|
||||||
|
LadderCache.Add(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Category addCategory(string id, string name)
|
private Category addCategory(string id, string name)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -91,7 +101,7 @@ namespace DtoImporterService
|
|||||||
var entityEntry = DbContext.Categories.Add(category);
|
var entityEntry = DbContext.Categories.Add(category);
|
||||||
var entity = entityEntry.Entity;
|
var entity = entityEntry.Entity;
|
||||||
|
|
||||||
Cache.Add(entity.AudibleCategoryId, entity);
|
CategoryCache.Add(entity.AudibleCategoryId, entity);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,12 +21,12 @@ namespace FileLiberator
|
|||||||
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
|
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
|
||||||
if (series is not null)
|
if (series is not null)
|
||||||
{
|
{
|
||||||
var seriesParent = ApplicationServices.DbContexts.GetContext().GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
|
using var context = ApplicationServices.DbContexts.GetContext();
|
||||||
|
var seriesParent = context.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
|
||||||
|
|
||||||
if (seriesParent is not null)
|
if (seriesParent is not null)
|
||||||
{
|
{
|
||||||
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
return Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,18 +51,24 @@ namespace FileLiberator
|
|||||||
|
|
||||||
var config = Configuration.Instance;
|
var config = Configuration.Instance;
|
||||||
var lameConfig = GetLameOptions(config);
|
var lameConfig = GetLameOptions(config);
|
||||||
|
var chapters = m4bBook.GetChaptersFromMetadata();
|
||||||
//Finishing configuring lame encoder.
|
//Finishing configuring lame encoder.
|
||||||
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||||
m4bBook,
|
m4bBook,
|
||||||
lameConfig,
|
lameConfig,
|
||||||
config.LameDownsampleMono,
|
config.LameDownsampleMono,
|
||||||
config.LameMatchSourceBR);
|
config.LameMatchSourceBR,
|
||||||
|
chapters);
|
||||||
|
|
||||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
if (m4bBook.AppleTags.Tracks is (int trackNum, int trackCount))
|
||||||
|
{
|
||||||
|
lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
|
||||||
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||||
await Mp4Operation;
|
await Mp4Operation;
|
||||||
|
|
||||||
@@ -73,7 +79,17 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3");
|
var realMp3Path
|
||||||
|
= FileUtility.SaferMoveToValidPath(
|
||||||
|
mp3File.Name,
|
||||||
|
proposedMp3Path,
|
||||||
|
Configuration.Instance.ReplacementCharacters,
|
||||||
|
extension: "mp3",
|
||||||
|
Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
|
SetFileTime(libraryBook, realMp3Path);
|
||||||
|
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
|
||||||
|
|
||||||
OnFileCreated(libraryBook, realMp3Path);
|
OnFileCreated(libraryBook, realMp3Path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AaxDecrypter;
|
using AaxDecrypter;
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
|
using AudibleApi.Common;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using Dinah.Core.ErrorHandling;
|
using Dinah.Core.ErrorHandling;
|
||||||
@@ -61,33 +62,53 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
|
||||||
|
|
||||||
// decrypt failed
|
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||||
if (finalStorageDir is null)
|
Task[] finalTasks = new[]
|
||||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
{
|
||||||
|
Task.Run(() => downloadCoverArt(libraryBook)),
|
||||||
|
moveFilesTask,
|
||||||
|
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
||||||
|
};
|
||||||
|
|
||||||
if (Configuration.Instance.DownloadCoverArt)
|
try
|
||||||
downloadCoverArt(libraryBook);
|
{
|
||||||
|
await Task.WhenAll(finalTasks);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
|
||||||
|
//Only fail if the downloaded audio files failed to move to Books directory
|
||||||
|
if (moveFilesTask.IsFaulted)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (moveFilesTask.IsCompletedSuccessfully)
|
||||||
|
{
|
||||||
|
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
|
||||||
|
|
||||||
// contains logic to check for config setting and OS
|
SetDirectoryTime(libraryBook, finalStorageDir);
|
||||||
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
return new StatusHandler();
|
||||||
|
|
||||||
return new StatusHandler();
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -101,14 +122,15 @@ namespace FileLiberator
|
|||||||
|
|
||||||
downloadValidation(libraryBook);
|
downloadValidation(libraryBook);
|
||||||
|
|
||||||
|
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
|
||||||
var api = await libraryBook.GetApiAsync();
|
var api = await libraryBook.GetApiAsync();
|
||||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
|
||||||
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||||
|
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
|
|
||||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
if (contentLic.DrmType != DrmType.Adrm)
|
||||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -118,7 +140,7 @@ namespace FileLiberator
|
|||||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||||
|
|
||||||
if (config.AllowLibationFixup)
|
if (config.AllowLibationFixup)
|
||||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
|
||||||
|
|
||||||
abDownloader = converter;
|
abDownloader = converter;
|
||||||
}
|
}
|
||||||
@@ -132,15 +154,28 @@ namespace FileLiberator
|
|||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
// REAL WORK DONE HERE
|
||||||
return await abDownloader.RunAsync();
|
var success = await abDownloader.RunAsync();
|
||||||
|
|
||||||
|
if (success && config.SaveMetadataToFile)
|
||||||
|
{
|
||||||
|
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
|
||||||
|
|
||||||
|
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||||
|
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
|
||||||
|
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
|
||||||
|
|
||||||
|
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
||||||
|
OnFileCreated(libraryBook, metadataFile);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
||||||
{
|
{
|
||||||
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
||||||
|
|
||||||
var outputFormat
|
var outputFormat
|
||||||
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
||||||
? OutputFormat.Mp3
|
? OutputFormat.Mp3
|
||||||
: OutputFormat.M4b;
|
: OutputFormat.M4b;
|
||||||
|
|
||||||
@@ -149,7 +184,10 @@ namespace FileLiberator
|
|||||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
//Set the requested AudioFormat for use in file naming templates
|
||||||
|
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
|
||||||
|
|
||||||
|
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
||||||
{
|
{
|
||||||
AudibleKey = contentLic?.Voucher?.Key,
|
AudibleKey = contentLic?.Voucher?.Key,
|
||||||
AudibleIV = contentLic?.Voucher?.Iv,
|
AudibleIV = contentLic?.Voucher?.Iv,
|
||||||
@@ -159,7 +197,11 @@ namespace FileLiberator
|
|||||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||||
|
var chapters
|
||||||
|
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||||
|
.OrderBy(c => c.StartOffsetMs)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (config.MergeOpeningAndEndCredits)
|
if (config.MergeOpeningAndEndCredits)
|
||||||
combineCredits(chapters);
|
combineCredits(chapters);
|
||||||
@@ -256,14 +298,19 @@ namespace FileLiberator
|
|||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string titleConcat = ": ")
|
||||||
{
|
{
|
||||||
List<AudibleApi.Common.Chapter> chaps = new();
|
List<Chapter> chaps = new();
|
||||||
|
|
||||||
foreach (var c in chapters)
|
foreach (var c in chapters)
|
||||||
{
|
{
|
||||||
if (c.Chapters is null)
|
if (c.Chapters is null)
|
||||||
chaps.Add(c);
|
chaps.Add(c);
|
||||||
|
else if (titleConcat is null)
|
||||||
|
{
|
||||||
|
chaps.Add(c);
|
||||||
|
chaps.AddRange(flattenChapters(c.Chapters));
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (c.LengthMs < 10000)
|
if (c.LengthMs < 10000)
|
||||||
@@ -281,13 +328,12 @@ namespace FileLiberator
|
|||||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||||
|
|
||||||
chaps.AddRange(children);
|
chaps.AddRange(children);
|
||||||
c.Chapters = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return chaps;
|
return chaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
public static void combineCredits(IList<Chapter> chapters)
|
||||||
{
|
{
|
||||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||||
{
|
{
|
||||||
@@ -311,9 +357,9 @@ namespace FileLiberator
|
|||||||
string errorTitle()
|
string errorTitle()
|
||||||
{
|
{
|
||||||
var title
|
var title
|
||||||
= (libraryBook.Book.Title.Length > 53)
|
= (libraryBook.Book.TitleWithSubtitle.Length > 53)
|
||||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..."
|
||||||
: libraryBook.Book.Title;
|
: libraryBook.Book.TitleWithSubtitle;
|
||||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||||
return errorBookTitle;
|
return errorBookTitle;
|
||||||
};
|
};
|
||||||
@@ -327,52 +373,78 @@ namespace FileLiberator
|
|||||||
|
|
||||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||||
{
|
{
|
||||||
|
if (Configuration.Instance.AllowLibationFixup)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
e = OnRequestCoverArt();
|
||||||
|
abDownloader.SetCoverArt(e);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e is not null)
|
if (e is not null)
|
||||||
OnCoverImageDiscovered(e);
|
OnCoverImageDiscovered(e);
|
||||||
else if (Configuration.Instance.AllowLibationFixup)
|
}
|
||||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Move new files to 'Books' directory</summary>
|
/// <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>
|
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||||
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||||
{
|
{
|
||||||
// create final directory. move each file into it
|
// create final directory. move each file into it
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
Directory.CreateDirectory(destinationDir);
|
|
||||||
|
|
||||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
for (var i = 0; i < entries.Count; i++)
|
||||||
|
|
||||||
if (getFirstAudio() == default)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
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
|
||||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
= FileUtility.SaferMoveToValidPath(
|
||||||
|
entry.Path,
|
||||||
|
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
|
||||||
|
Configuration.Instance.ReplacementCharacters,
|
||||||
|
overwrite: Configuration.Instance.OverwriteExisting);
|
||||||
|
|
||||||
|
SetFileTime(libraryBook, realDest);
|
||||||
|
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||||
|
|
||||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
// 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);
|
||||||
|
SetFileTime(libraryBook, cue.Path);
|
||||||
|
}
|
||||||
|
|
||||||
AudibleFileStorage.Audio.Refresh();
|
AudibleFileStorage.Audio.Refresh();
|
||||||
|
|
||||||
return destinationDir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
|
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||||
|
if (!Directory.Exists(destinationDir))
|
||||||
|
Directory.CreateDirectory(destinationDir);
|
||||||
|
return destinationDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
||||||
|
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||||
|
|
||||||
|
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||||
|
{
|
||||||
|
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||||
|
|
||||||
var coverPath = "[null]";
|
var coverPath = "[null]";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||||
|
|
||||||
@@ -381,7 +453,10 @@ namespace FileLiberator
|
|||||||
|
|
||||||
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
||||||
if (picBytes.Length > 0)
|
if (picBytes.Length > 0)
|
||||||
|
{
|
||||||
File.WriteAllBytes(coverPath, picBytes);
|
File.WriteAllBytes(coverPath, picBytes);
|
||||||
|
SetFileTime(libraryBook, coverPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ namespace FileLiberator
|
|||||||
public TimeSpan RuntimeLength { get; init; }
|
public TimeSpan RuntimeLength { get; init; }
|
||||||
public OutputFormat OutputFormat { get; init; }
|
public OutputFormat OutputFormat { get; init; }
|
||||||
public ChapterInfo ChapterInfo { get; init; }
|
public ChapterInfo ChapterInfo { get; init; }
|
||||||
|
public string Title => LibraryBook.Book.Title;
|
||||||
|
public string Subtitle => LibraryBook.Book.Subtitle;
|
||||||
|
public string Publisher => LibraryBook.Book.Publisher;
|
||||||
|
public string Language => LibraryBook.Book.Language;
|
||||||
|
public string AudibleProductId => LibraryBookDto.AudibleProductId;
|
||||||
|
public string SeriesName => LibraryBookDto.SeriesName;
|
||||||
|
public float? SeriesNumber => LibraryBookDto.SeriesNumber;
|
||||||
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||||
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||||
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ namespace FileLiberator
|
|||||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||||
var result = verifyDownload(actualDownloadedFilePath);
|
var result = verifyDownload(actualDownloadedFilePath);
|
||||||
|
|
||||||
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
SetFileTime(libraryBook, actualDownloadedFilePath);
|
||||||
|
SetDirectoryTime(libraryBook, Path.GetDirectoryName(actualDownloadedFilePath));
|
||||||
|
}
|
||||||
|
libraryBook.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
@@ -44,7 +45,7 @@ namespace FileLiberator
|
|||||||
|
|
||||||
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
|
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
|
||||||
{
|
{
|
||||||
libraryBook.Book.Title,
|
libraryBook.Book.TitleWithSubtitle,
|
||||||
libraryBook.Book.AudibleProductId,
|
libraryBook.Book.AudibleProductId,
|
||||||
libraryBook.Book.Locale,
|
libraryBook.Book.Locale,
|
||||||
Account = libraryBook.Account?.ToMask() ?? "[empty]"
|
Account = libraryBook.Account?.ToMask() ?? "[empty]"
|
||||||
@@ -98,5 +99,26 @@ namespace FileLiberator
|
|||||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
|
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
|
||||||
Completed?.Invoke(this, libraryBook);
|
Completed?.Invoke(this, libraryBook);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
protected static void SetFileTime(LibraryBook libraryBook, string file)
|
||||||
|
=> setFileSystemTime(libraryBook, new FileInfo(file));
|
||||||
|
protected static void SetDirectoryTime(LibraryBook libraryBook, string file)
|
||||||
|
=> setFileSystemTime(libraryBook, new DirectoryInfo(file));
|
||||||
|
|
||||||
|
private static void setFileSystemTime(LibraryBook libraryBook, FileSystemInfo fileInfo)
|
||||||
|
{
|
||||||
|
if (!fileInfo.Exists) return;
|
||||||
|
|
||||||
|
fileInfo.CreationTimeUtc = getTimeValue(Configuration.Instance.CreationTime) ?? fileInfo.CreationTimeUtc;
|
||||||
|
fileInfo.LastWriteTimeUtc = getTimeValue(Configuration.Instance.LastWriteTime) ?? fileInfo.LastWriteTimeUtc;
|
||||||
|
|
||||||
|
DateTime? getTimeValue(Configuration.DateTimeSource source) => source switch
|
||||||
|
{
|
||||||
|
Configuration.DateTimeSource.Added => libraryBook.DateAdded,
|
||||||
|
Configuration.DateTimeSource.Published => libraryBook.Book.DatePublished,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
@@ -13,40 +14,53 @@ namespace FileLiberator
|
|||||||
public static (string id, string title, string locale, string account) LogFriendly(this LibraryBook libraryBook)
|
public static (string id, string title, string locale, string account) LogFriendly(this LibraryBook libraryBook)
|
||||||
=> (
|
=> (
|
||||||
id: libraryBook.Book.AudibleProductId,
|
id: libraryBook.Book.AudibleProductId,
|
||||||
title: libraryBook.Book.Title,
|
title: libraryBook.Book.TitleWithSubtitle,
|
||||||
locale: libraryBook.Book.Locale,
|
locale: libraryBook.Book.Locale,
|
||||||
account: libraryBook.Account.ToMask()
|
account: libraryBook.Account.ToMask()
|
||||||
);
|
);
|
||||||
|
|
||||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
var apiExtended = await AudibleUtilities.ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
var apiExtended = await ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||||
return apiExtended.Api;
|
return apiExtended.Api;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
public static LibraryBookDto ToDto(this LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
Account = libraryBook.Account,
|
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||||
DateAdded = libraryBook.DateAdded,
|
var nickname
|
||||||
|
= persister.AccountsSettings.Accounts
|
||||||
|
.FirstOrDefault(a => a.AccountId == libraryBook.Account && a.Locale.Name == libraryBook.Book.Locale)
|
||||||
|
?.AccountName;
|
||||||
|
|
||||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
return new()
|
||||||
Title = libraryBook.Book.Title ?? "",
|
{
|
||||||
Locale = libraryBook.Book.Locale,
|
Account = libraryBook.Account,
|
||||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
AccountNickname = nickname,
|
||||||
DatePublished = libraryBook.Book.DatePublished,
|
DateAdded = libraryBook.DateAdded,
|
||||||
|
|
||||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||||
|
Title = libraryBook.Book.Title,
|
||||||
|
Subtitle = libraryBook.Book.Subtitle,
|
||||||
|
TitleWithSubtitle = libraryBook.Book.TitleWithSubtitle,
|
||||||
|
Locale = libraryBook.Book.Locale,
|
||||||
|
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||||
|
DatePublished = libraryBook.Book.DatePublished,
|
||||||
|
|
||||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||||
|
|
||||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||||
SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
|
||||||
IsPodcast = libraryBook.Book.IsEpisodeChild(),
|
|
||||||
|
|
||||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
||||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||||
Language = libraryBook.Book.Language
|
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||||
};
|
|
||||||
|
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||||
|
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||||
|
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||||
|
Language = libraryBook.Book.Language
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager
|
namespace FileManager
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -16,9 +17,9 @@ namespace FileManager
|
|||||||
public string SearchPattern { get; private set; }
|
public string SearchPattern { get; private set; }
|
||||||
public SearchOption SearchOption { get; private set; }
|
public SearchOption SearchOption { get; private set; }
|
||||||
|
|
||||||
private FileSystemWatcher fileSystemWatcher { get; set; }
|
private FileSystemWatcher? fileSystemWatcher { get; set; }
|
||||||
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
|
private BlockingCollection<FileSystemEventArgs>? directoryChangesEvents { get; set; }
|
||||||
private Task backgroundScanner { get; set; }
|
private Task? backgroundScanner { get; set; }
|
||||||
|
|
||||||
private object fsCacheLocker { get; } = new();
|
private object fsCacheLocker { get; } = new();
|
||||||
private List<LongPath> fsCache { get; } = new();
|
private List<LongPath> fsCache { get; } = new();
|
||||||
@@ -32,7 +33,7 @@ namespace FileManager
|
|||||||
Init();
|
Init();
|
||||||
}
|
}
|
||||||
|
|
||||||
public LongPath FindFile(System.Text.RegularExpressions.Regex regex)
|
public LongPath? FindFile(System.Text.RegularExpressions.Regex regex)
|
||||||
{
|
{
|
||||||
lock (fsCacheLocker)
|
lock (fsCacheLocker)
|
||||||
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
|
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
|
||||||
@@ -49,7 +50,7 @@ namespace FileManager
|
|||||||
lock (fsCacheLocker)
|
lock (fsCacheLocker)
|
||||||
{
|
{
|
||||||
fsCache.Clear();
|
fsCache.Clear();
|
||||||
fsCache.AddRange(FileUtility.SaferEnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ namespace FileManager
|
|||||||
Stop();
|
Stop();
|
||||||
|
|
||||||
lock (fsCacheLocker)
|
lock (fsCacheLocker)
|
||||||
fsCache.AddRange(FileUtility.SaferEnumerateFiles(RootDirectory, SearchPattern, SearchOption));
|
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
|
||||||
|
|
||||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||||
@@ -105,13 +106,13 @@ namespace FileManager
|
|||||||
|
|
||||||
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
|
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
|
||||||
{
|
{
|
||||||
directoryChangesEvents.Add(e);
|
directoryChangesEvents?.Add(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Background Thread
|
#region Background Thread
|
||||||
private void BackgroundScanner()
|
private void BackgroundScanner()
|
||||||
{
|
{
|
||||||
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
|
while (directoryChangesEvents?.TryTake(out var change, -1) is true)
|
||||||
{
|
{
|
||||||
lock (fsCacheLocker)
|
lock (fsCacheLocker)
|
||||||
UpdateLocalCache(change);
|
UpdateLocalCache(change);
|
||||||
@@ -147,14 +148,27 @@ namespace FileManager
|
|||||||
private void AddPath(LongPath path)
|
private void AddPath(LongPath path)
|
||||||
{
|
{
|
||||||
path = path.LongPathName;
|
path = path.LongPathName;
|
||||||
if (!File.Exists(path) && !Directory.Exists(path))
|
//Temporary files created when updating the db will disappear before their attributes can be read.
|
||||||
|
if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path))
|
||||||
return;
|
return;
|
||||||
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
||||||
AddUniqueFiles(FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption));
|
AddUniqueFiles(SafestEnumerateFiles(path));
|
||||||
else
|
else
|
||||||
AddUniqueFile(path);
|
AddUniqueFile(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IEnumerable<LongPath> SafestEnumerateFiles(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void AddUniqueFiles(IEnumerable<LongPath> newFiles)
|
private void AddUniqueFiles(IEnumerable<LongPath> newFiles)
|
||||||
{
|
{
|
||||||
foreach (var file in newFiles)
|
foreach (var file in newFiles)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
|
<PackageReference Include="Dinah.Core" Version="8.0.0.1" />
|
||||||
<PackageReference Include="Polly" Version="7.2.3" />
|
<PackageReference Include="Polly" Version="8.4.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
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;
|
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager
|
namespace FileManager
|
||||||
{
|
{
|
||||||
public static class FileUtility
|
public static class FileUtility
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// "txt" => ".txt"
|
/// "txt" => ".txt"
|
||||||
/// <br />".txt" => ".txt"
|
/// <br />".txt" => ".txt"
|
||||||
/// <br />null or whitespace => ""
|
/// <br />null or whitespace => ""
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetStandardizedExtension(string extension)
|
[return: NotNull]
|
||||||
|
public static string GetStandardizedExtension(string? extension)
|
||||||
=> string.IsNullOrWhiteSpace(extension)
|
=> string.IsNullOrWhiteSpace(extension)
|
||||||
? (extension ?? "")?.Trim()
|
? string.Empty
|
||||||
: '.' + extension.Trim().Trim('.');
|
: '.' + extension.Trim().Trim('.');
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -49,18 +49,18 @@ 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, string fileExtension, 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));
|
ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
|
||||||
|
|
||||||
fileExtension = GetStandardizedExtension(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)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
|
||||||
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
|
|
||||||
|
|
||||||
var fileName = Path.GetFileName(path);
|
var fileName = Path.GetFileName(path);
|
||||||
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
|
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -85,6 +85,7 @@ namespace FileManager
|
|||||||
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
|
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
ArgumentValidator.EnsureNotNull(path, nameof(path));
|
||||||
|
ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
|
||||||
|
|
||||||
var pathNoPrefix = path.PathWithoutPrefix;
|
var pathNoPrefix = path.PathWithoutPrefix;
|
||||||
|
|
||||||
@@ -147,14 +148,24 @@ namespace FileManager
|
|||||||
|
|
||||||
/// <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, enforce max file length
|
||||||
/// <br/>- Perform <see cref="SaferMove"/>
|
/// <br/>- Perform <see cref="SaferMove"/>
|
||||||
/// <br/>- Return valid path
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements, string extension = null)
|
/// <param name="source">Name of the file to move</param>
|
||||||
|
/// <param name="destination">The new path and name for the file.</param>
|
||||||
|
/// <param name="replacements">Rules for replacing illegal file path characters</param>
|
||||||
|
/// <param name="extension">File extension override to use for <paramref name="destination"/></param>
|
||||||
|
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
|
||||||
|
/// <returns>The actual destination filename</returns>
|
||||||
|
public static string SaferMoveToValidPath(
|
||||||
|
LongPath source,
|
||||||
|
LongPath destination,
|
||||||
|
ReplacementCharacters replacements,
|
||||||
|
string? extension = null,
|
||||||
|
bool overwrite = false)
|
||||||
{
|
{
|
||||||
extension = extension ?? Path.GetExtension(source);
|
extension ??= Path.GetExtension(source);
|
||||||
destination = GetValidFilename(destination, replacements, extension);
|
destination = GetValidFilename(destination, replacements, extension, overwrite);
|
||||||
SaferMove(source, destination);
|
SaferMove(source, destination);
|
||||||
return destination;
|
return destination;
|
||||||
}
|
}
|
||||||
@@ -204,6 +215,9 @@ namespace FileManager
|
|||||||
SaferDelete(destination);
|
SaferDelete(destination);
|
||||||
|
|
||||||
var dir = Path.GetDirectoryName(destination);
|
var dir = Path.GetDirectoryName(destination);
|
||||||
|
if (dir is null)
|
||||||
|
throw new DirectoryNotFoundException();
|
||||||
|
|
||||||
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
|
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
|
||||||
Directory.CreateDirectory(dir);
|
Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace FileManager
|
||||||
|
{
|
||||||
|
public sealed class LogArchiver : IAsyncDisposable, IDisposable
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
public void Dispose() => archive.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager
|
namespace FileManager
|
||||||
{
|
{
|
||||||
public class LongPath
|
public class LongPath
|
||||||
@@ -15,9 +17,9 @@ namespace FileManager
|
|||||||
public static readonly int MaxPathLength;
|
public static readonly int MaxPathLength;
|
||||||
private const int WIN_MAX_PATH = 260;
|
private const int WIN_MAX_PATH = 260;
|
||||||
private const string WIN_LONG_PATH_PREFIX = @"\\?\";
|
private const string WIN_LONG_PATH_PREFIX = @"\\?\";
|
||||||
internal static readonly bool IsWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
internal static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
|
||||||
internal static readonly bool IsLinux = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
internal static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
|
||||||
internal static readonly bool IsOSX = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
internal static readonly bool IsOSX = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
|
||||||
|
|
||||||
public string Path { get; }
|
public string Path { get; }
|
||||||
|
|
||||||
@@ -60,7 +62,8 @@ namespace FileManager
|
|||||||
=> IsWindows ? filename.Length
|
=> IsWindows ? filename.Length
|
||||||
: Encoding.UTF8.GetByteCount(filename);
|
: Encoding.UTF8.GetByteCount(filename);
|
||||||
|
|
||||||
public static implicit operator LongPath(string path)
|
[return: NotNullIfNotNull(nameof(path))]
|
||||||
|
public static implicit operator LongPath?(string? path)
|
||||||
{
|
{
|
||||||
if (path is null) return null;
|
if (path is null) return null;
|
||||||
|
|
||||||
@@ -93,7 +96,8 @@ namespace FileManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static implicit operator string(LongPath path) => path?.Path;
|
[return: NotNullIfNotNull(nameof(path))]
|
||||||
|
public static implicit operator string?(LongPath? path) => path?.Path;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string ShortPathName
|
public string ShortPathName
|
||||||
@@ -127,8 +131,6 @@ namespace FileManager
|
|||||||
//for newly-created entries in ther file system. Existing entries made while
|
//for newly-created entries in ther file system. Existing entries made while
|
||||||
//8dot3 names were disabled will not be reachable by short paths.
|
//8dot3 names were disabled will not be reachable by short paths.
|
||||||
|
|
||||||
if (Path is null) return null;
|
|
||||||
|
|
||||||
StringBuilder shortPathBuffer = new(MaxPathLength);
|
StringBuilder shortPathBuffer = new(MaxPathLength);
|
||||||
GetShortPathName(Path, shortPathBuffer, MaxPathLength);
|
GetShortPathName(Path, shortPathBuffer, MaxPathLength);
|
||||||
return shortPathBuffer.ToString();
|
return shortPathBuffer.ToString();
|
||||||
@@ -141,7 +143,6 @@ namespace FileManager
|
|||||||
get
|
get
|
||||||
{
|
{
|
||||||
if (!IsWindows) return Path;
|
if (!IsWindows) return Path;
|
||||||
if (Path is null) return null;
|
|
||||||
|
|
||||||
StringBuilder longPathBuffer = new(MaxPathLength);
|
StringBuilder longPathBuffer = new(MaxPathLength);
|
||||||
GetLongPathName(Path, longPathBuffer, MaxPathLength);
|
GetLongPathName(Path, longPathBuffer, MaxPathLength);
|
||||||
@@ -156,13 +157,19 @@ namespace FileManager
|
|||||||
{
|
{
|
||||||
if (!IsWindows) return Path;
|
if (!IsWindows) return Path;
|
||||||
return
|
return
|
||||||
Path?.StartsWith(WIN_LONG_PATH_PREFIX) == true ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
|
Path.StartsWith(WIN_LONG_PATH_PREFIX)
|
||||||
:Path;
|
? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
|
||||||
|
: Path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => Path;
|
public override string ToString() => Path;
|
||||||
|
|
||||||
|
public override int GetHashCode() => Path.GetHashCode();
|
||||||
|
public override bool Equals(object? obj) => obj is LongPath other && Path == other.Path;
|
||||||
|
public static bool operator ==(LongPath? path1, LongPath? path2) => path1?.Equals(path2) is true;
|
||||||
|
public static bool operator !=(LongPath? path1, LongPath? path2) => path1 is null || path2 is null || !path1.Equals(path2);
|
||||||
|
|
||||||
|
|
||||||
[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,7 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager.NamingTemplate;
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
internal interface IClosingPropertyTag : IPropertyTag
|
internal interface IClosingPropertyTag : IPropertyTag
|
||||||
@@ -17,7 +19,7 @@ internal interface IClosingPropertyTag : IPropertyTag
|
|||||||
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
||||||
/// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param>
|
/// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param>
|
||||||
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
||||||
bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag);
|
bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ConditionalTagCollection<TClass> : TagCollection
|
public class ConditionalTagCollection<TClass> : TagCollection
|
||||||
@@ -37,6 +39,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
|
|||||||
|
|
||||||
private class ConditionalTag : TagBase, IClosingPropertyTag
|
private class ConditionalTag : TagBase, IClosingPropertyTag
|
||||||
{
|
{
|
||||||
|
public override Regex NameMatcher { get; }
|
||||||
public Regex NameCloseMatcher { get; }
|
public Regex NameCloseMatcher { get; }
|
||||||
|
|
||||||
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
|
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
|
||||||
@@ -46,7 +49,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
|
|||||||
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag)
|
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
|
||||||
{
|
{
|
||||||
var match = NameCloseMatcher.Match(templateString);
|
var match = NameCloseMatcher.Match(templateString);
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager.NamingTemplate;
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
public class NamingTemplate
|
public class NamingTemplate
|
||||||
{
|
{
|
||||||
public string TemplateText { get; private set; }
|
public string TemplateText { get; private set; } = string.Empty;
|
||||||
public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse;
|
public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse;
|
||||||
public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
|
public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
|
||||||
public IEnumerable<string> Warnings => errors.Concat(warnings);
|
public IEnumerable<string> Warnings => errors.Concat(warnings);
|
||||||
public IEnumerable<string> Errors => errors;
|
public IEnumerable<string> Errors => errors;
|
||||||
|
|
||||||
private Delegate templateToString;
|
private Delegate? templateToString;
|
||||||
private readonly List<string> warnings = new();
|
private readonly List<string> warnings = new();
|
||||||
private readonly List<string> errors = new();
|
private readonly List<string> errors = new();
|
||||||
private readonly IEnumerable<TagCollection> TagCollections;
|
private readonly IEnumerable<TagCollection> TagCollections;
|
||||||
@@ -30,6 +32,9 @@ public class NamingTemplate
|
|||||||
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
|
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
|
||||||
public TemplatePart Evaluate(params object[] propertyClasses)
|
public TemplatePart Evaluate(params object[] propertyClasses)
|
||||||
{
|
{
|
||||||
|
if (templateToString is null)
|
||||||
|
throw new InvalidOperationException();
|
||||||
|
|
||||||
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
|
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
|
||||||
// First parameter is "this", so ignore it.
|
// First parameter is "this", so ignore it.
|
||||||
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
|
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
|
||||||
@@ -39,7 +44,7 @@ public class NamingTemplate
|
|||||||
if (args.Length != delegateArgTypes.Count())
|
if (args.Length != delegateArgTypes.Count())
|
||||||
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
|
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
|
||||||
|
|
||||||
return ((TemplatePart)templateToString.DynamicInvoke(args)).FirstPart;
|
return (templateToString.DynamicInvoke(args) as TemplatePart)!.FirstPart;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary>
|
/// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary>
|
||||||
@@ -69,7 +74,7 @@ public class NamingTemplate
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary>
|
/// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary>
|
||||||
private static Expression GetExpressionTree(BinaryNode node)
|
private static Expression GetExpressionTree(BinaryNode? node)
|
||||||
{
|
{
|
||||||
if (node is null) return TemplatePart.Blank;
|
if (node is null) return TemplatePart.Blank;
|
||||||
else if (node.IsValue) return node.Expression;
|
else if (node.IsValue) return node.Expression;
|
||||||
@@ -81,10 +86,10 @@ public class NamingTemplate
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary>
|
/// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary>
|
||||||
private BinaryNode IntermediateParse(string templateString)
|
private BinaryNode IntermediateParse(string? templateString)
|
||||||
{
|
{
|
||||||
if (templateString is null)
|
if (templateString is null)
|
||||||
throw new NullReferenceException(ERROR_NULL_IS_INVALID);
|
throw new ArgumentException(ERROR_NULL_IS_INVALID);
|
||||||
else if (string.IsNullOrEmpty(templateString))
|
else if (string.IsNullOrEmpty(templateString))
|
||||||
warnings.Add(WARNING_EMPTY);
|
warnings.Add(WARNING_EMPTY);
|
||||||
else if (string.IsNullOrWhiteSpace(templateString))
|
else if (string.IsNullOrWhiteSpace(templateString))
|
||||||
@@ -93,12 +98,12 @@ public class NamingTemplate
|
|||||||
TemplateText = templateString;
|
TemplateText = templateString;
|
||||||
|
|
||||||
BinaryNode topNode = BinaryNode.CreateRoot();
|
BinaryNode topNode = BinaryNode.CreateRoot();
|
||||||
BinaryNode currentNode = topNode;
|
BinaryNode? currentNode = topNode;
|
||||||
List<char> literalChars = new();
|
List<char> literalChars = new();
|
||||||
|
|
||||||
while (templateString.Length > 0)
|
while (templateString.Length > 0)
|
||||||
{
|
{
|
||||||
if (StartsWith(templateString, out string exactPropertyName, out var propertyTag, out var valueExpression))
|
if (StartsWith(templateString, out var exactPropertyName, out var propertyTag, out var valueExpression))
|
||||||
{
|
{
|
||||||
checkAndAddLiterals();
|
checkAndAddLiterals();
|
||||||
|
|
||||||
@@ -116,7 +121,7 @@ public class NamingTemplate
|
|||||||
{
|
{
|
||||||
checkAndAddLiterals();
|
checkAndAddLiterals();
|
||||||
|
|
||||||
BinaryNode lastParenth = currentNode;
|
BinaryNode? lastParenth = currentNode;
|
||||||
|
|
||||||
while (lastParenth?.IsConditional is false)
|
while (lastParenth?.IsConditional is false)
|
||||||
lastParenth = lastParenth.Parent;
|
lastParenth = lastParenth.Parent;
|
||||||
@@ -168,7 +173,7 @@ public class NamingTemplate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool StartsWith(string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression)
|
private bool StartsWith(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? valueExpression)
|
||||||
{
|
{
|
||||||
foreach (var pc in TagCollections)
|
foreach (var pc in TagCollections)
|
||||||
{
|
{
|
||||||
@@ -182,7 +187,7 @@ public class NamingTemplate
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool StartsWithClosing(string template, out string exactName, out IClosingPropertyTag closingPropertyTag)
|
private bool StartsWithClosing(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
|
||||||
{
|
{
|
||||||
foreach (var pc in TagCollections)
|
foreach (var pc in TagCollections)
|
||||||
{
|
{
|
||||||
@@ -198,36 +203,36 @@ public class NamingTemplate
|
|||||||
private class BinaryNode
|
private class BinaryNode
|
||||||
{
|
{
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
public BinaryNode Parent { get; private set; }
|
public BinaryNode? Parent { get; private set; }
|
||||||
public BinaryNode RightChild { get; private set; }
|
public BinaryNode? RightChild { get; private set; }
|
||||||
public BinaryNode LeftChild { get; private set; }
|
public BinaryNode? LeftChild { get; private set; }
|
||||||
public Expression Expression { get; private init; }
|
public Expression Expression { get; }
|
||||||
public bool IsConditional { get; private init; } = false;
|
public bool IsConditional { get; private init; } = false;
|
||||||
public bool IsValue { get; private init; } = false;
|
public bool IsValue { get; private init; } = false;
|
||||||
|
|
||||||
public static BinaryNode CreateRoot() => new("Root");
|
public static BinaryNode CreateRoot() => new("Root", Expression.Empty());
|
||||||
|
|
||||||
public static BinaryNode CreateValue(string literal) => new("Literal")
|
public static BinaryNode CreateValue(string literal)
|
||||||
{
|
=> new("Literal", TemplatePart.CreateLiteral(literal))
|
||||||
IsValue = true,
|
{
|
||||||
Expression = TemplatePart.CreateLiteral(literal)
|
IsValue = true
|
||||||
};
|
};
|
||||||
|
|
||||||
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
|
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property)
|
||||||
{
|
=> new(templateTag.TagName, TemplatePart.CreateProperty(templateTag, property))
|
||||||
IsValue = true,
|
{
|
||||||
Expression = TemplatePart.CreateProperty(templateTag, property)
|
IsValue = true
|
||||||
};
|
};
|
||||||
|
|
||||||
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
|
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property)
|
||||||
{
|
=> new(templateTag.TagName, property)
|
||||||
IsConditional = true,
|
{
|
||||||
Expression = property
|
IsConditional = true
|
||||||
};
|
};
|
||||||
|
|
||||||
private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right)
|
private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right)
|
||||||
{
|
{
|
||||||
var newNode = new BinaryNode("Concatenation")
|
var newNode = new BinaryNode("Concatenation", Expression.Empty())
|
||||||
{
|
{
|
||||||
LeftChild = left,
|
LeftChild = left,
|
||||||
RightChild = right
|
RightChild = right
|
||||||
@@ -237,7 +242,12 @@ public class NamingTemplate
|
|||||||
return newNode;
|
return newNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private BinaryNode(string name) => Name = name;
|
private BinaryNode(string name, Expression expression)
|
||||||
|
{
|
||||||
|
Name = name;
|
||||||
|
Expression = expression;
|
||||||
|
}
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
|
|
||||||
public BinaryNode AddNewNode(BinaryNode newNode)
|
public BinaryNode AddNewNode(BinaryNode newNode)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Linq;
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager.NamingTemplate;
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString);
|
public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString);
|
||||||
@@ -37,7 +38,7 @@ public class PropertyTagCollection<TClass> : TagCollection
|
|||||||
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
|
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
|
||||||
/// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default
|
/// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default
|
||||||
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
|
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
|
||||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty> formatter = null)
|
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
|
||||||
where TProperty : struct
|
where TProperty : struct
|
||||||
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
|
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ public class PropertyTagCollection<TClass> : TagCollection
|
|||||||
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
|
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
|
||||||
/// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default
|
/// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default
|
||||||
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
|
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
|
||||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty> formatter = null)
|
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
|
||||||
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
|
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,14 +73,15 @@ public class PropertyTagCollection<TClass> : TagCollection
|
|||||||
=> RegisterWithToString(templateTag, propertyGetter, toString);
|
=> RegisterWithToString(templateTag, propertyGetter, toString);
|
||||||
|
|
||||||
private void RegisterWithFormatter<TProperty, TPropertyValue>
|
private void RegisterWithFormatter<TProperty, TPropertyValue>
|
||||||
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue> formatter)
|
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue>? formatter)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
|
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
|
||||||
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
|
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
|
||||||
|
|
||||||
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
|
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
|
||||||
|
formatter ??= GetDefaultFormatter<TPropertyValue>();
|
||||||
|
|
||||||
if ((formatter ??= GetDefaultFormatter<TPropertyValue>()) is null)
|
if (formatter is null)
|
||||||
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc));
|
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc));
|
||||||
else
|
else
|
||||||
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
|
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
|
||||||
@@ -97,7 +99,7 @@ public class PropertyTagCollection<TClass> : TagCollection
|
|||||||
|
|
||||||
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
|
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
|
||||||
|
|
||||||
private PropertyFormatter<T> GetDefaultFormatter<T>()
|
private PropertyFormatter<T>? GetDefaultFormatter<T>()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -109,6 +111,7 @@ public class PropertyTagCollection<TClass> : TagCollection
|
|||||||
|
|
||||||
private class PropertyTag<TPropertyValue> : TagBase
|
private class PropertyTag<TPropertyValue> : TagBase
|
||||||
{
|
{
|
||||||
|
public override Regex NameMatcher { get; }
|
||||||
private Func<Expression, string, Expression> CreateToStringExpression { get; }
|
private Func<Expression, string, Expression> CreateToStringExpression { get; }
|
||||||
|
|
||||||
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter)
|
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager.NamingTemplate;
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
internal interface IPropertyTag
|
internal interface IPropertyTag
|
||||||
@@ -22,13 +24,13 @@ internal interface IPropertyTag
|
|||||||
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
||||||
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
|
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
|
||||||
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
||||||
bool StartsWith(string templateString, out string exactName, out Expression propertyValue);
|
bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal abstract class TagBase : IPropertyTag
|
internal abstract class TagBase : IPropertyTag
|
||||||
{
|
{
|
||||||
public ITemplateTag TemplateTag { get; }
|
public ITemplateTag TemplateTag { get; }
|
||||||
public Regex NameMatcher { get; protected init; }
|
public abstract Regex NameMatcher { get; }
|
||||||
public Type ReturnType => ValueExpression.Type;
|
public Type ReturnType => ValueExpression.Type;
|
||||||
protected Expression ValueExpression { get; }
|
protected Expression ValueExpression { get; }
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ internal abstract class TagBase : IPropertyTag
|
|||||||
/// <param name="formatter">The optional format string in the match inside the square brackets</param>
|
/// <param name="formatter">The optional format string in the match inside the square brackets</param>
|
||||||
protected abstract Expression GetTagExpression(string exactName, string formatter);
|
protected abstract Expression GetTagExpression(string exactName, string formatter);
|
||||||
|
|
||||||
public bool StartsWith(string templateString, out string exactName, out Expression propertyValue)
|
public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
|
||||||
{
|
{
|
||||||
var match = NameMatcher.Match(templateString);
|
var match = NameMatcher.Match(templateString);
|
||||||
if (match.Success)
|
if (match.Success)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections;
|
using System.Collections;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager.NamingTemplate;
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
|
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
|
||||||
@@ -32,7 +34,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
|
|||||||
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
||||||
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
|
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
|
||||||
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
|
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
|
||||||
internal bool StartsWith(string templateString, out string exactName, out IPropertyTag propertyTag, out Expression propertyValue)
|
internal bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? propertyValue)
|
||||||
{
|
{
|
||||||
foreach (var p in PropertyTags)
|
foreach (var p in PropertyTags)
|
||||||
{
|
{
|
||||||
@@ -57,7 +59,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
|
|||||||
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
||||||
/// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param>
|
/// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param>
|
||||||
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
||||||
internal bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag closingPropertyTag)
|
internal bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
|
||||||
{
|
{
|
||||||
foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>())
|
foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileManager.NamingTemplate;
|
namespace FileManager.NamingTemplate;
|
||||||
|
|
||||||
/// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary>
|
/// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary>
|
||||||
@@ -15,13 +16,13 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
|||||||
|
|
||||||
/// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is
|
/// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is
|
||||||
/// a registered property, otherwise <see cref="null"/> for string literals. </summary>
|
/// a registered property, otherwise <see cref="null"/> for string literals. </summary>
|
||||||
public ITemplateTag TemplateTag { get; }
|
public ITemplateTag? TemplateTag { get; }
|
||||||
|
|
||||||
/// <summary>The evaluated string.</summary>
|
/// <summary>The evaluated string.</summary>
|
||||||
public string Value { get; }
|
public string Value { get; }
|
||||||
|
|
||||||
private TemplatePart previous;
|
private TemplatePart? previous;
|
||||||
private TemplatePart next;
|
private TemplatePart? next;
|
||||||
private TemplatePart(string name, string value)
|
private TemplatePart(string name, string value)
|
||||||
{
|
{
|
||||||
TagName = name;
|
TagName = name;
|
||||||
@@ -53,14 +54,33 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
|||||||
private static Expression CreateExpression(string name, Expression value)
|
private static Expression CreateExpression(string name, Expression value)
|
||||||
=> Expression.New(constructorInfo, Expression.Constant(name), value);
|
=> Expression.New(constructorInfo, Expression.Constant(name), value);
|
||||||
|
|
||||||
private static readonly ConstructorInfo constructorInfo
|
private static readonly ConstructorInfo constructorInfo;
|
||||||
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(string), typeof(string) });
|
private static readonly ConstructorInfo tagTemplateConstructorInfo;
|
||||||
|
private static readonly MethodInfo addMethodInfo;
|
||||||
|
static TemplatePart()
|
||||||
|
{
|
||||||
|
var type = typeof(TemplatePart);
|
||||||
|
|
||||||
private static readonly ConstructorInfo tagTemplateConstructorInfo
|
if (type.GetConstructor(
|
||||||
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(ITemplateTag), typeof(string) });
|
BindingFlags.NonPublic | BindingFlags.Instance,
|
||||||
|
new Type[] { typeof(string), typeof(string) }) is not ConstructorInfo c1)
|
||||||
|
throw new MissingMethodException(nameof(TemplatePart));
|
||||||
|
|
||||||
private static readonly MethodInfo addMethodInfo
|
if (type.GetConstructor(
|
||||||
= typeof(TemplatePart).GetMethod(nameof(Concatenate), BindingFlags.NonPublic | BindingFlags.Static, new Type[] { typeof(TemplatePart), typeof(TemplatePart) });
|
BindingFlags.NonPublic | BindingFlags.Instance,
|
||||||
|
new Type[] { typeof(ITemplateTag), typeof(string) }) is not ConstructorInfo c2)
|
||||||
|
throw new MissingMethodException(nameof(TemplatePart));
|
||||||
|
|
||||||
|
if (type.GetMethod(
|
||||||
|
nameof(Concatenate),
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static,
|
||||||
|
new Type[] { typeof(TemplatePart), typeof(TemplatePart) }) is not MethodInfo m1)
|
||||||
|
throw new MissingMethodException(nameof(Concatenate));
|
||||||
|
|
||||||
|
constructorInfo = c1;
|
||||||
|
tagTemplateConstructorInfo = c2;
|
||||||
|
addMethodInfo = m1;
|
||||||
|
}
|
||||||
|
|
||||||
public IEnumerator<TemplatePart> GetEnumerator()
|
public IEnumerator<TemplatePart> GetEnumerator()
|
||||||
{
|
{
|
||||||
|
|||||||
Loaded 100 of 530 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user