mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-28 00:18:18 -05:00
Compare commits
268 Commits
v12.4.10.2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c91d359017 | ||
|
|
7dfdc0688a | ||
|
|
c6c36c74f1 | ||
|
|
d932b57853 | ||
|
|
a29da7318b | ||
|
|
678c3e6bcd | ||
|
|
a8466e38d4 | ||
|
|
802ccf25e8 | ||
|
|
c243b9c913 | ||
|
|
4d47ab3ebe | ||
|
|
8dea6200ce | ||
|
|
a578777352 | ||
|
|
e58e5165cf | ||
|
|
87c2cb6e19 | ||
|
|
4f44c26b57 | ||
|
|
03534773ab | ||
|
|
37f223fb77 | ||
|
|
cf932bd66c | ||
|
|
f0dc33a01e | ||
|
|
315d76e061 | ||
|
|
6e78145adc | ||
|
|
200a334f86 | ||
|
|
4dd4a1495a | ||
|
|
b3ce0e0af0 | ||
|
|
1299d91d08 | ||
|
|
ad3a767057 | ||
|
|
a59c73caf8 | ||
|
|
442a688b85 | ||
|
|
0c85ea4d11 | ||
|
|
03ed8e6b57 | ||
|
|
3eca508a26 | ||
|
|
770adf33f3 | ||
|
|
1087ffb150 | ||
|
|
f620234e7d | ||
|
|
2b6b5d082e | ||
|
|
cbbc45c3c5 | ||
|
|
28de1a6cb6 | ||
|
|
1615c6ef77 | ||
|
|
6961bd72fa | ||
|
|
68846a90e5 | ||
|
|
d60ec0702c | ||
|
|
1c55c8533a | ||
|
|
6fa69b603e | ||
|
|
3df8a97463 | ||
|
|
0bd7bd80b9 | ||
|
|
13bb4238b4 | ||
|
|
d5021e4f74 | ||
|
|
5e1458cfb4 | ||
|
|
e1d4533887 | ||
|
|
c1bd1d983b | ||
|
|
b567c38a98 | ||
|
|
348ec22465 | ||
|
|
90bb4d9176 | ||
|
|
7944154ea6 | ||
|
|
01fc7f3fb9 | ||
|
|
b70f973994 | ||
|
|
98d3f85579 | ||
|
|
bdae155af6 | ||
|
|
c8b44193ac | ||
|
|
9545b3a874 | ||
|
|
e932c9fab9 | ||
|
|
c8f4c1e751 | ||
|
|
0303db153f | ||
|
|
a7e9479eab | ||
|
|
d339dbc906 | ||
|
|
5fe6f931ad | ||
|
|
ca9fe9fc32 | ||
|
|
986dbd678f | ||
|
|
ea3716f48a | ||
|
|
426d5a87b4 | ||
|
|
c893bbe52e | ||
|
|
ad5a9874af | ||
|
|
3b70c08439 | ||
|
|
a230605ed5 | ||
|
|
d48ce39773 | ||
|
|
368e695214 | ||
|
|
9c3881c67d | ||
|
|
4c5fdf05f5 | ||
|
|
4bd491f5b9 | ||
|
|
c34b1e752e | ||
|
|
fa30c10435 | ||
|
|
cdb91ae2ca | ||
|
|
7852067b81 | ||
|
|
3708515df9 | ||
|
|
530aca4f4d | ||
|
|
cf571148bc | ||
|
|
2c2a720ba9 | ||
|
|
b577ef7187 | ||
|
|
ffbb3c3516 | ||
|
|
2a6cf38677 | ||
|
|
d8104a4d7c | ||
|
|
af85ea9219 | ||
|
|
c30e149a36 | ||
|
|
050a4867b7 | ||
|
|
2bf6f7a4f2 | ||
|
|
788a768271 | ||
|
|
022a6e979d | ||
|
|
9fc5a7d834 | ||
|
|
b72e5039b1 | ||
|
|
e992b49da2 | ||
|
|
74afbbf581 | ||
|
|
d82ffe1467 | ||
|
|
8a84a083d1 | ||
|
|
04827f81da | ||
|
|
805f42b1cc | ||
|
|
b9a1709284 | ||
|
|
b0a40e12b7 | ||
|
|
dfbc5ec9db | ||
|
|
649ef5f864 | ||
|
|
4345bf2ee2 | ||
|
|
441d430dea | ||
|
|
85ee0bcddb | ||
|
|
0f1fc0f11d | ||
|
|
75aa17df11 | ||
|
|
913019cdfd | ||
|
|
a55da5f187 | ||
|
|
f9ac0253fb | ||
|
|
fde78f4167 | ||
|
|
22159b79d8 | ||
|
|
7fe170acdf | ||
|
|
c0898a288b | ||
|
|
ce2b81036f | ||
|
|
65d24ce223 | ||
|
|
e8c911e603 | ||
|
|
59f66ff480 | ||
|
|
e05dcd6f54 | ||
|
|
d1ce9d5a83 | ||
|
|
2213f5c86a | ||
|
|
d6b232f342 | ||
|
|
f29c19beb8 | ||
|
|
9f6d08fc1f | ||
|
|
717dfcd923 | ||
|
|
a3d181b2ec | ||
|
|
d16eeea56b | ||
|
|
5d2513ec33 | ||
|
|
e5043dcf40 | ||
|
|
c61bfb4134 | ||
|
|
d47a2595b9 | ||
|
|
55e74db4fb | ||
|
|
0a171222bc | ||
|
|
c2093157ca | ||
|
|
8e073800cd | ||
|
|
1daf07b882 | ||
|
|
27a23a16d6 | ||
|
|
c878b9fec0 | ||
|
|
7a01f075ac | ||
|
|
23d391485d | ||
|
|
46be532740 | ||
|
|
e2fd88d075 | ||
|
|
bb0dea3fa9 | ||
|
|
def0b1f611 | ||
|
|
bfee579719 | ||
|
|
d4139861f3 | ||
|
|
ba15eb1a95 | ||
|
|
6263fedf84 | ||
|
|
0cbffc3f6c | ||
|
|
5f093b06ec | ||
|
|
f815c5fd47 | ||
|
|
69a8eaad4a | ||
|
|
01b5c18b2b | ||
|
|
5634fee2aa | ||
|
|
e98e4f10bc | ||
|
|
ec32ff77b2 | ||
|
|
683c984246 | ||
|
|
0fa5c4eb1e | ||
|
|
7507044b82 | ||
|
|
017902ab52 | ||
|
|
dcc5c1c640 | ||
|
|
19efa8c918 | ||
|
|
a34efb5e61 | ||
|
|
9533f80e89 | ||
|
|
fa238a0915 | ||
|
|
f98adef9e9 | ||
|
|
d85e5a0f98 | ||
|
|
365ac8167f | ||
|
|
4720779373 | ||
|
|
0c512162ab | ||
|
|
09ca419faf | ||
|
|
a2b1f13601 | ||
|
|
f4e7cf3418 | ||
|
|
8492a7ea3a | ||
|
|
1b5db9b28f | ||
|
|
5589a6cbd5 | ||
|
|
bfbad988c0 | ||
|
|
8db8615713 | ||
|
|
e8fa3f14b3 | ||
|
|
71552f2417 | ||
|
|
5b96f96a80 | ||
|
|
afffeb953c | ||
|
|
f4d8685058 | ||
|
|
b4cfd18976 | ||
|
|
e12548dacd | ||
|
|
fd95ac7a9c | ||
|
|
f7cd2b106b | ||
|
|
07e51f2191 | ||
|
|
fcd79c5561 | ||
|
|
ba98820989 | ||
|
|
b07e61e6a8 | ||
|
|
8c3fd19c70 | ||
|
|
fa8f761771 | ||
|
|
2c882e883d | ||
|
|
74c76a7414 | ||
|
|
17a0c21453 | ||
|
|
fc9c9dfe48 | ||
|
|
d5f0e39981 | ||
|
|
0f6493f4af | ||
|
|
454b490a06 | ||
|
|
ffea2648aa | ||
|
|
1ac967500c | ||
|
|
39e9f675d2 | ||
|
|
ed5afe5d0f | ||
|
|
ab075d0bef | ||
|
|
7fb1adb41b | ||
|
|
9735a8391c | ||
|
|
dbdfdbc536 | ||
|
|
3b86fc405f | ||
|
|
4ea7f04921 | ||
|
|
5b59b442ab | ||
|
|
b5d9c0a27a | ||
|
|
f5cbf89e13 | ||
|
|
00dc9e020d | ||
|
|
bfa0e4d338 | ||
|
|
5ceda408da | ||
|
|
716b1923a4 | ||
|
|
1148d8125d | ||
|
|
690fd10e42 | ||
|
|
736fbbf82f | ||
|
|
eda100b7ac | ||
|
|
ceb007500d | ||
|
|
05fad01624 | ||
|
|
e1d789ccdc | ||
|
|
d0f00f3f1e | ||
|
|
6ab82dba7b | ||
|
|
0045202334 | ||
|
|
4c80813651 | ||
|
|
6b637b35ab | ||
|
|
9b55ffa715 | ||
|
|
65da7890f1 | ||
|
|
72f92ec6c0 | ||
|
|
4efc084375 | ||
|
|
f955daa5ed | ||
|
|
144ab2162a | ||
|
|
6d0c4a9b3c | ||
|
|
8a682533c1 | ||
|
|
cecabc911e | ||
|
|
e35f5209dc | ||
|
|
4ffe70af0e | ||
|
|
233ba3184f | ||
|
|
ac4c168725 | ||
|
|
db588629c0 | ||
|
|
29be091a4b | ||
|
|
82a48db57b | ||
|
|
9f0f32a462 | ||
|
|
f64239b5ee | ||
|
|
bc8a35aedd | ||
|
|
2fca6b8b91 | ||
|
|
bc2eddd2dd | ||
|
|
ae012548bd | ||
|
|
76a59873ea | ||
|
|
3129fdba7b | ||
|
|
1771813849 | ||
|
|
7024bbf823 | ||
|
|
663f70b8bf | ||
|
|
7741e3caff | ||
|
|
c82eefa768 | ||
|
|
0e4231906a | ||
|
|
9bca84dca4 | ||
|
|
ca30fd41c6 |
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
27
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -6,10 +6,14 @@ labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking an attached log file may get limited or delayed help.
|
||||
|
||||
___
|
||||
|
||||
## Describe the bug
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
## To Reproduce
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
@@ -17,14 +21,23 @@ Steps to reproduce the behavior:
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
## Expected behavior
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
## Screenshots
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Platform**
|
||||
## Platform
|
||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||
|
||||
**Log Files**
|
||||
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'. If your user folder contains the file "LibationCrash.log", attach that also.
|
||||
## Log Files
|
||||
Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
|
||||
|
||||
**Default Log File Locations**
|
||||
|Platform|Folder|
|
||||
|-|-|
|
||||
|Windows|`%userprofile%\Libation`|
|
||||
|macOS|`~/Library/Application Support/Libation`|
|
||||
|Linux|`~/.local/share/Libation`|
|
||||
|
||||
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
|
||||
|
||||
127
.github/workflows/build-linux.yml
vendored
127
.github/workflows/build-linux.yml
vendored
@@ -6,63 +6,46 @@ name: build
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
libation-version:
|
||||
type: string
|
||||
description: "Version number override"
|
||||
required: false
|
||||
run_unit_tests:
|
||||
required: true
|
||||
dotnet-version:
|
||||
type: string
|
||||
required: true
|
||||
run-unit-tests:
|
||||
type: boolean
|
||||
description: "Skip running unit tests"
|
||||
required: false
|
||||
default: true
|
||||
runs_on:
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
retention-days:
|
||||
type: number
|
||||
architecture:
|
||||
type: string
|
||||
description: "The GitHub hosted runner to use"
|
||||
description: "CPU architecture targeted by the build."
|
||||
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:
|
||||
DOTNET_CONFIGURATION: "Release"
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
RELEASE_NAME: "chardonnay"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
|
||||
runs-on: ${{ inputs.runs_on }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUNTIME_ID: "linux-${{ inputs.architecture }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
env:
|
||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
inputVersion="${{ inputs.version_override }}"
|
||||
if [[ "${#inputVersion}" -gt 0 ]]
|
||||
then
|
||||
version="${inputVersion}"
|
||||
else
|
||||
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||
fi
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
- name: Unit test
|
||||
if: ${{ inputs.run_unit_tests }}
|
||||
if: ${{ inputs.run-unit-tests }}
|
||||
working-directory: ./Source
|
||||
run: dotnet test
|
||||
|
||||
@@ -70,63 +53,31 @@ jobs:
|
||||
id: publish
|
||||
working-directory: ./Source
|
||||
run: |
|
||||
if [[ "${{ inputs.OS }}" == "MacOS" ]]
|
||||
then
|
||||
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 \
|
||||
LibationAvalonia/LibationAvalonia.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
LibationCli/LibationCli.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
dotnet publish \
|
||||
HangoverAvalonia/HangoverAvalonia.csproj \
|
||||
--runtime $RUNTIME_ID \
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} \
|
||||
--output $OUTPUT \
|
||||
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
|
||||
PUBLISH_ARGS=(
|
||||
'--runtime' '${{ env.RUNTIME_ID }}'
|
||||
'--configuration' 'Release'
|
||||
'--output' '../bin'
|
||||
'-p:PublishProtocol=FileSystem'
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
|
||||
'-p:SelfContained=true')
|
||||
|
||||
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
|
||||
- name: Build bundle
|
||||
id: bundle
|
||||
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
|
||||
run: |
|
||||
BUNDLE_DIR=$(pwd)
|
||||
echo "Bundle dir: ${BUNDLE_DIR}"
|
||||
cd ..
|
||||
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
|
||||
SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
|
||||
chmod +rx ${SCRIPT}
|
||||
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
|
||||
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
|
||||
artifact=$(ls ./bundle)
|
||||
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Publish bundle
|
||||
uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.bundle.outputs.artifact }}
|
||||
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
|
||||
path: ./bundle/${{ steps.bundle.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
104
.github/workflows/build-mac.yml
vendored
Normal file
104
.github/workflows/build-mac.yml
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
# build-mac.yml
|
||||
# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation.
|
||||
---
|
||||
name: build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
libation-version:
|
||||
type: string
|
||||
required: true
|
||||
dotnet-version:
|
||||
type: string
|
||||
required: true
|
||||
run-unit-tests:
|
||||
type: boolean
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
retention-days:
|
||||
type: number
|
||||
sign-app:
|
||||
type: boolean
|
||||
description: "Wheather to sign an notorize the app bundle and dmg."
|
||||
architecture:
|
||||
type: string
|
||||
description: "CPU architecture targeted by the build."
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "macOS-${{ inputs.architecture }}"
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RUNTIME_ID: "osx-${{ inputs.architecture }}"
|
||||
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
|
||||
steps:
|
||||
- uses: apple-actions/import-codesign-certs@v6
|
||||
if: ${{ inputs.sign-app }}
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
|
||||
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
- name: Unit test
|
||||
if: ${{ inputs.run-unit-tests }}
|
||||
working-directory: ./Source
|
||||
run: dotnet test
|
||||
|
||||
- name: Publish
|
||||
id: publish
|
||||
working-directory: ./Source
|
||||
run: |
|
||||
PUBLISH_ARGS=(
|
||||
'--runtime' '${{ env.RUNTIME_ID }}'
|
||||
'--configuration' 'Release'
|
||||
'--output' '../bin'
|
||||
'-p:PublishProtocol=FileSystem'
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
|
||||
'-p:SelfContained=true')
|
||||
|
||||
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
|
||||
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
|
||||
|
||||
- name: Build bundle
|
||||
id: bundle
|
||||
run: |
|
||||
SCRIPT=./Scripts/Bundle_MacOS.sh
|
||||
chmod +rx ${SCRIPT}
|
||||
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}"
|
||||
artifact=$(ls ./bundle)
|
||||
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Notarize bundle
|
||||
if: ${{ inputs.sign-app }}
|
||||
run: |
|
||||
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
|
||||
WAIT="--wait"
|
||||
fi
|
||||
echo "::debug::Submitting the disk image for notarization"
|
||||
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1)
|
||||
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
|
||||
|
||||
echo "$RESPONSE"
|
||||
echo "::notice::Noraty Submission Id: $SUBMISSION_ID"
|
||||
|
||||
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
|
||||
echo "::debug::Stapling the notarization ticket to the disk image"
|
||||
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
|
||||
fi
|
||||
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.bundle.outputs.artifact }}
|
||||
path: ./bundle/${{ steps.bundle.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
121
.github/workflows/build-windows.yml
vendored
121
.github/workflows/build-windows.yml
vendored
@@ -6,113 +6,82 @@ name: build
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
libation-version:
|
||||
type: string
|
||||
description: "Version number override"
|
||||
required: false
|
||||
run_unit_tests:
|
||||
type: boolean
|
||||
description: "Skip running unit tests"
|
||||
required: false
|
||||
default: true
|
||||
architecture:
|
||||
type: string
|
||||
description: "CPU architecture targeted by the build."
|
||||
required: true
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: "Release"
|
||||
DOTNET_VERSION: "9.0.x"
|
||||
dotnet-version:
|
||||
type: string
|
||||
required: true
|
||||
run-unit-tests:
|
||||
type: boolean
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
retention-days:
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})"
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
RUNTIME_ID: "win-${{ inputs.architecture }}"
|
||||
strategy:
|
||||
matrix:
|
||||
os: [Windows]
|
||||
ui: [Avalonia]
|
||||
architecture: [x64]
|
||||
release_name: [chardonnay]
|
||||
include:
|
||||
- os: Windows
|
||||
- architecture: x64
|
||||
ui: WinForms
|
||||
release_name: classic
|
||||
prefix: Classic-
|
||||
- architecture: arm64
|
||||
ui: Avalonia
|
||||
release_name: chardonnay
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
env:
|
||||
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
if ("${{ inputs.version_override }}".length -gt 0) {
|
||||
$version = "${{ inputs.version_override }}"
|
||||
} else {
|
||||
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
|
||||
}
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
- name: Unit test
|
||||
if: ${{ inputs.run_unit_tests }}
|
||||
if: ${{ inputs.run-unit-tests }}
|
||||
working-directory: ./Source
|
||||
run: dotnet test
|
||||
|
||||
|
||||
- name: Publish
|
||||
working-directory: ./Source
|
||||
run: |
|
||||
dotnet publish `
|
||||
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
LibationCli/LibationCli.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:DefineConstants="${{ matrix.release_name }}" `
|
||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
dotnet publish `
|
||||
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||
--runtime ${{ env.RUNTIME_ID }} `
|
||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||
$PUBLISH_ARGS=@(
|
||||
"--runtime", "win-${{ matrix.architecture }}",
|
||||
"--configuration", "Release",
|
||||
"--output", "../bin",
|
||||
"-p:PublishProtocol=FileSystem",
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
|
||||
"-p:SelfContained=true")
|
||||
|
||||
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
|
||||
- name: Zip artifact
|
||||
id: zip
|
||||
working-directory: ./Source/bin/Publish
|
||||
working-directory: ./bin
|
||||
run: |
|
||||
$bin_dir = "${{ env.OUTPUT_NAME }}\"
|
||||
$delfiles = @(
|
||||
"WindowsConfigApp.exe",
|
||||
"WindowsConfigApp.runtimeconfig.json",
|
||||
"WindowsConfigApp.deps.json"
|
||||
)
|
||||
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||
"WindowsConfigApp.deps.json")
|
||||
|
||||
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
||||
Compress-Archive -Path * -DestinationPath "$artifact"
|
||||
|
||||
- name: Publish artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: ${{ steps.zip.outputs.artifact }}.zip
|
||||
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
||||
name: ${{ steps.zip.outputs.artifact }}
|
||||
path: ./bin/${{ steps.zip.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
72
.github/workflows/build.yml
vendored
72
.github/workflows/build.yml
vendored
@@ -6,26 +6,51 @@ name: build
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version_override:
|
||||
libation-version:
|
||||
type: string
|
||||
description: "Version number override"
|
||||
required: false
|
||||
run_unit_tests:
|
||||
description: "Libation version number"
|
||||
required: true
|
||||
dotnet-version:
|
||||
type: string
|
||||
default: "10.x"
|
||||
description: ".NET version to target"
|
||||
run-unit-tests:
|
||||
type: boolean
|
||||
description: "Skip running unit tests"
|
||||
required: false
|
||||
default: true
|
||||
description: "Whether to run unit tests prior to publishing."
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
description: "Whether to publish assemblies as ReadyToRun."
|
||||
release:
|
||||
type: boolean
|
||||
description: "Whether this workflow is being called as a release"
|
||||
retention-days:
|
||||
type: number
|
||||
description: "Number of days the artifacts are to be retained."
|
||||
|
||||
jobs:
|
||||
jobs:
|
||||
windows:
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [x64]
|
||||
uses: ./.github/workflows/build-windows.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
libation-version: ${{ inputs.libation-version }}
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
run-unit-tests: ${{ inputs.run-unit-tests }}
|
||||
publish-r2r: ${{ inputs.publish-r2r }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
macOS:
|
||||
strategy:
|
||||
matrix:
|
||||
architecture: [x64, arm64]
|
||||
uses: ./.github/workflows/build-mac.yml
|
||||
with:
|
||||
libation-version: ${{ inputs.libation-version }}
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
run-unit-tests: ${{ inputs.run-unit-tests }}
|
||||
publish-r2r: ${{ inputs.publish-r2r }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
linux:
|
||||
strategy:
|
||||
@@ -34,20 +59,11 @@ jobs:
|
||||
architecture: [x64, arm64]
|
||||
uses: ./.github/workflows/build-linux.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
runs_on: ubuntu-latest
|
||||
libation-version: ${{ inputs.libation-version }}
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
run-unit-tests: ${{ inputs.run-unit-tests }}
|
||||
publish-r2r: ${{ inputs.publish-r2r }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
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 }}
|
||||
|
||||
67
.github/workflows/deploy.yml
vendored
Normal file
67
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Deploy VitePress site to Pages
|
||||
|
||||
on:
|
||||
# Runs on pushes targeting the `main` branch. Change this to `master` if you're
|
||||
# using the `master` branch as the default branch.
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- .github/workflows/deploy.yml
|
||||
- docs/**
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Build job
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
# - uses: pnpm/action-setup@v4 # Uncomment this block if you're using pnpm
|
||||
# with:
|
||||
# version: 9 # Not needed if you've set "packageManager" in package.json
|
||||
# - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
- name: Install dependencies
|
||||
run: npm ci # or pnpm install / yarn install / bun install
|
||||
- name: Build with VitePress
|
||||
run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: .vitepress/dist
|
||||
|
||||
# Deployment job
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
- "v*"
|
||||
jobs:
|
||||
prerelease:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
@@ -31,16 +31,18 @@ jobs:
|
||||
build:
|
||||
needs: [prerelease]
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
version_override: ${{ needs.prerelease.outputs.version }}
|
||||
run_unit_tests: false
|
||||
libation-version: ${{ needs.prerelease.outputs.version }}
|
||||
publish-r2r: true
|
||||
release: true
|
||||
|
||||
release:
|
||||
needs: [prerelease, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: "*(Classic-)Libation.*"
|
||||
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Check the MetaInfo file
|
||||
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||
|
||||
2
.github/workflows/validate-desktop-file.yaml
vendored
2
.github/workflows/validate-desktop-file.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
validate-desktop-file:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- run: sudo apt --yes install desktop-file-utils
|
||||
- name: Check the desktop file
|
||||
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||
|
||||
27
.github/workflows/validate.yml
vendored
27
.github/workflows/validate.yml
vendored
@@ -6,16 +6,41 @@ name: validate
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- Source/**
|
||||
- .github/workflows/**
|
||||
pull_request:
|
||||
branches: [master]
|
||||
paths:
|
||||
- Source/**
|
||||
- .github/workflows/**
|
||||
|
||||
jobs:
|
||||
get_version:
|
||||
runs-on: ubuntu-slim
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
steps:
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj"
|
||||
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||
build:
|
||||
needs: [get_version]
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
libation-version: ${{ needs.get_version.outputs.version }}
|
||||
retention-days: 14
|
||||
run-unit-tests: true
|
||||
|
||||
docker:
|
||||
needs: [get_version]
|
||||
uses: ./.github/workflows/docker.yml
|
||||
with:
|
||||
version: ${GITHUB_SHA}
|
||||
version: ${{ needs.get_version.outputs.version }}
|
||||
release: false
|
||||
secrets:
|
||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -370,4 +370,15 @@ FodyWeavers.xsd
|
||||
|
||||
/__TODO.txt
|
||||
/DataLayer/LibationContext.db
|
||||
*/bin-Avalonia
|
||||
*/bin-Avalonia
|
||||
|
||||
# macOS Directory Info
|
||||
.DS_Store
|
||||
|
||||
# JetBrains Rider Settings
|
||||
**/.idea/
|
||||
|
||||
# VitePress
|
||||
node_modules
|
||||
.vitepress/cache
|
||||
.vitepress/dist
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
|
||||
"WindowsAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-arm64\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
|
||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
|
||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
|
||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
|
||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
|
||||
}
|
||||
|
||||
109
.vitepress/config.js
Normal file
109
.vitepress/config.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { defineConfig } from "vitepress";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
title: "Libation",
|
||||
description:
|
||||
"Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
|
||||
head: [["link", { rel: "icon", href: "/favicon.ico" }]],
|
||||
cleanUrls: true,
|
||||
themeConfig: {
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
logo: {
|
||||
light: "/libation_logo_light.svg",
|
||||
dark: "/libation_logo_dark.svg",
|
||||
},
|
||||
|
||||
footer: {
|
||||
message: "Released under the GPLv3 License",
|
||||
},
|
||||
|
||||
editLink: {
|
||||
pattern: "https://github.com/rmcrackan/Libation/edit/main/:path",
|
||||
},
|
||||
|
||||
lastUpdated: true,
|
||||
|
||||
nav: [
|
||||
{ text: "Getting Started", link: "/docs/getting-started" },
|
||||
{ text: "Docs", link: "/docs/index" },
|
||||
{
|
||||
text: "Download",
|
||||
link: "https://github.com/rmcrackan/Libation/releases/latest",
|
||||
},
|
||||
{
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
items: [
|
||||
{ text: "Overview", link: "/docs/index" },
|
||||
{ text: "Getting Started", link: "/docs/getting-started" },
|
||||
{ text: "FAQ", link: "/docs/frequently-asked-questions" },
|
||||
{
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Installation",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: "Linux", link: "/docs/installation/linux" },
|
||||
{ text: "Mac", link: "/docs/installation/mac" },
|
||||
{ text: "Docker", link: "/docs/installation/docker" },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Features",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Audio File Formats",
|
||||
link: "/docs/features/audio-file-formats",
|
||||
},
|
||||
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
|
||||
{
|
||||
text: "Searching & Filtering",
|
||||
link: "/docs/features/searching-and-filtering",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Advanced",
|
||||
collapsed: false,
|
||||
items: [{ text: "Advanced Topics", link: "/docs/advanced/advanced" }],
|
||||
},
|
||||
{
|
||||
text: "Development",
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: "Getting Started",
|
||||
link: "/docs/development/getting-started",
|
||||
},
|
||||
{ text: "Contribute", link: "/docs/development/contribute" },
|
||||
{ text: "Website & Docs", link: "/docs/development/website" },
|
||||
{ text: "Linux Setup (Nix)", link: "/docs/development/nix-linux-setup" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
outline: {
|
||||
level: "deep",
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
{ icon: "github", link: "https://github.com/rmcrackan/Libation" },
|
||||
],
|
||||
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
},
|
||||
});
|
||||
15
.vitepress/theme/custom.css
Normal file
15
.vitepress/theme/custom.css
Normal file
@@ -0,0 +1,15 @@
|
||||
/* Custom styles for Libation documentation */
|
||||
|
||||
/* Hide certain nav items on tablet devices to prevent horizontal scroll */
|
||||
@media (min-width: 640px) and (max-width: 959px) {
|
||||
/* Target specific nav items by their position */
|
||||
/* Hide "Issues & Requests" and "Donate" links on tablet */
|
||||
.VPNav .VPNavBar .nav .VPNavBarMenu .VPMenu:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Alternative: Use a more specific selector if needed */
|
||||
.VPNavBarMenuLink[href*="issues"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
4
.vitepress/theme/index.js
Normal file
4
.vitepress/theme/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import DefaultTheme from 'vitepress/theme'
|
||||
import './custom.css'
|
||||
|
||||
export default DefaultTheme
|
||||
13
.vscode/launch.json
vendored
13
.vscode/launch.json
vendored
@@ -6,7 +6,7 @@
|
||||
"configurations": [
|
||||
|
||||
{
|
||||
"name": ".NET Core Launch (console)",
|
||||
"name": ".NET Core Launch (console) Windows",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build",
|
||||
@@ -15,6 +15,17 @@
|
||||
"cwd": "${workspaceFolder}",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole"
|
||||
},
|
||||
{
|
||||
"name": ".NET Core Launch (console) Linux",
|
||||
"type": "coreclr",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "build_linux",
|
||||
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"stopAtEntry": false,
|
||||
"console": "internalConsole"
|
||||
}
|
||||
|
||||
]
|
||||
|
||||
17
.vscode/tasks.json
vendored
17
.vscode/tasks.json
vendored
@@ -37,6 +37,23 @@
|
||||
//"reveal": "silent"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "build_linux",
|
||||
"type": "shell",
|
||||
"command": "dotnet",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj",
|
||||
"-p:TargetFramework=net9.0",
|
||||
"-p:TargetFrameworks=net9.0",
|
||||
"-p:RuntimeIdentifier=linux-x64"
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
//"reveal": "silent"
|
||||
},
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
Directory.Build.props
Normal file
5
Directory.Build.props
Normal file
@@ -0,0 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -1,16 +1,19 @@
|
||||
# Dockerfile
|
||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG TARGETARCH
|
||||
|
||||
COPY Source /Source
|
||||
RUN dotnet publish \
|
||||
/Source/LibationCli/LibationCli.csproj \
|
||||
--os linux \
|
||||
--arch ${TARGETARCH} \
|
||||
--configuration Release \
|
||||
--output /Source/bin/Publish/Linux-chardonnay \
|
||||
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
||||
-p:PublishProtocol=FileSystem \
|
||||
-p:PublishReadyToRun=true \
|
||||
-p:SelfContained=true
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:9.0
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0
|
||||
ARG USER_UID=1001
|
||||
ARG USER_GID=1001
|
||||
|
||||
|
||||
@@ -1,111 +1 @@
|
||||
## [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**.
|
||||
|
||||
|
||||
|
||||
# Advanced: Table of Contents
|
||||
|
||||
- [Files and folders](#files-and-folders)
|
||||
- [Settings](#settings)
|
||||
- [Custom File Naming](NamingTemplates.md)
|
||||
- [Command Line Interface](#command-line-interface)
|
||||
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
|
||||
|
||||
|
||||
|
||||
### Files and folders
|
||||
|
||||
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
|
||||
|
||||
* In Libation's initial folder are the files that make up the program. Since nothing else is here, just copy new files here to upgrade the program. Delete this folder to delete Libation.
|
||||
|
||||
* In a separate folder, Libation keeps track of all of the files it creates like settings and downloaded images. After an upgrade, Libation might think that's its being run for the first time. Just click ADVANCED SETUP and point to this folder. Libation will reload your library and settings.
|
||||
|
||||
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||
|
||||
### Settings
|
||||
|
||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
||||
|
||||
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
|
||||
|
||||
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
||||
|
||||
Warnings about relying solely on on the CLI:
|
||||
* CLI will not perform any upgrades.
|
||||
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
|
||||
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
|
||||
|
||||
```
|
||||
help
|
||||
libationcli --help
|
||||
|
||||
verb-specific help
|
||||
libationcli scan --help
|
||||
|
||||
scan all libraries
|
||||
libationcli scan
|
||||
scan only libraries for specific accounts
|
||||
libationcli scan nickname1 nickname2
|
||||
|
||||
convert all m4b files to mp3
|
||||
libationcli convert
|
||||
|
||||
liberate all books and pdfs
|
||||
libationcli liberate
|
||||
liberate pdfs only
|
||||
libationcli liberate --pdf
|
||||
libationcli liberate -p
|
||||
|
||||
export library to file
|
||||
libationcli export --path "C:\foo\bar\my.json" --json
|
||||
libationcli export -p "C:\foo\bar\my.json" -j
|
||||
libationcli export -p "C:\foo\bar\my.csv" --csv
|
||||
libationcli export -p "C:\foo\bar\my.csv" -c
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
||||
|
||||
Set download statuses throughout library based on whether each book's audio file can be found.
|
||||
Must include at least one flag: --downloaded , --not-downloaded.
|
||||
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
|
||||
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
|
||||
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
|
||||
CLI: Full library. No prompt
|
||||
|
||||
libationcli set-status -d
|
||||
libationcli set-status -n
|
||||
libationcli set-status -d -n
|
||||
```
|
||||
### Custom Theme Colors
|
||||
|
||||
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
|
||||
|
||||
#### Theme Editor Window
|
||||
|
||||
The theme editor has a list of style names and their currently assigned colors. To change a style color, click on the color swatch in the left-hand column to open the color editor for that style. Observe the color changes in real-time on the built-in preview panel on the right-hand side of the theme editor.
|
||||
|
||||
You may import or export themes using the buttons at the bottom-left of the theme editor.
|
||||
"Cancel" or closing the window will revert any changes you've made in the theme editor.
|
||||
"Reset" will reset any changes you've made in the theme editor.
|
||||
"Defaults" will restore the application default colors for the active theme ("Light" or "Dark")
|
||||
"Save" will save the theme colors to the ChardonnayTheme.json file and close the editor.
|
||||
|
||||
Note: you may only edit the currently applied theme ("Light" or "Dark").
|
||||
|
||||
#### Video Walkthrough
|
||||
The below video demonstrates using the theme editor to make changes to the Dark theme color pallet.
|
||||
|
||||
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
|
||||
|
||||
|
||||
# This page has been moved to https://getlibation.com/docs/advanced/advanced
|
||||
1
Documentation/AudioFileFormats.md
Normal file
1
Documentation/AudioFileFormats.md
Normal file
@@ -0,0 +1 @@
|
||||
# This page has been moved to https://getlibation.com/docs/features/audio-file-formats
|
||||
@@ -1,76 +1 @@
|
||||
## [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**.
|
||||
|
||||
> [!WARNING]
|
||||
> ## Breaking Changes
|
||||
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them or see the User section below for other options, or if you're not sure.
|
||||
> * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior.
|
||||
> * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable.
|
||||
|
||||
# Disclaimer
|
||||
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||
|
||||
### Configuration
|
||||
Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values.
|
||||
|
||||
### Running
|
||||
Once the configuration files are copied, the docker image can be run with the following command.
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation:latest
|
||||
```
|
||||
|
||||
By default the container will scan for new books once and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. For example, if you pass in `10m` it will keep running, scan for new books, and download them every 10 minutes.
|
||||
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
-e SLEEP_TIME='10m' \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation:latest
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
| Env Var | Default | Description |
|
||||
| -------- | ------- | ----------- |
|
||||
| SLEEP_TIME | -1 | Length of time to sleep before doing another scan/download. Set to -1 to run one. |
|
||||
| LIBATION_BOOKS_DIR | /data | Folder where books will be saved |
|
||||
| LIBATION_CONFIG_DIR | /config | Folder to read configuration from. |
|
||||
| LIBATION_DB_DIR | /db | Optional folder to load database from. If not mounted, will load database from `LIBATION_CONFIG_DIR`. |
|
||||
| LIBATION_DB_FILE | | Name of database file to load. By default it will look for all `.db` files and load one if there is only one present. |
|
||||
| LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. |
|
||||
|
||||
### User
|
||||
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors, including [sqlite error](#1060), [Microsoft.Data.Sqlite.SqliteException](#1110), [unable to open database file](#1113), [Microsoft.EntityFrameworkCore.DbUpdateException](#1049)
|
||||
|
||||
If you're not sure what your user number is, check the output of the `id` command. Docker should normally run with the number of the user who configured and ran it.
|
||||
|
||||
If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following:
|
||||
|
||||
```
|
||||
sudo docker run -d \
|
||||
-u 2000:3000 \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation:latest
|
||||
```
|
||||
|
||||
If the user it's running as is correct, and it still cannot write, be sure to check whether the files and/or folders might be owned by the wrong user. You can use the `chown` command to change the owner of the file to the correct user and group number, for example: `chown -R 1001:1001 /mnt/audiobooks /mnt/libation-config`
|
||||
|
||||
### Advanced Database Options
|
||||
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
|
||||
|
||||
### Getting help
|
||||
As mentioned above: docker is not officially supported. I'm adding this at the bottom of the page for anyone serious enough to have read this far. If you've tried everything above and would still like help, you can open an [issue](https://github.com/rmcrackan/Libation/issues). Please include `[docker]` in the title. There are also some docker folks who have offered occasional assistance who you can tag within your issue: `@ducamagnifico` , `@wtanksleyjr` , `@CLHatch`.
|
||||
|
||||
**Reminder** that these are just friendly users who are sometimes around. They're *not* our customer support.
|
||||
# This page has been moved to https://getlibation.com/docs/installation/docker
|
||||
@@ -1,46 +1 @@
|
||||
## [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: Where can I get help for my specific problem?
|
||||
|
||||
**A:** [You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
|
||||
## 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: I'm having trouble loggin into my Brazil account.
|
||||
|
||||
For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
|
||||
|
||||
## 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)
|
||||
# This page has been moved to https://getlibation.com/docs/frequently-asked-questions
|
||||
@@ -1,155 +1 @@
|
||||
## [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**.
|
||||
|
||||
|
||||
|
||||
# Getting started: Table of Contents
|
||||
|
||||
- [Download Libation](#download-libation-1)
|
||||
- [Installation](#installation)
|
||||
- [Create Accounts](#create-accounts)
|
||||
- [Import your library](#import-your-library)
|
||||
- [Download your books -- DRM-free!](#download-your-books----drm-free)
|
||||
- [Download PDF attachments](#download-pdf-attachments)
|
||||
- [Details of downloaded files](#details-of-downloaded-files)
|
||||
- [Export your library](#export-your-library)
|
||||
- [I still need help](#i-still-need-help)
|
||||
|
||||
|
||||
|
||||
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
|
||||
|
||||
##### Which version? Chardonnay vs Classic
|
||||
|
||||
Nearly 100% of the difference is look and feel -- it's a matter of preference.
|
||||
|
||||
Chardonnay has an updated look and will work and look the same on Windows, Mac, and Linux.
|
||||
Classic is Windows only. It has an older look because it's built with older, duller, and more mature technology. This tech has built into it better support for things like accessibility for screen readers.
|
||||
|
||||
### Installation
|
||||
|
||||
* Windows
|
||||
|
||||
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
|
||||
|
||||
* [Linux](InstallOnLinux.md)
|
||||
* [MacOS](InstallOnMac.md)
|
||||
|
||||
### Create Accounts
|
||||
|
||||
Create your account(s):
|
||||
|
||||

|
||||
|
||||
New locale options include many more regions including old audible accounts which pre-date the amazon acquisition
|
||||
|
||||

|
||||
|
||||
### Import your library
|
||||
|
||||
Be default, Libation will periodically scan the accounts you added above with a checkbox next to them. Nothing for you to do. You can also scan manually.
|
||||
|
||||
Select Import > Scan Library:
|
||||
|
||||

|
||||
|
||||
Or if you have multiple accounts, you'll get to choose whether to scan all accounts or just the ones you select:
|
||||
|
||||

|
||||
|
||||
If this is a new installation, or you're scanning an account you haven't scanned before, you'll be prompted to enter your password for the Audible account.
|
||||
|
||||

|
||||
|
||||
Enter the password and click Submit. Audible will prompt you with a CAPTCHA image.
|
||||
|
||||

|
||||
|
||||
Enter the CAPTCHA answer characters and click Submit. If all has gone well, Libation will start scanning the account.
|
||||
|
||||
In rare instances, the Captcha image/response will fail in an endless loop. If this happens, delete the problem account, and then click Save. Re-add the account and click Save again. Now try to scan the account again. This time, instead of typing your password, click the link that says "Or click here". This will open the Audible External Login dialog shown below.
|
||||
|
||||

|
||||
|
||||
You can either copy the URL shown and paste it into your browser or launch the browser directly by clicking Launch in Browser. Audible will display its standard login page. Login, including answering the CAPTCHA on the next page. In some cases, you might have to approve the login from the email account associated with that login, but once the login is successful, you'll see an error message.
|
||||
|
||||

|
||||
|
||||
This actually means you've successfully logged in. Copy the entire URL shown in your browser and return to Libation. Paste that URL into the text box at the bottom of the Audible External Login window and click Submit.
|
||||
|
||||
You'll see this window while it's scanning:
|
||||
|
||||

|
||||
|
||||
Success! We see how many new titles are imported:
|
||||
|
||||

|
||||
|
||||
### Download your books -- DRM-free!
|
||||
|
||||
Automatically download some or all of your audible books. This shows you how much of your library is not yet downloaded and decrypted:
|
||||
|
||||
The stoplights will tell you a title's status:
|
||||
|
||||
* Green: downloaded and decrypted
|
||||
* Yellow: downloaded but still encrypted with DRM
|
||||
* Red: not downloaded
|
||||
* PDF icon without arrow: downloaded
|
||||
* PDF with arrow: not downloaded
|
||||
|
||||
Or hover over the button to see the status.
|
||||
|
||||

|
||||
|
||||
Select Liberate > Begin Book Backups
|
||||
|
||||
You can also click on the stop light to download only that title and its PDF
|
||||
|
||||

|
||||
|
||||
First the original book with DRM is downloaded
|
||||
|
||||

|
||||
|
||||
Then it's decrypted so you can use it on any device you choose. The very first time you decrypt a book, this step will take a while. Every other book will go much faster. The first time, Libation has to figure out the special decryption key which allows your personal books to be unlocked.
|
||||
|
||||

|
||||
|
||||
And voila! If you have multiple books not yet liberated, Libation will automatically move on to the next.
|
||||
|
||||

|
||||
|
||||
The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||
|
||||
### Download PDF attachments
|
||||
|
||||
For books which include PDF downloads, Libation can download these for you as well and will attempt to store them with the book. "Book backup" will already download an available PDF. This additional option is useful when Audible adds a PDF to your book after you've already backed it up.
|
||||
|
||||
Select Liberate > Begin PDF Backups
|
||||
|
||||

|
||||
|
||||
The downloads work just like with books, only with no additional decryption needed.
|
||||
|
||||

|
||||
|
||||
### Details of downloaded files
|
||||
|
||||

|
||||
|
||||
When you set up Libation, you'll specify a Books directory. Libation looks inside that directory and all subdirectories to look for files or folders with each library book's audible id. This way, organization is completely up to you. When you download + decrypt a book, you get several files
|
||||
|
||||
* .m4b: your audiobook in m4b format. This is the most pure version of your audiobook and retains the highest quality. Now that it's decrypted, you can play it on any audio player and put it on any device. If you'd like, you can also use 3rd party tools to turn it into an mp3. The freedom to do what you want with your files was the original inspiration for Libation.
|
||||
* .cue: this is a file which logs where chapter breaks occur. Many tools are able to use this if you want to split your book into files along chapter lines.
|
||||
|
||||
### Export your library
|
||||
|
||||

|
||||
|
||||
Export your library to Excel, CSV, or JSON
|
||||
|
||||
### I still need help
|
||||
|
||||
[You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
# This page has been moved to https://getlibation.com/docs/getting-started
|
||||
@@ -1,67 +1 @@
|
||||
## [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**.
|
||||
|
||||
|
||||
## Packaging status
|
||||
|
||||
[](https://repology.org/project/libation/versions)
|
||||
|
||||
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the [Libation repository's releases page](https://github.com/rmcrackan/Libation/releases).
|
||||
|
||||
Run these commands in your terminal to download and install Libation. **Make sure you replace** `X.X.X` with the latest Libation version and `ARCH` with your CPU's architechture (either `amd64` or `arm64`).
|
||||
|
||||
### Debian
|
||||
```Console
|
||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.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-ARCH.rpm
|
||||
sudo yum install ./libation.rpm
|
||||
```
|
||||
### Fedora
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
|
||||
sudo dnf5 install ./libation.rpm
|
||||
```
|
||||
---
|
||||
### Arch Linux
|
||||
```Console
|
||||
yay -S libation
|
||||
```
|
||||
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
|
||||
|
||||
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
|
||||
### NixOS
|
||||
- Install via `nix-shell`
|
||||
```Console
|
||||
nix-shell -p libation
|
||||
```
|
||||
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
|
||||
- Install via NixOS configuration
|
||||
```Console
|
||||
environment.systemPackages = [
|
||||
pkgs.libation
|
||||
];
|
||||
```
|
||||
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
|
||||
- On NixOS via via `nix-env`
|
||||
```Console
|
||||
nix-env -iA nixos.libation
|
||||
```
|
||||
- On Non NixOS via `nix-env`
|
||||
```Console
|
||||
nix-env -iA nixpkgs.libation
|
||||
```
|
||||
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
|
||||
|
||||
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
|
||||
|
||||
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.
|
||||
|
||||
Report bugs to https://github.com/rmcrackan/Libation/issues
|
||||
# This page has been moved to https://getlibation.com/docs/installation/linux
|
||||
@@ -1,82 +1 @@
|
||||
## [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**.
|
||||
|
||||
|
||||
|
||||
# Run Libation on MacOS
|
||||
This walkthrough should get you up and running with Libation on your Mac.
|
||||
|
||||
## Supports macOS 13 (Ventura) and above
|
||||
|
||||
## Install Libation
|
||||
|
||||
- Download the file from the latest release and extract it.
|
||||
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
|
||||
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||
- Move the extracted Libation app bundle to your applications folder.
|
||||
- Right-click on Libation and then click on open
|
||||
- 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
|
||||
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!
|
||||
|
||||
## "Apple can't check app for malicious software"
|
||||
|
||||
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
|
||||
|
||||
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
|
||||
* Go to Security, then click Open.
|
||||
* Click Open Anyway. This button is available for about an hour after you try to open the app.
|
||||
* Enter your login password, then click OK.
|
||||
|
||||
## 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
|
||||
|
||||
Libation comes with a recovery app called Hangover. You can start it by running this command:
|
||||
```Console
|
||||
open /Applications/Libation.app --args hangover
|
||||
```
|
||||
|
||||
## 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:
|
||||
```Console
|
||||
open /Applications/Libation.app --args cli
|
||||
```
|
||||
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
|
||||
|
||||
Then use `./LibationCli` to execute a command.
|
||||
|
||||
## Get Libation running on Mac
|
||||
|
||||
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
|
||||
# This page has been moved to https://getlibation.com/docs/installation/mac
|
||||
1
Documentation/LinuxDevelopmentSetupUsingNix.md
Normal file
1
Documentation/LinuxDevelopmentSetupUsingNix.md
Normal file
@@ -0,0 +1 @@
|
||||
# This page has been moved to https://getlibation.com/docs/development/nix-linux-setup
|
||||
@@ -1,156 +1 @@
|
||||
## [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
|
||||
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
|
||||
|
||||
These templates apply to both GUI and CLI.
|
||||
|
||||
# Table of Contents
|
||||
|
||||
- [Template Tags](#template-tags)
|
||||
- [Property Tags](#property-tags)
|
||||
- [Conditional Tags](#conditional-tags)
|
||||
- [Tag Formatters](#tag-formatters)
|
||||
- [Text Formatters](#text-formatters)
|
||||
- [Series Formatters](#series-formatters)
|
||||
- [Series List Formatters](#series-list-formatters)
|
||||
- [Name Formatters](#name-formatters)
|
||||
- [Name List Formatters](#name-list-formatters)
|
||||
- [Number Formatters](#number-formatters)
|
||||
- [Date Formatters](#date-formatters)
|
||||
|
||||
|
||||
# Template Tags
|
||||
|
||||
These are the naming template tags currently supported by Libation.
|
||||
|
||||
## Property Tags
|
||||
These tags will be replaced in the template with the audiobook's values.
|
||||
|
||||
|Tag|Description|Type|
|
||||
|-|-|-|
|
||||
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|
||||
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|
||||
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|
||||
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|
||||
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|
||||
|\<first author\>|First author|[Name](#name-formatters)|
|
||||
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|
||||
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|
||||
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
||||
|\<first series\>|First series|[Series](#series-formatters)|
|
||||
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|
||||
|\<channels\>|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)|
|
||||
|\<codec\>|Audio codec of the last downloaded audiobook|[Text](#text-formatters)|
|
||||
|\<file version\>|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)|
|
||||
|\<libation version\>|Libation version used during last download of the audiobook|[Text](#text-formatters)|
|
||||
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|
||||
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||
|\<year\>|Year published|[Number](#number-formatters)|
|
||||
|\<language\>|Book's language|[Text](#text-formatters)|
|
||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|
||||
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|
||||
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|
||||
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|
||||
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|
||||
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|
||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
|
||||
|
||||
**†** Does not support custom formatting
|
||||
|
||||
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
|
||||
|
||||
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
|
||||
|
||||
## Conditional Tags
|
||||
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|
||||
|
||||
|Tag|Description|Type|
|
||||
|-|-|-|
|
||||
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|
||||
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||
|\<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.
|
||||
|
||||
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
|
||||
|
||||
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
||||
|
||||
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
|
||||
|
||||
|
||||
# Tag Formatters
|
||||
**Text**, **Name List**, **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
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
||||
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
||||
|
||||
## Series Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1, B08376S3R2|
|
||||
|
||||
## Series List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|separator()|Speficy the text used to join<br>multiple series names.<br><br>Default is ", "|`<series[separator(; )]>`|Sherlock Holmes; Some Other Series|
|
||||
|format(\{N \| # \| ID\})|Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above.|`<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<author[format({L}, {ID}) separator(; )]>`|Sherlock Holmes, 1; Some Other Series, 1<hr>herlock Holmes, B08376S3R2; Some Other Series, B000000000|
|
||||
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
|
||||
|
||||
## Name Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\{T \| F \| M \| L \| S \| ID\}|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>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|
||||
|
||||
## Name List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|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 \| ID\})|Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above.|`<author[format({L}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>`|Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|
||||
|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|
|
||||
|
||||
## 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|
|
||||
|-|-|-|-|
|
||||
|\[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
|
||||
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||
### Standard DateTime Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|
||||
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
|
||||
|
||||
### Custom DateTime Formatters
|
||||
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|
||||
|yy|2-digit year|\<file date[yy]\>|23|
|
||||
|MM|2-digit month|\<file date[MM]\>|02|
|
||||
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|
||||
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
|
||||
|
||||
|
||||
# This page has been moved to https://getlibation.com/docs/features/naming-templates
|
||||
@@ -1,79 +1 @@
|
||||
## [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**.
|
||||
|
||||
|
||||
|
||||
# Searching and filtering: Table of Contents
|
||||
|
||||
- [Tags](#tags)
|
||||
- [Searches](#searches)
|
||||
- [Search examples](#search-examples)
|
||||
- [Filters](#filters)
|
||||
|
||||
|
||||
|
||||
### Tags
|
||||
|
||||
To add tags to a title, click the tags button
|
||||
|
||||

|
||||
|
||||
Add as many tags as you'd like. Tags are separated by a space. Each tag can contain letters, numbers, and underscores
|
||||
|
||||

|
||||
|
||||
Tags are saved non-case specific for easy search. There is one special tag "hidden" which will also grey-out the book
|
||||
|
||||

|
||||
|
||||
To edit tags, just click the button again.
|
||||
|
||||
### Searches
|
||||
|
||||
Libation's advanced searching is built on the powerful Lucene search engine. Simple searches are effortless and powerful searches are simple. To search, just type and click Filter or press enter
|
||||
|
||||
* Type anything in the search box to search common fields: title, authors, narrators, and the book's audible id
|
||||
* Use Lucene's "Query Parser Syntax" for advanced searching.
|
||||
* Easy tutorial: http://www.lucenetutorial.com/lucene-query-syntax.html
|
||||
* Full official guide: https://lucene.apache.org/core/2_9_4/queryparsersyntax.html
|
||||
* Tons of search fields, specific to audiobooks
|
||||
* Synonyms so you don't have to memorize magic words. Eg: author and author**s** will both work
|
||||
* Click [?] button for a full list of search fields and synonyms 
|
||||
* Search by tag like \[this\]
|
||||
* When tags have an underscore you can use part of the tag. This is useful for quick categories. The below examples make this more clear.
|
||||
|
||||
### Search examples
|
||||
|
||||
Search for anything with the word potter
|
||||
|
||||

|
||||
|
||||
If you only want to see Harry Potter
|
||||
|
||||

|
||||
|
||||
If you only want to see potter except for Harry Potter. You can also use "-" instead of "NOT"
|
||||
|
||||

|
||||

|
||||
|
||||
To see only books written by Neil Gaiman where he also narrates his own book. (If you don't include AND, you'll see everything written by Neil Gaiman and also all books in your library which are self-narrated.)
|
||||
|
||||

|
||||
|
||||
I tagged autobiographies as auto_bio and biographies written by someone else as bio. I can get only autobiographies with \[auto_bio\] or get both by searching \[bio\]
|
||||
|
||||
![Search example: \[bio\]](images/SearchExampleBio.png)
|
||||
![Search example: \[auto_bio\]](images/SearchExampleAutoBio.png)
|
||||
|
||||
### Filters
|
||||
|
||||
If you have a search you want to save, click Add To Quick Filters to save it in your Quick Filters list. To use it again, select it from the Quick Filters list.
|
||||
|
||||
To edit this list go to Quick Filters > Edit quick filters. Here you can re-order the list, delete filters, double-click a filter to edit it, or double-click the bottom blank box to add a new filter.
|
||||
|
||||
Check "Quick Filters > Start Libation with 1st filter Default" to have your top filter automatically applied when Libation starts. In this top example, I want to always start without these: at books I've tagged hidden, books I've tagged as free_audible_originals, and books which I have rated.
|
||||
|
||||

|
||||
# This page has been moved to https://getlibation.com/docs/features/searching-and-filtering
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
93
README.md
93
README.md
@@ -1,74 +1,55 @@
|
||||
# Libation: Liberate your Library
|
||||
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
**Libation** is a free, open-source application for downloading and managing your Audible audiobooks. It decrypts your library, removes DRM, and lets you own your audiobooks forever.
|
||||
|
||||
### 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**.
|
||||
## Features
|
||||
|
||||
- **Unlock Your Library**: Download and remove DRM from your audiobooks.
|
||||
- **Cross-Platform**: Fully supported on Windows, macOS, and Linux.
|
||||
- **Region Support**: Works with Audible regions US, UK, Canada, Germany, France, Australia, Japan, India, and Spain.
|
||||
- **Advanced Organization**: Search, filter, and tag your books.
|
||||
- **Fast & Efficient**: Powered by AAXClean for fast decryption without heavy dependencies like ffmpeg.
|
||||
- **Import**: Easily import your existing library, including cover art.
|
||||
|
||||
## Getting started with Libation
|
||||
|
||||
# Table of Contents
|
||||
|
||||
- [Audible audiobook manager](#audible-audiobook-manager)
|
||||
- [The good](#the-good)
|
||||
- [The bad](#the-bad)
|
||||
- [The ugly](#the-ugly)
|
||||
- [Getting started](Documentation/GettingStarted.md)
|
||||
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
|
||||
- [Installation](Documentation/GettingStarted.md#installation)
|
||||
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
|
||||
- [Import your library](Documentation/GettingStarted.md#import-your-library)
|
||||
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
|
||||
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
||||
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
||||
- [Export your library](Documentation/GettingStarted.md#export-your-library)
|
||||
- If you still need help, [you can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
|
||||
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
|
||||
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
||||
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
||||
- [Search examples](Documentation/SearchingAndFiltering.md#search-examples)
|
||||
- [Filters](Documentation/SearchingAndFiltering.md#filters)
|
||||
- [Advanced](Documentation/Advanced.md)
|
||||
- [Files and folders](Documentation/Advanced.md#files-and-folders)
|
||||
- [Settings](Documentation/Advanced.md#settings)
|
||||
- [Custom File Naming](Documentation/NamingTemplates.md)
|
||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
|
||||
- [Docker](Documentation/Docker.md)
|
||||
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
||||
|
||||
## Getting started
|
||||
All documentation has been moved to our new site: [getlibation.com](https://getlibation.com). Or jump to the important bits:
|
||||
|
||||
* [Getting Started](https://getlibation.com/docs/getting-started)
|
||||
* [Download](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
* [Step-by-step walk-through](Documentation/GettingStarted.md)
|
||||
* [Issues, bugs, and requests](https://github.com/rmcrackan/Libation/issues)
|
||||
* [Documentation](https://getlibation.com/docs/index)
|
||||
|
||||
## Audible audiobook manager
|
||||
## Development
|
||||
|
||||
### The good
|
||||
Grab the latest release for your platform from the [Releases Page](https://github.com/rmcrackan/Libation/releases/latest).
|
||||
|
||||
* Import library from audible, including cover art
|
||||
* Download and remove DRM from all books
|
||||
* Download accompanying PDFs
|
||||
* Add tags to books for better organization
|
||||
* Powerful advanced search built on the Lucene search engine
|
||||
* Customizable saved filters for common searches
|
||||
* Open source
|
||||
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
|
||||
* Fully supported in Windows, Mac, and Linux
|
||||
## Documentation
|
||||
|
||||
<a name="theBad"/>
|
||||
Comprehensive documentation is available in the `docs` directory and on our [Documentation Site](https://getlibation.com/docs).
|
||||
|
||||
### The bad
|
||||
- [Getting Started](https://getlibation.com/docs/getting-started)
|
||||
- [FAQ](https://getlibation.com/docs/frequently-asked-questions)
|
||||
|
||||
* Large file size
|
||||
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
|
||||
## Development & Contributing
|
||||
|
||||
### The ugly
|
||||
We welcome contributions!
|
||||
|
||||
* Documentation? Yer lookin' at it
|
||||
* This is a single-developer personal passion project. Support, response, updates, enhancements, bug fixes etc are as my free time allows
|
||||
* I have a full-time job, a life, and a finite attention span. Therefore a lot of time can potentially go by with no improvements of any kind
|
||||
- **[Development Getting Started](https://getlibation.com/docs/development/getting-started)**: Setup your environment.
|
||||
- **[Contribute](https://getlibation.com/docs/development/contribute)**: How to contribute code.
|
||||
- **[Website & Docs](https://getlibation.com/docs/development/website)**: How to run and improve the documentation.
|
||||
- **[Linux Setup (Nix)](https://getlibation.com/docs/development/nix-linux-setup)**: Nix-based environment setup.
|
||||
|
||||
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
|
||||
## Community & Support
|
||||
|
||||
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
|
||||
- **[Issues](https://github.com/rmcrackan/Libation/issues)**: Report bugs or request features.
|
||||
- **[PayPal](https://paypal.me/mcrackan?locale.x=en_us)**: Support the project if you find it useful.
|
||||
|
||||
## License
|
||||
|
||||
Libation is released under the GPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
If you found this useful, tell a friend. If you found this REALLY useful, you can click here to PayPal.me
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.
|
||||
|
||||
@@ -28,14 +28,6 @@ then
|
||||
exit
|
||||
fi
|
||||
|
||||
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
|
||||
|
||||
if ! contains "$BIN_DIR" "$ARCH"
|
||||
then
|
||||
echo "This script must be called with a Libation binaries for ${ARCH}."
|
||||
exit
|
||||
fi
|
||||
|
||||
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
|
||||
|
||||
DEB_DIR=./deb
|
||||
@@ -113,6 +105,7 @@ Essential: no
|
||||
Priority: optional
|
||||
Maintainer: github.com/rmcrackan
|
||||
Description: liberate your audiobooks
|
||||
Recommends: libgtk-3-0, libwebkit2gtk-4.1-0
|
||||
" >> $FOLDER_DEBIAN/control
|
||||
|
||||
echo "Changing permissions for pre- and post-install files..."
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
BIN_DIR=$1; shift
|
||||
VERSION=$1; shift
|
||||
ARCH=$1; shift
|
||||
SIGN_WITH_KEY=$1; shift
|
||||
|
||||
if [ -z "$BIN_DIR" ]
|
||||
then
|
||||
@@ -28,12 +29,9 @@ then
|
||||
exit
|
||||
fi
|
||||
|
||||
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
|
||||
|
||||
if ! contains "$BIN_DIR" $ARCH
|
||||
if [ "$SIGN_WITH_KEY" != "true" ]
|
||||
then
|
||||
echo "This script must be called with a Libation binaries for ${ARCH}."
|
||||
exit
|
||||
echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
|
||||
fi
|
||||
|
||||
BUNDLE=./Libation.app
|
||||
@@ -74,6 +72,15 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||
echo "Moving Info.plist file..."
|
||||
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
|
||||
|
||||
echo "Moving Libation_DS_Store file..."
|
||||
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
|
||||
|
||||
echo "Moving background.png file..."
|
||||
mv $BUNDLE_MACOS/background.png ./background.png
|
||||
|
||||
echo "Moving background.png file..."
|
||||
mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
|
||||
|
||||
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
|
||||
echo "Set LSArchitecturePriority to $PLIST_ARCH"
|
||||
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
|
||||
@@ -81,27 +88,45 @@ sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
|
||||
echo "Set CFBundleVersion to $VERSION"
|
||||
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
||||
|
||||
|
||||
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||
|
||||
for n in "${delfiles[@]}"
|
||||
do
|
||||
echo "Deleting $n"
|
||||
rm $BUNDLE_MACOS/$n
|
||||
done
|
||||
|
||||
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
|
||||
DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
|
||||
|
||||
echo "Signing executables in: $BUNDLE"
|
||||
codesign --force --deep -s - $BUNDLE
|
||||
all_identities=$(security find-identity -v -p codesigning)
|
||||
identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
|
||||
|
||||
echo "Creating app bundle: $APP_FILE"
|
||||
tar -czvf $APP_FILE $BUNDLE
|
||||
if [ "$SIGN_WITH_KEY" == "true" ]; then
|
||||
echo "Signing executables in: $BUNDLE"
|
||||
codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE"
|
||||
codesign --verify --verbose "$BUNDLE"
|
||||
else
|
||||
echo "Signing with empty key: $BUNDLE"
|
||||
codesign --force --deep -s - $BUNDLE
|
||||
fi
|
||||
|
||||
mkdir bundle
|
||||
echo "moving to ./bundle/$APP_FILE"
|
||||
mv $APP_FILE ./bundle/$APP_FILE
|
||||
echo "Creating app disk image: $DMG_FILE"
|
||||
mkdir Libation
|
||||
mv $BUNDLE ./Libation/$BUNDLE
|
||||
mv Libation_DS_Store Libation/.DS_Store
|
||||
mkdir Libation/.background
|
||||
mv background.png Libation/.background/
|
||||
ln -s /Applications "./Libation/ "
|
||||
mkdir ./bundle
|
||||
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
|
||||
# Create a .DS_Store by:
|
||||
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
|
||||
# - Open the folder and edit it to your liking.
|
||||
# - Copy the .DS_Store from the directory and save it to Libation_DS_Store
|
||||
|
||||
rm -r $BUNDLE
|
||||
|
||||
if [ "$SIGN_WITH_KEY" == "true" ]; then
|
||||
echo "Signing $DMG_FILE"
|
||||
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
|
||||
fi
|
||||
|
||||
echo "Done!"
|
||||
|
||||
@@ -28,14 +28,6 @@ then
|
||||
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=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
@@ -62,7 +54,7 @@ License: GPLv3+
|
||||
URL: https://github.com/rmcrackan/Libation
|
||||
Source0: https://github.com/rmcrackan/Libation
|
||||
|
||||
Requires: bash
|
||||
Requires: bash gtk3 webkit2gtk4.1
|
||||
|
||||
|
||||
%define __os_install_post %{nil}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
@@ -13,7 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.2" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="2.1.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -126,8 +126,8 @@ namespace AaxDecrypter
|
||||
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());
|
||||
if (DownloadOptions.SeriesNumber is string part)
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
|
||||
}
|
||||
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
|
||||
@@ -26,7 +26,17 @@ namespace AaxDecrypter
|
||||
protected string OutputDirectory { get; }
|
||||
public IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||
protected virtual long InputFilePosition
|
||||
{
|
||||
get
|
||||
{
|
||||
//Use try/catch instread of checking CanRead to avoid
|
||||
//a race with the background download completing
|
||||
//between the check and the Position call.
|
||||
try { return InputFileStream.Position; }
|
||||
catch { return InputFileStream.Length; }
|
||||
}
|
||||
}
|
||||
private bool downloadFinished;
|
||||
|
||||
private NetworkFileStreamPersister? m_nfsPersister;
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace AaxDecrypter
|
||||
KeyPart2 = keyPart2;
|
||||
}
|
||||
|
||||
[Newtonsoft.Json.JsonConstructor]
|
||||
public KeyData(string keyPart1, string? keyPart2 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
|
||||
@@ -43,7 +44,7 @@ namespace AaxDecrypter
|
||||
string? Publisher { get; }
|
||||
string? Language { get; }
|
||||
string? SeriesName { get; }
|
||||
float? SeriesNumber { get; }
|
||||
string? SeriesNumber { get; }
|
||||
NAudio.Lame.LameConfig? LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
using AAXClean.Codecs;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public static class MpegUtil
|
||||
@@ -50,20 +52,22 @@ namespace AaxDecrypter
|
||||
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);
|
||||
}
|
||||
|
||||
//Copy over all other freeform tags
|
||||
foreach (var t in mp4File.AppleTags.AppleListBox.Tags.OfType<Mpeg4Lib.Boxes.FreeformTagBox>())
|
||||
{
|
||||
if (t.Name?.Name is string name &&
|
||||
t.Mean?.ReverseDnsDomain is string domain &&
|
||||
!lameConfig.ID3.UserDefinedText.ContainsKey(name) &&
|
||||
mp4File.AppleTags.AppleListBox.GetFreeformTagString(domain, name) is string tagStr &&
|
||||
!string.IsNullOrWhiteSpace(tagStr))
|
||||
lameConfig.ID3.UserDefinedText.Add(name, tagStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,6 +209,12 @@ namespace AaxDecrypter
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Don't throw from DownloadTask.
|
||||
//This task gets awaited in Dispose() and we don't want to have an unhandled exception there.
|
||||
Serilog.Log.Error(ex, "An error was encountered during the download process.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeFile.Dispose();
|
||||
@@ -306,7 +312,7 @@ namespace AaxDecrypter
|
||||
if (WritePosition > endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Serilog.Log.Information("Download was cancelled");
|
||||
}
|
||||
@@ -402,7 +408,7 @@ namespace AaxDecrypter
|
||||
*/
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !disposed)
|
||||
if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
|
||||
{
|
||||
_cancellationSource.Cancel();
|
||||
DownloadTask?.GetAwaiter().GetResult();
|
||||
@@ -413,7 +419,6 @@ namespace AaxDecrypter
|
||||
OnUpdate(waitForWrite: true);
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using FileManager;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>12.4.10.2</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.0.0.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||
<!-- Do not remove unused Serilog.Sinks -->
|
||||
<!-- Only File 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.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -24,7 +24,8 @@ namespace AppScaffolding
|
||||
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
|
||||
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
|
||||
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
|
||||
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64
|
||||
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64,
|
||||
WindowsAvalonia_Arm64 = OS.Windows | Variety.Chardonnay | Architecture.Arm64,
|
||||
}
|
||||
|
||||
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
||||
@@ -79,8 +80,18 @@ namespace AppScaffolding
|
||||
}
|
||||
|
||||
/// <summary>most migrations go in here</summary>
|
||||
public static void RunPostConfigMigrations(Configuration config)
|
||||
public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false)
|
||||
{
|
||||
if (ephemeralSettings)
|
||||
{
|
||||
var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath));
|
||||
config.LoadEphemeralSettings(settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
||||
}
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
|
||||
//
|
||||
@@ -93,6 +104,39 @@ namespace AppScaffolding
|
||||
Migrations.migrate_to_v12_0_1(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete shared memory and write-ahead log SQLite database files which may prevent access to the database.
|
||||
/// These file may or may not cause libation to hang on CreateContext,
|
||||
/// so try our luck by swallowing any exceptions and continuing.
|
||||
/// </summary>
|
||||
private static void DeleteOpenSqliteFiles(Configuration config)
|
||||
{
|
||||
var walFile = SqliteStorage.DatabasePath + "-wal";
|
||||
var shmFile = SqliteStorage.DatabasePath + "-shm";
|
||||
if (File.Exists(walFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
FileManager.FileUtility.SaferDelete(walFile);
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {@WalFile}", walFile);
|
||||
}
|
||||
}
|
||||
if (File.Exists(shmFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
FileManager.FileUtility.SaferDelete(shmFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {@ShmFile}", shmFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
||||
{
|
||||
@@ -121,7 +165,7 @@ namespace AppScaffolding
|
||||
zipFileSink["Name"] = "File";
|
||||
fileChanged = true;
|
||||
}
|
||||
var hooks = $"{nameof(LibationFileManager)}.{nameof(FileSinkHook)}, {nameof(LibationFileManager)}";
|
||||
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
|
||||
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
|
||||
{
|
||||
@@ -150,7 +194,7 @@ namespace AppScaffolding
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles, "Log.log") },
|
||||
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
@@ -158,7 +202,8 @@ namespace AppScaffolding
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
|
||||
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +278,7 @@ namespace AppScaffolding
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
#if DEBUG
|
||||
@@ -246,9 +292,11 @@ namespace AppScaffolding
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
|
||||
static int fileCount(FileManager.LongPath longPath)
|
||||
static int fileCount(FileManager.LongPath? longPath)
|
||||
{
|
||||
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||
if (longPath is null)
|
||||
return -1;
|
||||
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
@@ -288,8 +336,8 @@ namespace AppScaffolding
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
#nullable restore
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
@@ -432,8 +480,8 @@ namespace AppScaffolding
|
||||
const string FILENAME_V1 = "FileLocations.json";
|
||||
const string FILENAME_V2 = "FileLocationsV2.json";
|
||||
|
||||
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
|
||||
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
|
||||
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1);
|
||||
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
|
||||
|
||||
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using LibationFileManager;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace AppScaffolding
|
||||
{
|
||||
/// <summary>
|
||||
@@ -20,21 +21,21 @@ namespace AppScaffolding
|
||||
/// </summary>
|
||||
public static class UNSAFE_MigrationHelper
|
||||
{
|
||||
public static string SettingsDirectory
|
||||
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
|
||||
public static string? SettingsDirectory
|
||||
=> !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null
|
||||
? null
|
||||
: value;
|
||||
|
||||
#region appsettings.json
|
||||
|
||||
public static bool APPSETTINGS_TryGet(string key, out string value)
|
||||
public static bool APPSETTINGS_TryGet(string key, out string? value)
|
||||
{
|
||||
bool success = false;
|
||||
JToken val = null;
|
||||
JToken? val = null;
|
||||
|
||||
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
|
||||
|
||||
value = success ? val.Value<string>() : null;
|
||||
value = success ? val?.Value<string>() : null;
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -59,7 +60,10 @@ namespace AppScaffolding
|
||||
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
|
||||
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
|
||||
{
|
||||
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
|
||||
if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile)
|
||||
return;
|
||||
|
||||
var startingContents = File.ReadAllText(appSettingsFile);
|
||||
|
||||
JObject jObj;
|
||||
try
|
||||
@@ -82,40 +86,37 @@ namespace AppScaffolding
|
||||
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
|
||||
return;
|
||||
|
||||
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
|
||||
File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented);
|
||||
System.Threading.Thread.Sleep(100);
|
||||
}
|
||||
#endregion
|
||||
#region Settings.json
|
||||
public const string LIBATION_FILES_KEY = "LibationFiles";
|
||||
private const string SETTINGS_JSON = "Settings.json";
|
||||
|
||||
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
|
||||
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
|
||||
public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON);
|
||||
|
||||
public static bool Settings_TryGet(string key, out string value)
|
||||
public static bool Settings_TryGet(string key, out string? value)
|
||||
{
|
||||
bool success = false;
|
||||
JToken val = null;
|
||||
JToken? val = null;
|
||||
|
||||
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
|
||||
|
||||
value = success ? val.Value<string>() : null;
|
||||
value = success ? val?.Value<string>() : null;
|
||||
return success;
|
||||
}
|
||||
|
||||
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
|
||||
{
|
||||
JToken val = null;
|
||||
JToken? val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
return val?.Type == jTokenType;
|
||||
}
|
||||
|
||||
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
|
||||
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value)
|
||||
{
|
||||
JToken val = null;
|
||||
JToken? val = null;
|
||||
|
||||
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
|
||||
|
||||
@@ -157,10 +158,10 @@ namespace AppScaffolding
|
||||
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
|
||||
return false;
|
||||
|
||||
JArray array = null;
|
||||
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
|
||||
JArray? array = null;
|
||||
process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray);
|
||||
|
||||
length = array.Count;
|
||||
length = array?.Count ?? 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -171,8 +172,7 @@ namespace AppScaffolding
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||
array.Add(newValue);
|
||||
(jObj.SelectToken(jsonPath) as JArray)?.Add(newValue);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,8 +200,7 @@ namespace AppScaffolding
|
||||
|
||||
process_SettingsJson(jObj =>
|
||||
{
|
||||
var array = (JArray)jObj.SelectToken(jsonPath);
|
||||
if (position < array.Count)
|
||||
if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count)
|
||||
array.RemoveAt(position);
|
||||
});
|
||||
}
|
||||
@@ -228,7 +227,7 @@ namespace AppScaffolding
|
||||
private static void process_SettingsJson(Action<JObject> action, bool save = true)
|
||||
{
|
||||
// only insert if not exists
|
||||
if (!SettingsJson_Exists)
|
||||
if (!File.Exists(SettingsJsonPath))
|
||||
return;
|
||||
|
||||
var startingContents = File.ReadAllText(SettingsJsonPath);
|
||||
@@ -260,7 +259,7 @@ namespace AppScaffolding
|
||||
#endregion
|
||||
#region LibationContext.db
|
||||
public const string LIBATION_CONTEXT = "LibationContext.db";
|
||||
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
|
||||
public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
|
||||
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="NPOI" Version="2.7.4" />
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
|
||||
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
|
||||
<ProjectReference Include="..\DataLayer.Postgres\DataLayer.Postgres.csproj" />
|
||||
<ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -69,10 +69,10 @@ namespace ApplicationServices
|
||||
return Count;
|
||||
}
|
||||
|
||||
public void Execute()
|
||||
public async Task ExecuteAsync()
|
||||
{
|
||||
foreach (var a in actionSets)
|
||||
a.LibraryBooks.UpdateBookStatus(a.newStatus);
|
||||
await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
|
||||
#nullable enable
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class DbContexts
|
||||
{
|
||||
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||
public static LibationContext GetContext()
|
||||
=> LibationContext.Create(SqliteStorage.ConnectionString);
|
||||
public static class DbContexts
|
||||
{
|
||||
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||
public static LibationContext GetContext()
|
||||
{
|
||||
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
|
||||
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
|
||||
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
|
||||
context.Database.Migrate();
|
||||
return context;
|
||||
}
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
{
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking(includeParents);
|
||||
}
|
||||
|
||||
public static List<LibraryBook> GetDeletedLibraryBooks()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetDeletedLibraryBooks();
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibrary_Flat_NoTracking(includeParents);
|
||||
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
@@ -11,8 +6,14 @@ using Dinah.Core.Logging;
|
||||
using DtoImporterService;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using Microsoft.Extensions.DependencyModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
#nullable enable
|
||||
@@ -70,7 +71,7 @@ namespace ApplicationServices
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
@@ -141,16 +142,16 @@ namespace ApplicationServices
|
||||
return default;
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(importIntoDbAsync)}");
|
||||
newCount = await importIntoDbAsync(importItems);
|
||||
logTime($"post {nameof(importIntoDbAsync)}");
|
||||
logTime($"pre {nameof(ImportIntoDbAsync)}");
|
||||
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
|
||||
logTime($"post {nameof(ImportIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
@@ -180,7 +181,8 @@ namespace ApplicationServices
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
|
||||
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(item, "item");
|
||||
ArgumentValidator.EnsureNotNull(accountId, "accountId");
|
||||
@@ -203,35 +205,23 @@ namespace ApplicationServices
|
||||
return 0;
|
||||
}
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
var bookImporter = new BookImporter(ctx);
|
||||
bookImporter.Import(importItems);
|
||||
var book = ctx.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId);
|
||||
|
||||
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(context));
|
||||
return qtyChanged;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (book is null)
|
||||
{
|
||||
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
|
||||
ctx.LibraryBooks.Add(book);
|
||||
}
|
||||
else
|
||||
{
|
||||
book.AbsentFromLastScan = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static LogArchiver? openLogArchive(string? archivePath)
|
||||
{
|
||||
@@ -268,7 +258,7 @@ namespace ApplicationServices
|
||||
|
||||
await using LogArchiver? archiver
|
||||
= Log.Logger.IsDebugEnabled()
|
||||
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
|
||||
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "LibraryScans.zip"))
|
||||
: default;
|
||||
|
||||
archiver?.DeleteAllButNewestN(20);
|
||||
@@ -309,7 +299,7 @@ namespace ApplicationServices
|
||||
|
||||
try
|
||||
{
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions);
|
||||
|
||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||
|
||||
@@ -347,23 +337,21 @@ namespace ApplicationServices
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
|
||||
private static async Task<int> ImportIntoDbAsync(List<ImportItem> importItems) => await Task.Run(() => importIntoDb(importItems));
|
||||
private static int importIntoDb(List<ImportItem> importItems)
|
||||
{
|
||||
logTime("importIntoDbAsync -- pre db");
|
||||
using var context = DbContexts.GetContext();
|
||||
var libraryBookImporter = new LibraryBookImporter(context);
|
||||
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
int qtyChanges = SaveContext(context);
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
logTime("importIntoDbAsync -- pre db");
|
||||
|
||||
// this is any changes at all to the database, not just new books
|
||||
if (qtyChanges > 0)
|
||||
await Task.Run(() => finalizeLibrarySizeChange(context));
|
||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||
int newCount = 0;
|
||||
|
||||
return newCount;
|
||||
}
|
||||
DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
var libraryBookImporter = new LibraryBookImporter(ctx);
|
||||
newCount = libraryBookImporter.Import(importItems);
|
||||
logTime("importIntoDbAsync -- post Import()");
|
||||
});
|
||||
return newCount;
|
||||
}
|
||||
|
||||
public static int SaveContext(LibationContext context)
|
||||
{
|
||||
@@ -389,57 +377,58 @@ namespace ApplicationServices
|
||||
|
||||
#region remove/restore books
|
||||
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
|
||||
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
{
|
||||
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
// Entry() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in removeLibraryBooks)
|
||||
{
|
||||
{
|
||||
lb.IsDeleted = true;
|
||||
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
finalizeLibrarySizeChange(context);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
|
||||
private static int restoreBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
try
|
||||
{
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
// Entry() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in libraryBooks)
|
||||
{
|
||||
lb.IsDeleted = false;
|
||||
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error removing books");
|
||||
Log.Logger.Error(ex, "Error restoring books");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||
{
|
||||
try
|
||||
public static Task<int> PermanentlyDeleteBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => permanentlyDeleteBooks(idsToRemove));
|
||||
private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
try
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// Entry() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in libraryBooks)
|
||||
{
|
||||
lb.IsDeleted = false;
|
||||
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
finalizeLibrarySizeChange(context);
|
||||
|
||||
return qtyChanges;
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
ctx.LibraryBooks.RemoveRange(libraryBooks);
|
||||
ctx.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -448,36 +437,40 @@ namespace ApplicationServices
|
||||
}
|
||||
}
|
||||
|
||||
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||
static int DoDbSizeChangeOperation(Action<LibationContext> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
try
|
||||
{
|
||||
int qtyChanges;
|
||||
List<LibraryBook>? library;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
using (var context = DbContexts.GetContext())
|
||||
{
|
||||
action?.Invoke(context);
|
||||
|
||||
context.LibraryBooks.RemoveRange(libraryBooks);
|
||||
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
|
||||
qtyChanges = SaveContext(context);
|
||||
logTime("importIntoDbAsync -- post SaveChanges");
|
||||
library = qtyChanges == 0 ? null : context.GetLibrary_Flat_NoTracking(includeParents: true);
|
||||
}
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
finalizeLibrarySizeChange(context);
|
||||
if (library is not null)
|
||||
finalizeLibrarySizeChange(library);
|
||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error restoring books");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error performing DB Size change operation");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
// call this whenever books are added or removed from library
|
||||
private static void finalizeLibrarySizeChange(LibationContext context)
|
||||
private static void finalizeLibrarySizeChange(List<LibraryBook> library)
|
||||
{
|
||||
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
|
||||
LibrarySizeChanged?.Invoke(null, library);
|
||||
}
|
||||
|
||||
@@ -490,21 +483,21 @@ namespace ApplicationServices
|
||||
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateUserDefinedItem(
|
||||
public static async Task<int> UpdateUserDefinedItemAsync(
|
||||
this LibraryBook lb,
|
||||
string? tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
Rating? rating = null)
|
||||
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||
=> await UpdateUserDefinedItemAsync([lb], tags, bookStatus, pdfStatus, rating);
|
||||
|
||||
public static int UpdateUserDefinedItem(
|
||||
public static async Task<int> UpdateUserDefinedItemAsync(
|
||||
this IEnumerable<LibraryBook> lb,
|
||||
string? tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
Rating? rating = null)
|
||||
=> updateUserDefinedItem(
|
||||
=> await UpdateUserDefinedItemAsync(
|
||||
lb,
|
||||
udi => {
|
||||
// blank tags are expected. null tags are not
|
||||
@@ -521,52 +514,54 @@ namespace ApplicationServices
|
||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||
});
|
||||
|
||||
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
|
||||
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
|
||||
public static async Task<int> UpdateBookStatusAsync(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
|
||||
=> await lb.UpdateUserDefinedItemAsync(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
|
||||
|
||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static async Task<int> UpdateBookStatusAsync(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
|
||||
public static async Task<int> UpdateBookStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
|
||||
|
||||
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||
public static async Task<int> UpdatePdfStatusAsync(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
|
||||
public static async Task<int> UpdatePdfStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
||||
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
|
||||
|
||||
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static async Task<int> UpdateTagsAsync(this LibraryBook libraryBook, string tags)
|
||||
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
|
||||
public static async Task<int> UpdateTagsAsync(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
|
||||
|
||||
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||
=> libraryBook.updateUserDefinedItem(action);
|
||||
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||
=> libraryBooks.updateUserDefinedItem(action);
|
||||
public static async Task<int> UpdateUserDefinedItemAsync(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||
=> await UpdateUserDefinedItemAsync([libraryBook], action);
|
||||
|
||||
private static int updateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) => new[] { libraryBook }.updateUserDefinedItem(action);
|
||||
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||
public static Task<int> UpdateUserDefinedItemAsync(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||
=> Task.Run(() => libraryBooks.updateUserDefinedItem(action));
|
||||
|
||||
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// Entry() instead of Attach() due to possible stack overflow with large tables
|
||||
foreach (var book in libraryBooks)
|
||||
int qtyChanges;
|
||||
using (var context = DbContexts.GetContext())
|
||||
{
|
||||
action?.Invoke(book.Book.UserDefinedItem);
|
||||
// Entry() instead of Attach() due to possible stack overflow with large tables
|
||||
foreach (var book in libraryBooks)
|
||||
{
|
||||
action?.Invoke(book.Book.UserDefinedItem);
|
||||
|
||||
var udiEntity = context.Entry(book.Book.UserDefinedItem);
|
||||
var udiEntity = context.Entry(book.Book.UserDefinedItem);
|
||||
|
||||
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
|
||||
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
|
||||
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
qtyChanges = context.SaveChanges();
|
||||
}
|
||||
if (qtyChanges > 0)
|
||||
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
|
||||
|
||||
@@ -582,7 +577,7 @@ namespace ApplicationServices
|
||||
|
||||
// must be here instead of in db layer due to AaxcExists
|
||||
public static LiberatedStatus Liberated_Status(Book book)
|
||||
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
|
||||
=> book.AudioExists ? book.UserDefinedItem.BookStatus
|
||||
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
|
||||
: LiberatedStatus.NotLiberated;
|
||||
|
||||
@@ -591,7 +586,7 @@ namespace ApplicationServices
|
||||
|
||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable, IEnumerable<LibraryBook> LibraryBooks)
|
||||
{
|
||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||
public bool HasPendingBooks => PendingBooks > 0;
|
||||
@@ -650,7 +645,7 @@ namespace ApplicationServices
|
||||
|
||||
var pdfResults = libraryBooks
|
||||
.AsParallel()
|
||||
.Where(lb => lb.Book.HasPdf())
|
||||
.Where(lb => lb.Book.HasPdf)
|
||||
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
|
||||
.ToList();
|
||||
|
||||
@@ -660,7 +655,7 @@ namespace ApplicationServices
|
||||
|
||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
@@ -149,12 +150,12 @@ namespace ApplicationServices
|
||||
Locale = a.Book.Locale,
|
||||
Title = a.Book.Title,
|
||||
Subtitle = a.Book.Subtitle,
|
||||
AuthorNames = a.Book.AuthorNames(),
|
||||
NarratorNames = a.Book.NarratorNames(),
|
||||
AuthorNames = a.Book.AuthorNames,
|
||||
NarratorNames = a.Book.NarratorNames,
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
Description = a.Book.Description,
|
||||
Publisher = a.Book.Publisher,
|
||||
HasPdf = a.Book.HasPdf(),
|
||||
HasPdf = a.Book.HasPdf,
|
||||
SeriesNames = a.Book.SeriesNames(),
|
||||
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
|
||||
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
|
||||
@@ -208,19 +209,11 @@ namespace ApplicationServices
|
||||
{
|
||||
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
|
||||
|
||||
var workbook = new XSSFWorkbook();
|
||||
var sheet = workbook.CreateSheet("Library");
|
||||
using var workbook = new XLWorkbook();
|
||||
var sheet = workbook.AddWorksheet("Library");
|
||||
|
||||
var detailSubtotalFont = workbook.CreateFont();
|
||||
detailSubtotalFont.IsBold = true;
|
||||
|
||||
var detailSubtotalCellStyle = workbook.CreateCellStyle();
|
||||
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
|
||||
|
||||
// headers
|
||||
var rowIndex = 0;
|
||||
var row = sheet.CreateRow(rowIndex);
|
||||
|
||||
var columns = new[] {
|
||||
nameof(ExportDto.Account),
|
||||
nameof(ExportDto.DateAdded),
|
||||
@@ -261,81 +254,71 @@ namespace ApplicationServices
|
||||
nameof(ExportDto.ChannelCount),
|
||||
nameof(ExportDto.BitRate)
|
||||
};
|
||||
var col = 0;
|
||||
|
||||
int rowIndex = 1, col = 1;
|
||||
var headerRow = sheet.Row(rowIndex++);
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var cell = row.CreateCell(col++);
|
||||
var name = ExportDto.GetName(c);
|
||||
cell.SetCellValue(name);
|
||||
cell.CellStyle = detailSubtotalCellStyle;
|
||||
var headerCell = headerRow.Cell(col++);
|
||||
headerCell.Value = ExportDto.GetName(c);
|
||||
headerCell.Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
var dateFormat = workbook.CreateDataFormat();
|
||||
var dateStyle = workbook.CreateCellStyle();
|
||||
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
|
||||
|
||||
rowIndex++;
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// Add data rows
|
||||
foreach (var dto in dtos)
|
||||
{
|
||||
col = 0;
|
||||
row = sheet.CreateRow(rowIndex++);
|
||||
col = 1;
|
||||
var row = sheet.Row(rowIndex++);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||
row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle;
|
||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||
row.CreateCell(col++).SetCellValue(dto.Subtitle);
|
||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||
row.CreateCell(col++).SetCellValue(dto.Description);
|
||||
row.CreateCell(col++).SetCellValue(dto.Publisher);
|
||||
row.CreateCell(col++).SetCellValue(dto.HasPdf);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
|
||||
row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall);
|
||||
row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance);
|
||||
row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory);
|
||||
row.CreateCell(col++).SetCellValue(dto.PictureId);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
|
||||
row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle;
|
||||
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.MyRatingOverall);
|
||||
row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance);
|
||||
row.CreateCell(col++).SetCellValue(dto.MyRatingStory);
|
||||
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
|
||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||
row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle;
|
||||
row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsFinished);
|
||||
row.CreateCell(col++).SetCellValue(dto.IsSpatial);
|
||||
row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion);
|
||||
row.CreateCell(col++).SetCellValue(dto.CodecString);
|
||||
row.CreateCell(col++).SetCellValue(dto.SampleRate);
|
||||
row.CreateCell(col++).SetCellValue(dto.ChannelCount);
|
||||
row.CreateCell(col++).SetCellValue(dto.BitRate);
|
||||
row.Cell(col++).Value = dto.Account;
|
||||
row.Cell(col++).SetDate(dto.DateAdded, dateFormat);
|
||||
row.Cell(col++).Value = dto.AudibleProductId;
|
||||
row.Cell(col++).Value = dto.Locale;
|
||||
row.Cell(col++).Value = dto.Title;
|
||||
row.Cell(col++).Value = dto.Subtitle;
|
||||
row.Cell(col++).Value = dto.AuthorNames;
|
||||
row.Cell(col++).Value = dto.NarratorNames;
|
||||
row.Cell(col++).Value = dto.LengthInMinutes;
|
||||
row.Cell(col++).Value = dto.Description;
|
||||
row.Cell(col++).Value = dto.Publisher;
|
||||
row.Cell(col++).Value = dto.HasPdf;
|
||||
row.Cell(col++).Value = dto.SeriesNames;
|
||||
row.Cell(col++).Value = dto.SeriesOrder;
|
||||
row.Cell(col++).Value = dto.CommunityRatingOverall;
|
||||
row.Cell(col++).Value = dto.CommunityRatingPerformance;
|
||||
row.Cell(col++).Value = dto.CommunityRatingStory;
|
||||
row.Cell(col++).Value = dto.PictureId;
|
||||
row.Cell(col++).Value = dto.IsAbridged;
|
||||
row.Cell(col++).SetDate(dto.DatePublished, dateFormat);
|
||||
row.Cell(col++).Value = dto.CategoriesNames;
|
||||
row.Cell(col++).Value = dto.MyRatingOverall;
|
||||
row.Cell(col++).Value = dto.MyRatingPerformance;
|
||||
row.Cell(col++).Value = dto.MyRatingStory;
|
||||
row.Cell(col++).Value = dto.MyLibationTags;
|
||||
row.Cell(col++).Value = dto.BookStatus;
|
||||
row.Cell(col++).Value = dto.PdfStatus;
|
||||
row.Cell(col++).Value = dto.ContentType;
|
||||
row.Cell(col++).Value = dto.Language;
|
||||
row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat);
|
||||
row.Cell(col++).Value = dto.LastDownloadedVersion;
|
||||
row.Cell(col++).Value = dto.IsFinished;
|
||||
row.Cell(col++).Value = dto.IsSpatial;
|
||||
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
|
||||
row.Cell(col++).Value = dto.CodecString;
|
||||
row.Cell(col++).Value = dto.SampleRate;
|
||||
row.Cell(col++).Value = dto.ChannelCount;
|
||||
row.Cell(col++).Value = dto.BitRate;
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
workbook.Write(fileData);
|
||||
workbook.SaveAs(saveFilePath);
|
||||
}
|
||||
|
||||
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate)
|
||||
=> nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value)
|
||||
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||
|
||||
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt)
|
||||
=> nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value)
|
||||
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||
|
||||
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat)
|
||||
=> nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value)
|
||||
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
|
||||
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
|
||||
{
|
||||
cell.Value = value;
|
||||
cell.Style.DateFormat.Format = dateFormat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using AudibleApi.Common;
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NPOI.XSSF.UserModel;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices
|
||||
@@ -16,19 +17,10 @@ namespace ApplicationServices
|
||||
if (!records.Any())
|
||||
return;
|
||||
|
||||
using var workbook = new XSSFWorkbook();
|
||||
var sheet = workbook.CreateSheet("Records");
|
||||
|
||||
var detailSubtotalFont = workbook.CreateFont();
|
||||
detailSubtotalFont.IsBold = true;
|
||||
|
||||
var detailSubtotalCellStyle = workbook.CreateCellStyle();
|
||||
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
|
||||
using var workbook = new XLWorkbook();
|
||||
var worksheet = workbook.AddWorksheet("Records");
|
||||
|
||||
// headers
|
||||
var rowIndex = 0;
|
||||
var row = sheet.CreateRow(rowIndex);
|
||||
|
||||
var columns = new List<string>
|
||||
{
|
||||
nameof(Type.Name),
|
||||
@@ -49,56 +41,52 @@ namespace ApplicationServices
|
||||
if (records.OfType<Clip>().Any())
|
||||
columns.Add(nameof(Clip.Title));
|
||||
|
||||
var col = 0;
|
||||
int rowIndex = 1, col = 1;
|
||||
var headerRow = worksheet.Row(rowIndex++);
|
||||
foreach (var c in columns)
|
||||
{
|
||||
var cell = row.CreateCell(col++);
|
||||
cell.SetCellValue(c);
|
||||
cell.CellStyle = detailSubtotalCellStyle;
|
||||
var headerCell = headerRow.Cell(col++);
|
||||
headerCell.Value = c;
|
||||
headerCell.Style.Font.Bold = true;
|
||||
}
|
||||
|
||||
var dateFormat = workbook.CreateDataFormat();
|
||||
var dateStyle = workbook.CreateCellStyle();
|
||||
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
|
||||
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
|
||||
|
||||
// Add data rows
|
||||
foreach (var record in records)
|
||||
{
|
||||
col = 0;
|
||||
col = 1;
|
||||
var row = worksheet.Row(rowIndex++);
|
||||
|
||||
row = sheet.CreateRow(++rowIndex);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(record.GetType().Name);
|
||||
|
||||
var dateCreatedCell = row.CreateCell(col++);
|
||||
dateCreatedCell.CellStyle = dateStyle;
|
||||
dateCreatedCell.SetCellValue(record.Created.DateTime);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
|
||||
row.Cell(col++).Value = record.GetType().Name;
|
||||
row.Cell(col++).SetDate(record.Created.DateTime, dateFormat);
|
||||
row.Cell(col++).Value = record.Start.TotalMilliseconds;
|
||||
|
||||
if (record is IAnnotation annotation)
|
||||
{
|
||||
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
|
||||
|
||||
var lastModifiedCell = row.CreateCell(col++);
|
||||
lastModifiedCell.CellStyle = dateStyle;
|
||||
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
|
||||
row.Cell(col++).Value = annotation.AnnotationId;
|
||||
row.Cell(col++).SetDate(annotation.LastModified.DateTime, dateFormat);
|
||||
|
||||
if (annotation is IRangeAnnotation rangeAnnotation)
|
||||
{
|
||||
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
|
||||
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
|
||||
row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds;
|
||||
row.Cell(col++).Value = rangeAnnotation.Text;
|
||||
|
||||
if (rangeAnnotation is Clip clip)
|
||||
row.CreateCell(col++).SetCellValue(clip.Title);
|
||||
row.Cell(col++).Value = clip.Title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
workbook.Write(fileData);
|
||||
workbook.SaveAs(saveFilePath);
|
||||
}
|
||||
|
||||
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
|
||||
{
|
||||
cell.Value = value;
|
||||
cell.Style.DateFormat.Format = dateFormat;
|
||||
}
|
||||
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
|
||||
{
|
||||
if (!records.Any())
|
||||
|
||||
@@ -18,68 +18,64 @@ namespace AudibleUtilities
|
||||
public string AccountId { get; }
|
||||
|
||||
// user-friendly, non-canonical name. mutable
|
||||
private string _accountName;
|
||||
public string AccountName
|
||||
{
|
||||
get => _accountName;
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
var v = value.Trim();
|
||||
if (v == _accountName)
|
||||
if (v == field)
|
||||
return;
|
||||
_accountName = v;
|
||||
field = v;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
// whether to include this account when scanning libraries.
|
||||
// technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here
|
||||
private bool _libraryScan = true;
|
||||
public bool LibraryScan
|
||||
{
|
||||
get => _libraryScan;
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
if (value == _libraryScan)
|
||||
if (value == field)
|
||||
return;
|
||||
_libraryScan = value;
|
||||
field = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
private string _decryptKey = "";
|
||||
/// <summary>aka: activation bytes</summary>
|
||||
public string DecryptKey
|
||||
{
|
||||
get => _decryptKey;
|
||||
get => field ?? "";
|
||||
set
|
||||
{
|
||||
var v = (value ?? "").Trim();
|
||||
if (v == _decryptKey)
|
||||
if (v == field)
|
||||
return;
|
||||
_decryptKey = v;
|
||||
field = v;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
private Identity _identity;
|
||||
public Identity IdentityTokens
|
||||
{
|
||||
get => _identity;
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
if (_identity is null && value is null)
|
||||
if (field is null && value is null)
|
||||
return;
|
||||
|
||||
if (_identity is not null)
|
||||
_identity.Updated -= update;
|
||||
if (field is not null)
|
||||
field.Updated -= update;
|
||||
|
||||
if (value is not null)
|
||||
value.Updated += update;
|
||||
|
||||
_identity = value;
|
||||
field = value;
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using AudibleApi.Authorization;
|
||||
using Dinah.Core;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities
|
||||
{
|
||||
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
|
||||
@@ -14,8 +15,8 @@ namespace AudibleUtilities
|
||||
// JSON : Array (properties on the collection will not be serialized)
|
||||
public class AccountsSettings : IUpdatable
|
||||
{
|
||||
public event EventHandler Updated;
|
||||
private void update(object sender = null, EventArgs e = null)
|
||||
public event EventHandler? Updated;
|
||||
private void update(object? sender = null, EventArgs? e = null)
|
||||
{
|
||||
foreach (var account in Accounts)
|
||||
validate(account);
|
||||
@@ -48,9 +49,9 @@ namespace AudibleUtilities
|
||||
}
|
||||
}
|
||||
|
||||
private string _cdm;
|
||||
private string? _cdm;
|
||||
[JsonProperty]
|
||||
public string Cdm
|
||||
public string? Cdm
|
||||
{
|
||||
get => _cdm;
|
||||
set
|
||||
@@ -68,7 +69,7 @@ namespace AudibleUtilities
|
||||
#endregion
|
||||
|
||||
#region de/serialize
|
||||
public static AccountsSettings FromJson(string json)
|
||||
public static AccountsSettings? FromJson(string json)
|
||||
=> JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings());
|
||||
|
||||
public string ToJson(Formatting formatting = Formatting.Indented)
|
||||
@@ -107,7 +108,7 @@ namespace AudibleUtilities
|
||||
account.Updated += update;
|
||||
}
|
||||
|
||||
public Account GetAccount(string accountId, string locale)
|
||||
public Account? GetAccount(string accountId, string? locale)
|
||||
{
|
||||
if (locale is null)
|
||||
return null;
|
||||
|
||||
@@ -10,6 +10,7 @@ using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using System.Threading;
|
||||
using LibationFileManager;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities
|
||||
@@ -72,16 +73,16 @@ namespace AudibleUtilities
|
||||
// 2 retries == 3 total
|
||||
.RetryAsync(2);
|
||||
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions libraryOptions, bool importEpisodes = true)
|
||||
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions libraryOptions)
|
||||
{
|
||||
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed
|
||||
// worse, this 1st dummy call doesn't seem to help:
|
||||
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
|
||||
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
|
||||
return policy.ExecuteAsync(() => getItemsAsync(libraryOptions, importEpisodes));
|
||||
return policy.ExecuteAsync(() => getItemsAsync(libraryOptions));
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
|
||||
@@ -95,12 +96,12 @@ namespace AudibleUtilities
|
||||
|
||||
//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))
|
||||
await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
|
||||
{
|
||||
if (importEpisodes)
|
||||
if (Configuration.Instance.ImportEpisodes)
|
||||
{
|
||||
var episodes = item.Where(i => i.IsEpisodes).ToList();
|
||||
var series = item.Where(i => i.IsSeriesParent).ToList();
|
||||
var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList();
|
||||
var series = itemsBatch.Where(i => i.IsSeriesParent).ToList();
|
||||
|
||||
var parentAsins = episodes
|
||||
.SelectMany(i => i.Relationships)
|
||||
@@ -119,7 +120,11 @@ namespace AudibleUtilities
|
||||
items.AddRange(series);
|
||||
}
|
||||
|
||||
items.AddRange(item.Where(i => !i.IsSeriesParent && !i.IsEpisodes));
|
||||
var booksInBatch
|
||||
= itemsBatch
|
||||
.Where(i => !i.IsSeriesParent && !i.IsEpisodes)
|
||||
.Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles);
|
||||
items.AddRange(booksInBatch);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace AudibleUtilities
|
||||
|
||||
public static class AudibleApiStorage
|
||||
{
|
||||
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
|
||||
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json");
|
||||
|
||||
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
|
||||
|
||||
|
||||
@@ -28,10 +28,11 @@ namespace AudibleUtilities
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items)));
|
||||
if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
|
||||
exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
|
||||
//// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date'
|
||||
//if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
|
||||
// exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
|
||||
|
||||
return exceptions;
|
||||
return exceptions;
|
||||
}
|
||||
}
|
||||
public class BookValidator : IValidator
|
||||
@@ -91,7 +92,7 @@ namespace AudibleUtilities
|
||||
if (distinct.Any(s => s.SeriesId is null))
|
||||
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
|
||||
|
||||
//// unfortunately, a user has a series with no name
|
||||
//// unfortunately, an actual 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)));
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="9.4.2.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||
<PackageReference Include="AudibleApi" Version="10.1.2.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.33.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
#nullable enable
|
||||
@@ -56,18 +57,99 @@ internal class Device
|
||||
|
||||
public byte[] SignMessage(byte[] message)
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
var digestion = sha1.ComputeHash(message);
|
||||
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||
var digestion = SHA1.HashData(message);
|
||||
return PssSha1Signer.SignHash(CdmKey, digestion);
|
||||
}
|
||||
|
||||
public bool VerifyMessage(byte[] message, byte[] signature)
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
var digestion = sha1.ComputeHash(message);
|
||||
var digestion = SHA1.HashData(message);
|
||||
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||
}
|
||||
|
||||
public byte[] DecryptSessionKey(byte[] sessionKey)
|
||||
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
|
||||
|
||||
/// <summary>
|
||||
/// Completely managed implementation of RSASSA-PSS using SHA-1.
|
||||
/// https://github.com/bcgit/bc-csharp/blob/master/crypto/src/crypto/signers/PssSigner.cs
|
||||
///
|
||||
/// Absolutely nobody anywhere should use this RSASSA-PSS implementation in anything where they care about security at all. We completely skipped the random salt part of it because libation doesn't need security; it only needs to satisfy Audible server's challenge-response requirements.
|
||||
/// </summary>
|
||||
private static class PssSha1Signer
|
||||
{
|
||||
private const int Sha1DigestSize = 20;
|
||||
private const int Trailer = 0xBC;
|
||||
|
||||
public static byte[] SignHash(RSA rsa, ReadOnlySpan<byte> hash)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNotEqual(hash.Length, Sha1DigestSize);
|
||||
|
||||
var parameters = rsa.ExportParameters(true);
|
||||
var Modulus = new BigInteger(parameters.Modulus, isUnsigned: true, isBigEndian: true);
|
||||
var Exponent = new BigInteger(parameters.D, isUnsigned: true, isBigEndian: true);
|
||||
var emBits = rsa.KeySize - 1;
|
||||
var block = new byte[(emBits + 7) / 8];
|
||||
var firstByteMask = (byte)(0xFFU >> ((block.Length * 8) - emBits));
|
||||
|
||||
Span<byte> mDash = new byte[8 + 2 * Sha1DigestSize];
|
||||
|
||||
hash.CopyTo(mDash.Slice(8));
|
||||
var h = SHA1.HashData(mDash);
|
||||
|
||||
block[^(2 * (Sha1DigestSize + 1))] = 1;
|
||||
byte[] dbMask = MaskGeneratorFunction1(h, 0, h.Length, block.Length - Sha1DigestSize - 1);
|
||||
for (int i = 0; i != dbMask.Length; i++)
|
||||
block[i] ^= dbMask[i];
|
||||
|
||||
h.CopyTo(block, block.Length - Sha1DigestSize - 1);
|
||||
|
||||
block[0] &= firstByteMask;
|
||||
block[^1] = Trailer;
|
||||
|
||||
var input = new BigInteger(block, isUnsigned: true, isBigEndian: true);
|
||||
var result = BigInteger.ModPow(input, Exponent, Modulus);
|
||||
return result.ToByteArray(isUnsigned: true, isBigEndian: true);
|
||||
}
|
||||
|
||||
private static byte[] MaskGeneratorFunction1(byte[] Z, int zOff, int zLen, int length)
|
||||
{
|
||||
byte[] mask = new byte[length];
|
||||
byte[] hashBuf = new byte[Sha1DigestSize];
|
||||
byte[] C = new byte[4];
|
||||
int counter = 0;
|
||||
|
||||
using var sha = SHA1.Create();
|
||||
|
||||
for (; counter < (length / Sha1DigestSize); counter++)
|
||||
{
|
||||
ItoOSP(counter, C);
|
||||
|
||||
sha.TransformBlock(Z, zOff, zLen, null, 0);
|
||||
sha.TransformFinalBlock(C, 0, C.Length);
|
||||
|
||||
sha.Hash!.CopyTo(mask, counter * Sha1DigestSize);
|
||||
}
|
||||
|
||||
if ((counter * Sha1DigestSize) < length)
|
||||
{
|
||||
ItoOSP(counter, C);
|
||||
|
||||
sha.TransformBlock(Z, zOff, zLen, null, 0);
|
||||
sha.TransformFinalBlock(C, 0, C.Length);
|
||||
|
||||
Array.Copy(sha.Hash!, 0, mask, counter * Sha1DigestSize, mask.Length - (counter * Sha1DigestSize));
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
private static void ItoOSP(int i, byte[] sp)
|
||||
{
|
||||
sp[0] = (byte)((uint)i >> 24);
|
||||
sp[1] = (byte)((uint)i >> 16);
|
||||
sp[2] = (byte)((uint)i >> 8);
|
||||
sp[3] = (byte)((uint)i >> 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
Source/DataLayer.Postgres/DataLayer.Postgres.csproj
Normal file
38
Source/DataLayer.Postgres/DataLayer.Postgres.csproj
Normal file
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
494
Source/DataLayer.Postgres/Migrations/20251027224441_InitialPostgres.Designer.cs
generated
Normal file
494
Source/DataLayer.Postgres/Migrations/20251027224441_InitialPostgres.Designer.cs
generated
Normal file
@@ -0,0 +1,494 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20251027224441_InitialPostgres")]
|
||||
partial class InitialPostgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
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.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("smallint");
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
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("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
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("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
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,372 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialPostgres : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Books",
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
AudibleProductId = table.Column<string>(type: "text", nullable: true),
|
||||
Title = table.Column<string>(type: "text", nullable: true),
|
||||
Subtitle = table.Column<string>(type: "text", nullable: true),
|
||||
Description = table.Column<string>(type: "text", nullable: true),
|
||||
LengthInMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||
ContentType = table.Column<int>(type: "integer", nullable: false),
|
||||
Locale = table.Column<string>(type: "text", nullable: true),
|
||||
PictureId = table.Column<string>(type: "text", nullable: true),
|
||||
PictureLarge = table.Column<string>(type: "text", nullable: true),
|
||||
IsAbridged = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsSpatial = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DatePublished = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
|
||||
Language = table.Column<string>(type: "text", nullable: true),
|
||||
Rating_OverallRating = table.Column<float>(type: "real", nullable: true),
|
||||
Rating_PerformanceRating = table.Column<float>(type: "real", nullable: true),
|
||||
Rating_StoryRating = table.Column<float>(type: "real", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Books", x => x.BookId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Categories",
|
||||
columns: table => new
|
||||
{
|
||||
CategoryId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
AudibleCategoryId = table.Column<string>(type: "text", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Categories", x => x.CategoryId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CategoryLadders",
|
||||
columns: table => new
|
||||
{
|
||||
CategoryLadderId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Contributors",
|
||||
columns: table => new
|
||||
{
|
||||
ContributorId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Name = table.Column<string>(type: "text", nullable: true),
|
||||
AudibleContributorId = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Contributors", x => x.ContributorId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Series",
|
||||
columns: table => new
|
||||
{
|
||||
SeriesId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
AudibleSeriesId = table.Column<string>(type: "text", nullable: true),
|
||||
Name = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Series", x => x.SeriesId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LibraryBooks",
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(type: "integer", nullable: false),
|
||||
DateAdded = table.Column<DateTime>(type: "timestamp without time zone", nullable: false),
|
||||
Account = table.Column<string>(type: "text", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
|
||||
AbsentFromLastScan = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IncludedUntil = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LibraryBooks", x => x.BookId);
|
||||
table.ForeignKey(
|
||||
name: "FK_LibraryBooks_Books_BookId",
|
||||
column: x => x.BookId,
|
||||
principalTable: "Books",
|
||||
principalColumn: "BookId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Supplement",
|
||||
columns: table => new
|
||||
{
|
||||
SupplementId = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
BookId = table.Column<int>(type: "integer", nullable: false),
|
||||
Url = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Supplement", x => x.SupplementId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Supplement_Books_BookId",
|
||||
column: x => x.BookId,
|
||||
principalTable: "Books",
|
||||
principalColumn: "BookId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserDefinedItem",
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(type: "integer", nullable: false),
|
||||
LastDownloaded = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
|
||||
LastDownloadedVersion = table.Column<string>(type: "text", nullable: true),
|
||||
LastDownloadedFormat = table.Column<long>(type: "bigint", nullable: true),
|
||||
LastDownloadedFileVersion = table.Column<string>(type: "text", nullable: true),
|
||||
Tags = table.Column<string>(type: "text", nullable: true),
|
||||
Rating_OverallRating = table.Column<float>(type: "real", nullable: true),
|
||||
Rating_PerformanceRating = table.Column<float>(type: "real", nullable: true),
|
||||
Rating_StoryRating = table.Column<float>(type: "real", nullable: true),
|
||||
BookStatus = table.Column<int>(type: "integer", nullable: false),
|
||||
PdfStatus = table.Column<int>(type: "integer", nullable: true),
|
||||
IsFinished = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserDefinedItem", x => x.BookId);
|
||||
table.ForeignKey(
|
||||
name: "FK_UserDefinedItem_Books_BookId",
|
||||
column: x => x.BookId,
|
||||
principalTable: "Books",
|
||||
principalColumn: "BookId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
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__categoryLaddersCate~",
|
||||
column: x => x._categoryLaddersCategoryLadderId,
|
||||
principalTable: "CategoryLadders",
|
||||
principalColumn: "CategoryLadderId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BookContributor",
|
||||
columns: table => new
|
||||
{
|
||||
BookId = table.Column<int>(type: "integer", nullable: false),
|
||||
ContributorId = table.Column<int>(type: "integer", nullable: false),
|
||||
Role = table.Column<int>(type: "integer", nullable: false),
|
||||
Order = table.Column<byte>(type: "smallint", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BookContributor", x => new { x.BookId, x.ContributorId, x.Role });
|
||||
table.ForeignKey(
|
||||
name: "FK_BookContributor_Books_BookId",
|
||||
column: x => x.BookId,
|
||||
principalTable: "Books",
|
||||
principalColumn: "BookId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_BookContributor_Contributors_ContributorId",
|
||||
column: x => x.ContributorId,
|
||||
principalTable: "Contributors",
|
||||
principalColumn: "ContributorId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SeriesBook",
|
||||
columns: table => new
|
||||
{
|
||||
SeriesId = table.Column<int>(type: "integer", nullable: false),
|
||||
BookId = table.Column<int>(type: "integer", nullable: false),
|
||||
Order = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SeriesBook", x => new { x.SeriesId, x.BookId });
|
||||
table.ForeignKey(
|
||||
name: "FK_SeriesBook_Books_BookId",
|
||||
column: x => x.BookId,
|
||||
principalTable: "Books",
|
||||
principalColumn: "BookId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SeriesBook_Series_SeriesId",
|
||||
column: x => x.SeriesId,
|
||||
principalTable: "Series",
|
||||
principalColumn: "SeriesId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
table: "Contributors",
|
||||
columns: new[] { "ContributorId", "AudibleContributorId", "Name" },
|
||||
values: new object[] { -1, null, "" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookCategory_BookId",
|
||||
table: "BookCategory",
|
||||
column: "BookId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookCategory_CategoryLadderId",
|
||||
table: "BookCategory",
|
||||
column: "CategoryLadderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookContributor_BookId",
|
||||
table: "BookContributor",
|
||||
column: "BookId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BookContributor_ContributorId",
|
||||
table: "BookContributor",
|
||||
column: "ContributorId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Books_AudibleProductId",
|
||||
table: "Books",
|
||||
column: "AudibleProductId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Categories_AudibleCategoryId",
|
||||
table: "Categories",
|
||||
column: "AudibleCategoryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
|
||||
table: "CategoryCategoryLadder",
|
||||
column: "_categoryLaddersCategoryLadderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Contributors_Name",
|
||||
table: "Contributors",
|
||||
column: "Name");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Series_AudibleSeriesId",
|
||||
table: "Series",
|
||||
column: "AudibleSeriesId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SeriesBook_BookId",
|
||||
table: "SeriesBook",
|
||||
column: "BookId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SeriesBook_SeriesId",
|
||||
table: "SeriesBook",
|
||||
column: "SeriesId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Supplement_BookId",
|
||||
table: "Supplement",
|
||||
column: "BookId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BookCategory");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BookContributor");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CategoryCategoryLadder");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LibraryBooks");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SeriesBook");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Supplement");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserDefinedItem");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Contributors");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Categories");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CategoryLadders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Series");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
partial class LibationContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
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.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("smallint");
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
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("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
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("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Source/DataLayer.Postgres/PostgresContextFactory.cs
Normal file
12
Source/DataLayer.Postgres/PostgresContextFactory.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace DataLayer.Postgres
|
||||
{
|
||||
public class PostgresContextFactory : IDesignTimeDbContextFactory<LibationContext>
|
||||
{
|
||||
public LibationContext CreateDbContext(string[] args)
|
||||
{
|
||||
return LibationContextFactory.CreatePostgres(string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj
Normal file
34
Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj
Normal file
@@ -0,0 +1,34 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user