Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f7ffacdf8 | ||
|
|
829b35c5a8 | ||
|
|
614b05d5ff | ||
|
|
26ccc77b47 | ||
|
|
64fb2ccf7c | ||
|
|
890747a902 | ||
|
|
1fdcea929f | ||
|
|
7848366818 | ||
|
|
40b4915b65 | ||
|
|
80b86086ca | ||
|
|
bff9b67b72 | ||
|
|
657a7bb6bc | ||
|
|
f0d7a7bf64 | ||
|
|
8bc098e7bd | ||
|
|
9280b29512 | ||
|
|
d8e9b9c505 | ||
|
|
554b308364 | ||
|
|
8d7872a376 | ||
|
|
747451d243 | ||
|
|
7e79e98771 | ||
|
|
4b7939541a | ||
|
|
a3734c76b1 | ||
|
|
ced4ea6c17 | ||
|
|
35ca6f2621 | ||
|
|
4dab16837e | ||
|
|
1cf889eed7 | ||
|
|
b65b1e819b | ||
|
|
3d50643ab0 | ||
|
|
abd18d74b0 | ||
|
|
0e49df06b8 | ||
|
|
38cc3e9725 | ||
|
|
c9af2bba4b | ||
|
|
2191c1536d | ||
|
|
5b9bf2fbb0 | ||
|
|
9b1ce8c1d7 | ||
|
|
9f8075041b | ||
|
|
944645379e | ||
|
|
cc72517284 | ||
|
|
0044820415 | ||
|
|
9f24027de1 | ||
|
|
24f95cb03d | ||
|
|
3aeea54615 | ||
|
|
f511041781 | ||
|
|
da9dc91469 | ||
|
|
e04e70d333 | ||
|
|
e0b566ee60 | ||
|
|
bf15d7302e | ||
|
|
8f01c644c0 | ||
|
|
ebd2cc96c5 | ||
|
|
0d1cc42ca7 | ||
|
|
e126dd09ce | ||
|
|
ec497f4f81 | ||
|
|
248fdfd2bc | ||
|
|
35862d619a | ||
|
|
ac2c67985d | ||
|
|
f8ae303417 | ||
|
|
0d24caeac2 | ||
|
|
7f1b357c52 | ||
|
|
ef67ae9d6a | ||
|
|
f35c82d59d | ||
|
|
10c01f4147 | ||
|
|
9366b3baca | ||
|
|
20e792c589 | ||
|
|
dfb63d3275 | ||
|
|
19db226f5a | ||
|
|
203ab00865 | ||
|
|
b11a4887d7 | ||
|
|
e73fc5e1eb | ||
|
|
8561a15061 | ||
|
|
28ba62aead | ||
|
|
176294cc55 | ||
|
|
152b0e362d | ||
|
|
4600d029dc | ||
|
|
1a5684799c | ||
|
|
0df17a2296 | ||
|
|
45472abd1f | ||
|
|
f2ea4539f2 | ||
|
|
52d3b9cb67 | ||
|
|
3d87f2cd9b | ||
|
|
e4a3d2ac79 | ||
|
|
8aa157f2f6 | ||
|
|
5ab6c1fe70 | ||
|
|
b23c46f79f | ||
|
|
0e987eef00 | ||
|
|
ace3d80e41 | ||
|
|
4bfb4e73ce | ||
|
|
7805a3ef11 | ||
|
|
08ca2a2db3 | ||
|
|
64a85b6aab | ||
|
|
1a38273d5f | ||
|
|
303dd7c471 | ||
|
|
313e3846c3 | ||
|
|
422c86345e | ||
|
|
ce952417fb | ||
|
|
5f4551822b | ||
|
|
3aebc7c885 | ||
|
|
3982edd0f1 | ||
|
|
f4dafac28f | ||
|
|
1090d29f74 | ||
|
|
1c336e1fe9 | ||
|
|
c7e9e9ac1e | ||
|
|
8232b2b5e5 | ||
|
|
9ca879cc3d | ||
|
|
ece48eb6d7 | ||
|
|
bffaea6026 | ||
|
|
e2aae85fd7 | ||
|
|
1777dc5a7e | ||
|
|
2dfe00f428 | ||
|
|
2cd0a022ff | ||
|
|
5d7ac699e6 | ||
|
|
7d806e0f3e | ||
|
|
0a9e489f48 | ||
|
|
17612dacd2 | ||
|
|
e61ad41d5a | ||
|
|
c77f2e2162 | ||
|
|
bfcd226795 | ||
|
|
0af7c4d90a | ||
|
|
e4826388be | ||
|
|
98a1fa4dda | ||
|
|
81e9ab7fb2 | ||
|
|
9c82d34ba4 | ||
|
|
a384bceab0 | ||
|
|
545540d9a4 | ||
|
|
f402912a92 | ||
|
|
aab4f1d9d6 | ||
|
|
f183b587b8 | ||
|
|
733a091ebd | ||
|
|
9043ea6334 | ||
|
|
40890f242a | ||
|
|
6c03f525bf | ||
|
|
dcda1a0cc2 | ||
|
|
e509f842e4 | ||
|
|
faa2e04b9f | ||
|
|
71afb5c9f4 | ||
|
|
d90ef3f4d4 | ||
|
|
f84bb753e9 | ||
|
|
b34970bd47 | ||
|
|
a37eb383cd | ||
|
|
614965e1ab | ||
|
|
52d611a74c | ||
|
|
653381b1df | ||
|
|
4e067f5b5b | ||
|
|
ee05ca4eb2 | ||
|
|
65e12d9a8f | ||
|
|
5dc1bafb94 | ||
|
|
3010f80834 | ||
|
|
6c20b85200 | ||
|
|
bf87180fe9 | ||
|
|
ae9aac789f | ||
|
|
e6cd182872 | ||
|
|
7eeb2dcd7f | ||
|
|
71bb0571d1 | ||
|
|
7bb5b2968e | ||
|
|
b051283fca | ||
|
|
53af2ee39e | ||
|
|
fab32a1744 | ||
|
|
e2dabc8a53 | ||
|
|
fd82f7ae5a | ||
|
|
df9535a83d | ||
|
|
85b6792468 | ||
|
|
e37abbf276 | ||
|
|
c3938c49a9 | ||
|
|
7658f21d7c | ||
|
|
c4827fc761 | ||
|
|
649b52af1d | ||
|
|
da06511951 | ||
|
|
88d3e5ff0c | ||
|
|
5f99e594d8 | ||
|
|
981a183992 | ||
|
|
ac036f65f1 | ||
|
|
b36e110b49 | ||
|
|
ef3c71a939 | ||
|
|
b2af93bed9 | ||
|
|
1f427919e6 | ||
|
|
c9c5bbb687 | ||
|
|
efbefa2784 | ||
|
|
51aabe5dd4 | ||
|
|
1c668adff8 | ||
|
|
4170dcc1d5 | ||
|
|
68cfae1d58 | ||
|
|
a790c7535c | ||
|
|
3b7d5a354f | ||
|
|
a9375f1520 | ||
|
|
47c9fcb883 | ||
|
|
5f5c9f65ed | ||
|
|
1417a4b992 | ||
|
|
2d6120f0c4 | ||
|
|
2a25b7e0ad | ||
|
|
4766ea7372 | ||
|
|
d195dd07dc | ||
|
|
bcbb7610ad | ||
|
|
6c5773df24 | ||
|
|
211f15af25 | ||
|
|
e3b0f80016 | ||
|
|
7b2c7e49e5 | ||
|
|
6a40d19393 | ||
|
|
3167744111 | ||
|
|
b6a3ba335a | ||
|
|
fa1ddc726a | ||
|
|
b3b0662dec | ||
|
|
5cb22cfd24 | ||
|
|
e911344850 | ||
|
|
8ec7e5a9d2 | ||
|
|
e1f749c3da | ||
|
|
ba060d15aa | ||
|
|
93fde236c8 | ||
|
|
13aad1a7cb | ||
|
|
65c64c4504 | ||
|
|
14ba04c28b | ||
|
|
96e886d207 | ||
|
|
c7279574a9 | ||
|
|
a522e1ff7e | ||
|
|
9ebc4444bd | ||
|
|
525afdf050 | ||
|
|
983cdf6ad5 | ||
|
|
09bb32a435 | ||
|
|
817ef33fbd | ||
|
|
be52b496a6 | ||
|
|
c0c99db6fa | ||
|
|
a1c8fb5921 | ||
|
|
4576c0e193 | ||
|
|
d592e9435e | ||
|
|
9ce6cb54ab | ||
|
|
c15d49fc64 | ||
|
|
99be869aa9 | ||
|
|
a0e875a79c | ||
|
|
6134becc70 | ||
|
|
eadf7cff79 | ||
|
|
87ca76f9cb | ||
|
|
43d1019059 | ||
|
|
ed87ded77a | ||
|
|
56d4205360 | ||
|
|
1f839606ae | ||
|
|
6eebe652d4 | ||
|
|
5fff22a0e1 | ||
|
|
cd7040cdc7 | ||
|
|
97b792868f | ||
|
|
984f931f67 | ||
|
|
e0dd9b845a | ||
|
|
f1c8b320c2 | ||
|
|
9b7d0cd909 | ||
|
|
99592ff84e | ||
|
|
f97cfe77f9 | ||
|
|
2954cb961b | ||
|
|
1e29b98b82 | ||
|
|
8b76da0dbe | ||
|
|
0a749d2d88 | ||
|
|
9ed6c1fd0d | ||
|
|
9825e2b552 | ||
|
|
011efe3676 | ||
|
|
2bdcc221f5 | ||
|
|
21bedca367 | ||
|
|
074fe79ded | ||
|
|
ac8c090c4c | ||
|
|
ade693bebb | ||
|
|
9bc53e45cd | ||
|
|
7d4eaa11e7 | ||
|
|
4521c5d5ed | ||
|
|
eb39f994e1 | ||
|
|
c19833b34e |
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"CdmUrls": [
|
||||||
|
"https://ollj0gz40d.execute-api.us-west-2.amazonaws.com/default/AudibleCdm"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ labels: bug
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
**Describe the bug**
|
||||||
A clear and concise description of what the bug is.
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
**To Reproduce**
|
**To Reproduce**
|
||||||
Steps to reproduce the behavior:
|
Steps to reproduce the behavior:
|
||||||
|
|
||||||
1. Go to '...'
|
1. Go to '...'
|
||||||
@@ -17,15 +17,14 @@ Steps to reproduce the behavior:
|
|||||||
3. Scroll down to '....'
|
3. Scroll down to '....'
|
||||||
4. See error
|
4. See error
|
||||||
|
|
||||||
**Expected behavior**
|
**Expected behavior**
|
||||||
A clear and concise description of what you expected to happen.
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
**Screenshots**
|
**Screenshots**
|
||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Platform**
|
**Platform**
|
||||||
|
|
||||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||||
|
|
||||||
**Log Files**
|
**Log Files**
|
||||||
Attach your Libation log file here.
|
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.
|
||||||
@@ -6,14 +6,26 @@ labels: enhancement
|
|||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**No-go ideas**
|
||||||
|
There are lots of great ideas and many are beyond what we intend to do for Libation. Some good ideas which we do not intend to pursue:
|
||||||
|
|
||||||
|
* comprehensive api/cli
|
||||||
|
* aax/audiobook import
|
||||||
|
* bulk rename of existing files
|
||||||
|
* general metadata/tag editor
|
||||||
|
* playback features
|
||||||
|
* web gui
|
||||||
|
* supporting non-audible vendors
|
||||||
|
* official docker support
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
A clear and concise description of what you want to happen.
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
**Describe alternatives you've considered**
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
Add any other context or screenshots about the feature request here.
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -8,37 +8,37 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
version_override:
|
version_override:
|
||||||
type: string
|
type: string
|
||||||
description: 'Version number override'
|
description: "Version number override"
|
||||||
required: false
|
required: false
|
||||||
run_unit_tests:
|
run_unit_tests:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Skip running unit tests'
|
description: "Skip running unit tests"
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
runs_on:
|
runs_on:
|
||||||
type: string
|
type: string
|
||||||
description: 'The GitHub hosted runner to use'
|
description: "The GitHub hosted runner to use"
|
||||||
required: true
|
required: true
|
||||||
OS:
|
OS:
|
||||||
type: string
|
type: string
|
||||||
description: >
|
description: >
|
||||||
The operating system targeted by the build.
|
The operating system targeted by the build.
|
||||||
|
|
||||||
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
|
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
|
||||||
required: true
|
required: true
|
||||||
architecture:
|
architecture:
|
||||||
type: string
|
type: string
|
||||||
description: 'CPU architecture targeted by the build.'
|
description: "CPU architecture targeted by the build."
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_CONFIGURATION: 'Release'
|
DOTNET_CONFIGURATION: "Release"
|
||||||
DOTNET_VERSION: '8.0.x'
|
DOTNET_VERSION: "9.0.x"
|
||||||
RELEASE_NAME: 'chardonnay'
|
RELEASE_NAME: "chardonnay"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: '${{ inputs.OS }}-${{ inputs.architecture }}'
|
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
|
||||||
runs-on: ${{ inputs.runs_on }}
|
runs-on: ${{ inputs.runs_on }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
|
||||||
fi
|
fi
|
||||||
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
echo "version=${version}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Unit test
|
- name: Unit test
|
||||||
if: ${{ inputs.run_unit_tests }}
|
if: ${{ inputs.run_unit_tests }}
|
||||||
working-directory: ./Source
|
working-directory: ./Source
|
||||||
@@ -69,7 +69,7 @@ jobs:
|
|||||||
- name: Publish
|
- name: Publish
|
||||||
id: publish
|
id: publish
|
||||||
working-directory: ./Source
|
working-directory: ./Source
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ inputs.OS }}" == "MacOS" ]]
|
if [[ "${{ inputs.OS }}" == "MacOS" ]]
|
||||||
then
|
then
|
||||||
display_os="macOS"
|
display_os="macOS"
|
||||||
@@ -78,13 +78,13 @@ jobs:
|
|||||||
display_os="Linux"
|
display_os="Linux"
|
||||||
RUNTIME_ID="linux-${{ inputs.architecture }}"
|
RUNTIME_ID="linux-${{ inputs.architecture }}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
|
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
|
||||||
|
|
||||||
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
|
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
|
||||||
echo "Runtime Identifier: $RUNTIME_ID"
|
echo "Runtime Identifier: $RUNTIME_ID"
|
||||||
echo "Output Directory: $OUTPUT"
|
echo "Output Directory: $OUTPUT"
|
||||||
|
|
||||||
dotnet publish \
|
dotnet publish \
|
||||||
LibationAvalonia/LibationAvalonia.csproj \
|
LibationAvalonia/LibationAvalonia.csproj \
|
||||||
--runtime $RUNTIME_ID \
|
--runtime $RUNTIME_ID \
|
||||||
@@ -122,7 +122,7 @@ jobs:
|
|||||||
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
|
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
|
||||||
artifact=$(ls ./bundle)
|
artifact=$(ls ./bundle)
|
||||||
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
- name: Publish bundle
|
- name: Publish bundle
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -8,22 +8,29 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
version_override:
|
version_override:
|
||||||
type: string
|
type: string
|
||||||
description: 'Version number override'
|
description: "Version number override"
|
||||||
required: false
|
required: false
|
||||||
run_unit_tests:
|
run_unit_tests:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Skip running unit tests'
|
description: "Skip running unit tests"
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
|
architecture:
|
||||||
|
type: string
|
||||||
|
description: "CPU architecture targeted by the build."
|
||||||
|
required: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_CONFIGURATION: 'Release'
|
DOTNET_CONFIGURATION: "Release"
|
||||||
DOTNET_VERSION: '8.0.x'
|
DOTNET_VERSION: "9.0.x"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: '${{ matrix.os }}-${{ matrix.release_name }}'
|
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
env:
|
||||||
|
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||||
|
RUNTIME_ID: "win-${{ inputs.architecture }}"
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [Windows]
|
os: [Windows]
|
||||||
@@ -63,46 +70,42 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
LibationCli/LibationCli.csproj `
|
LibationCli/LibationCli.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:DefineConstants="${{ matrix.release_name }}" `
|
-p:DefineConstants="${{ matrix.release_name }}" `
|
||||||
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
dotnet publish `
|
dotnet publish `
|
||||||
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
|
||||||
|
--runtime ${{ env.RUNTIME_ID }} `
|
||||||
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
--configuration ${{ env.DOTNET_CONFIGURATION }} `
|
||||||
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
|
--output bin/Publish/${{ env.OUTPUT_NAME }} `
|
||||||
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
|
||||||
|
|
||||||
- name: Zip artifact
|
- name: Zip artifact
|
||||||
id: zip
|
id: zip
|
||||||
working-directory: ./Source/bin/Publish
|
working-directory: ./Source/bin/Publish
|
||||||
run: |
|
run: |
|
||||||
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
|
$bin_dir = "${{ env.OUTPUT_NAME }}\"
|
||||||
$delfiles = @(
|
$delfiles = @(
|
||||||
"libmp3lame.x64.so",
|
|
||||||
"libmp3lame.arm64.so",
|
|
||||||
"libmp3lame.x64.dylib",
|
|
||||||
"libmp3lame.arm64.dylib",
|
|
||||||
"ffmpegaac.x64.so",
|
|
||||||
"ffmpegaac.arm64.so",
|
|
||||||
"ffmpegaac.x64.dylib",
|
|
||||||
"ffmpegaac.arm64.dylib",
|
|
||||||
"WindowsConfigApp.exe",
|
"WindowsConfigApp.exe",
|
||||||
"WindowsConfigApp.runtimeconfig.json",
|
"WindowsConfigApp.runtimeconfig.json",
|
||||||
"WindowsConfigApp.deps.json"
|
"WindowsConfigApp.deps.json"
|
||||||
)
|
)
|
||||||
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
|
||||||
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
|
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
|
||||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||||
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
|
||||||
|
|
||||||
@@ -112,4 +115,4 @@ jobs:
|
|||||||
name: ${{ steps.zip.outputs.artifact }}.zip
|
name: ${{ steps.zip.outputs.artifact }}.zip
|
||||||
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
@@ -8,22 +8,25 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
version_override:
|
version_override:
|
||||||
type: string
|
type: string
|
||||||
description: 'Version number override'
|
description: "Version number override"
|
||||||
required: false
|
required: false
|
||||||
run_unit_tests:
|
run_unit_tests:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: 'Skip running unit tests'
|
description: "Skip running unit tests"
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
windows:
|
windows:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
architecture: [x64]
|
||||||
uses: ./.github/workflows/build-windows.yml
|
uses: ./.github/workflows/build-windows.yml
|
||||||
with:
|
with:
|
||||||
version_override: ${{ inputs.version_override }}
|
version_override: ${{ inputs.version_override }}
|
||||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
|
architecture: ${{ matrix.architecture }}
|
||||||
|
|
||||||
linux:
|
linux:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -36,7 +39,7 @@ jobs:
|
|||||||
OS: ${{ matrix.OS }}
|
OS: ${{ matrix.OS }}
|
||||||
architecture: ${{ matrix.architecture }}
|
architecture: ${{ matrix.architecture }}
|
||||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
|
|
||||||
macos:
|
macos:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
@@ -47,4 +50,4 @@ jobs:
|
|||||||
runs_on: macos-latest
|
runs_on: macos-latest
|
||||||
OS: MacOS
|
OS: MacOS
|
||||||
architecture: ${{ matrix.architecture }}
|
architecture: ${{ matrix.architecture }}
|
||||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||||
@@ -8,7 +8,11 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
type: string
|
type: string
|
||||||
description: 'Version number'
|
description: "Version number"
|
||||||
|
required: true
|
||||||
|
release:
|
||||||
|
type: boolean
|
||||||
|
description: "Is this a release build?"
|
||||||
required: true
|
required: true
|
||||||
secrets:
|
secrets:
|
||||||
docker_username:
|
docker_username:
|
||||||
@@ -16,12 +20,10 @@ on:
|
|||||||
docker_token:
|
docker_token:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
env:
|
|
||||||
DOCKER_IMAGE: ${{ secrets.docker_username }}/libation
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
build_and_push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -33,14 +35,29 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
|
if: ${{ inputs.release }}
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.docker_username }}
|
username: ${{ secrets.docker_username }}
|
||||||
password: ${{ secrets.docker_token }}
|
password: ${{ secrets.docker_token }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Generate docker image tags
|
||||||
uses: docker/build-push-action@v5
|
id: metadata
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
push: true
|
flavor: |
|
||||||
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
latest=true
|
||||||
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}
|
images: |
|
||||||
|
name=${{ secrets.docker_username }}/libation
|
||||||
|
tags: |
|
||||||
|
type=raw,value=${{ inputs.version }},enable=${{ inputs.release }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ steps.metadata.outputs.tags != ''}}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
tags: ${{ steps.metadata.outputs.tags }}
|
||||||
|
labels: ${{ steps.metadata.outputs.labels }}
|
||||||
@@ -5,7 +5,7 @@ name: release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- "v*"
|
||||||
jobs:
|
jobs:
|
||||||
prerelease:
|
prerelease:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Get tag version
|
- name: Get tag version
|
||||||
id: get_version
|
id: get_version
|
||||||
run: |
|
run: |
|
||||||
export TAG='${{ github.ref_name }}'
|
export TAG="${{ github.ref_name }}"
|
||||||
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
|
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
@@ -23,6 +23,7 @@ jobs:
|
|||||||
uses: ./.github/workflows/docker.yml
|
uses: ./.github/workflows/docker.yml
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.prerelease.outputs.version }}
|
version: ${{ needs.prerelease.outputs.version }}
|
||||||
|
release: true
|
||||||
secrets:
|
secrets:
|
||||||
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -33,15 +34,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version_override: ${{ needs.prerelease.outputs.version }}
|
version_override: ${{ needs.prerelease.outputs.version }}
|
||||||
run_unit_tests: false
|
run_unit_tests: false
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: [prerelease,build]
|
needs: [prerelease, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
pattern: "*(Classic-)Libation.*"
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
id: release
|
id: release
|
||||||
@@ -49,13 +51,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: Libation ${{ needs.prerelease.outputs.version }}
|
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||||
body: <Put a body here>
|
body: <Put a body here>
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
draft: true
|
draft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
files: |
|
||||||
- name: Upload release assets
|
artifacts/*/*
|
||||||
uses: dwenegar/upload-release-assets@v2
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
|
||||||
with:
|
|
||||||
release_id: '${{ steps.release.outputs.id }}'
|
|
||||||
assets_path: ./artifacts
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Validate MetaInfo
|
||||||
|
"on":
|
||||||
|
pull_request:
|
||||||
|
branches: ["master"]
|
||||||
|
paths:
|
||||||
|
- .github/workflows/validate-appstream-metainfo.yml
|
||||||
|
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||||
|
push:
|
||||||
|
branches: ["master"]
|
||||||
|
paths:
|
||||||
|
- .github/workflows/validate-appstream-metainfo.yml
|
||||||
|
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-appstream-metainfo:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Check the MetaInfo file
|
||||||
|
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: Check desktop file
|
||||||
|
"on":
|
||||||
|
pull_request:
|
||||||
|
branches: ["master"]
|
||||||
|
paths:
|
||||||
|
- .github/workflows/validate-desktop-file.yml
|
||||||
|
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||||
|
push:
|
||||||
|
branches: ["master"]
|
||||||
|
paths:
|
||||||
|
- .github/workflows/validate-desktop-file.yml
|
||||||
|
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-desktop-file:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: sudo apt --yes install desktop-file-utils
|
||||||
|
- name: Check the desktop file
|
||||||
|
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# validate.yml
|
# validate.yml
|
||||||
# Validates that Libation will build on a pull request or push to master.
|
# Validates that Libation will build on a pull request or push to master.
|
||||||
---
|
---
|
||||||
name: validate
|
name: validate
|
||||||
|
|
||||||
@@ -12,3 +12,11 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/build.yml
|
uses: ./.github/workflows/build.yml
|
||||||
|
docker:
|
||||||
|
uses: ./.github/workflows/docker.yml
|
||||||
|
with:
|
||||||
|
version: ${GITHUB_SHA}
|
||||||
|
release: false
|
||||||
|
secrets:
|
||||||
|
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
|
||||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
|
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
|
||||||
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm",
|
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
|
||||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
|
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
|
||||||
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
|
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
|
||||||
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm",
|
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
|
||||||
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
|
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"LibationFiles": "/config-internal"
|
||||||
|
}
|
||||||
@@ -1,68 +1,174 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Rewire echo to print date time
|
error() {
|
||||||
echo() {
|
log "ERROR" "$1"
|
||||||
if [[ -n $1 ]]; then
|
|
||||||
printf "$(date '+%F %T'): %s\n" "$1"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ################################
|
warn() {
|
||||||
# Setup
|
log "WARNING" "$1"
|
||||||
# ################################
|
}
|
||||||
echo "Starting"
|
|
||||||
if [[ -z "${SLEEP_TIME}" ]]; then
|
|
||||||
echo "No sleep time passed in. Will run once and exit."
|
|
||||||
else
|
|
||||||
echo "Sleep time is set to ${SLEEP_TIME}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
info() {
|
||||||
|
log "info" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
# Check if the config directory is passed in, and there is no link to it then create the link.
|
debug() {
|
||||||
if [ -d "/config" ] && [ ! -d "/root/Libation" ]; then
|
if [ "${LOG_LEVEL}" = "debug" ]; then
|
||||||
echo "Linking config directory to the Libation config directory"
|
log "debug" "$1"
|
||||||
ln -s /config/ /root/Libation
|
fi
|
||||||
fi
|
}
|
||||||
|
|
||||||
# If no config error and exit
|
log() {
|
||||||
if [ ! -d "/config" ]; then
|
LEVEL=$1
|
||||||
echo "ERROR: No /config directory. You must pass in a volume containing your config mapped to /config"
|
MESSAGE=$2
|
||||||
|
printf "$(date '+%F %T') %s: %s\n" "${LEVEL}" "${MESSAGE}"
|
||||||
|
}
|
||||||
|
|
||||||
|
init_config_file() {
|
||||||
|
FILE=$1
|
||||||
|
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
|
||||||
|
if [ -f ${FULLPATH} ]; then
|
||||||
|
info "loading ${FILE}"
|
||||||
|
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
warn "${FULLPATH} not found, creating empty file"
|
||||||
|
echo "{}" > ${LIBATION_CONFIG_INTERNAL}/${FILE}
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
update_settings() {
|
||||||
|
FILE=$1
|
||||||
|
KEY=$2
|
||||||
|
VALUE=$3
|
||||||
|
info "setting ${KEY} to ${VALUE}"
|
||||||
|
echo $(jq --arg k "${KEY}" --arg v "${VALUE}" '.[$k] = $v' ${LIBATION_CONFIG_INTERNAL}/${FILE}) > ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp
|
||||||
|
mv ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp ${LIBATION_CONFIG_INTERNAL}/${FILE}
|
||||||
|
}
|
||||||
|
|
||||||
|
is_mounted() {
|
||||||
|
DIR=$1
|
||||||
|
if grep -qs "${DIR} " /proc/mounts;
|
||||||
|
then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
create_db() {
|
||||||
|
DBFILE=$1
|
||||||
|
if [ -f "${DBFILE}" ]; then
|
||||||
|
warn "prexisting database found when creating"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
if ! touch "${DBFILE}"; then
|
||||||
|
error "unable to create database, check permissions on host"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_db() {
|
||||||
|
DBPATH=$1
|
||||||
|
dbpattern="*.db"
|
||||||
|
|
||||||
|
debug "using database directory ${DBPATH}"
|
||||||
|
|
||||||
|
# Figure out the right databse file
|
||||||
|
if [[ -z "${LIBATION_DB_FILE}" ]];
|
||||||
|
then
|
||||||
|
dbCount=$(find "${DBPATH}" -maxdepth 1 -type f -name "${dbpattern}" | wc -l)
|
||||||
|
if [ "${dbCount}" -gt 1 ];
|
||||||
|
then
|
||||||
|
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
|
||||||
|
exit 1
|
||||||
|
elif [ "${dbCount}" -eq 1 ];
|
||||||
|
then
|
||||||
|
files=( ${DBPATH}/${dbpattern} )
|
||||||
|
FILE=${files[0]}
|
||||||
|
else
|
||||||
|
FILE="${DBPATH}/LibationContext.db"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
FILE="${DBPATH}/${LIBATION_DB_FILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
debug "planning to use database ${FILE}"
|
||||||
|
|
||||||
|
if [ -f "${FILE}" ]; then
|
||||||
|
info "database found at ${FILE}"
|
||||||
|
elif [ ${LIBATION_CREATE_DB} = "true" ];
|
||||||
|
then
|
||||||
|
warn "database not found, creating one at ${FILE}"
|
||||||
|
create_db ${FILE}
|
||||||
|
else
|
||||||
|
error "database not found and creation is disabled"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
ln -s "${FILE}" "${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
|
||||||
|
}
|
||||||
|
|
||||||
# If user passes in db from a /db/ folder and a db does not already exist / is not already linked
|
run() {
|
||||||
FILE=/db/LibationContext.db
|
info "scanning accounts"
|
||||||
if [ -f "${FILE}" ] && [ ! -f "/config/LibationContext.db" ]; then
|
/libation/LibationCli scan
|
||||||
echo "Linking passed in Libation database from /db/ to the Libation config directory"
|
info "liberating books"
|
||||||
ln -s $FILE /config/LibationContext.db
|
/libation/LibationCli liberate
|
||||||
fi
|
}
|
||||||
|
|
||||||
# Confirm we have a db in the config direcotry.
|
main() {
|
||||||
if [ ! -f "/config/LibationContext.db" ]; then
|
info "initializing libation"
|
||||||
echo "ERROR: No Libation database detected, exiting."
|
init_config_file AccountsSettings.json
|
||||||
exit 1
|
init_config_file Settings.json
|
||||||
fi
|
|
||||||
|
info "loading settings"
|
||||||
|
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
|
||||||
|
update_settings Settings.json InProgress /tmp
|
||||||
|
|
||||||
# ################################
|
info "loading database"
|
||||||
# Loop and liberate
|
# If user provides a separate database mount, use that
|
||||||
# ################################
|
if is_mounted "${LIBATION_DB_DIR}";
|
||||||
while true
|
then
|
||||||
do
|
DB_LOCATION=${LIBATION_DB_DIR}
|
||||||
echo ""
|
# Otherwise, use the config directory
|
||||||
echo "Scanning accounts"
|
else
|
||||||
/libation/LibationCli scan
|
DB_LOCATION=${LIBATION_CONFIG_DIR}
|
||||||
echo "Liberating books"
|
fi
|
||||||
/libation/LibationCli liberate
|
setup_db ${DB_LOCATION}
|
||||||
echo ""
|
|
||||||
|
# Try to warn if books dir wasn't mounted in
|
||||||
|
if ! is_mounted "${LIBATION_BOOKS_DIR}";
|
||||||
|
then
|
||||||
|
warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Let the user know what the run type will be
|
||||||
|
if [[ -z "${SLEEP_TIME}" ]]; then
|
||||||
|
SLEEP_TIME=-1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${SLEEP_TIME}" == -1 ]; then
|
||||||
|
info "running once"
|
||||||
|
else
|
||||||
|
info "running every ${SLEEP_TIME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# loop
|
||||||
|
while true
|
||||||
|
do
|
||||||
|
run
|
||||||
|
|
||||||
# Liberate only once if SLEEP_TIME was set to -1
|
# Liberate only once if SLEEP_TIME was set to -1
|
||||||
if [ "${SLEEP_TIME}" = -1 ]; then
|
if [ "${SLEEP_TIME}" == -1 ]; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Sleeping for ${SLEEP_TIME}"
|
|
||||||
sleep "${SLEEP_TIME}"
|
sleep "${SLEEP_TIME}"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Exiting"
|
info "exiting"
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
@@ -1,22 +1,39 @@
|
|||||||
# Dockerfile
|
# Dockerfile
|
||||||
FROM mcr.microsoft.com/dotnet/sdk:8.0 as build-env
|
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
COPY Source /Source
|
COPY Source /Source
|
||||||
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
RUN dotnet publish \
|
||||||
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
|
/Source/LibationCli/LibationCli.csproj \
|
||||||
|
--arch ${TARGETARCH} \
|
||||||
|
--configuration Release \
|
||||||
|
--output /Source/bin/Publish/Linux-chardonnay \
|
||||||
|
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:9.0
|
||||||
|
ARG USER_UID=1001
|
||||||
|
ARG USER_GID=1001
|
||||||
|
|
||||||
|
# Set the character set that will be used for folder and filenames when liberating
|
||||||
|
ENV LANG=C.UTF-8
|
||||||
|
ENV LC_ALL=C.UTF-8
|
||||||
|
|
||||||
|
ENV SLEEP_TIME=-1
|
||||||
|
ENV LIBATION_CONFIG_INTERNAL=/config-internal
|
||||||
|
ENV LIBATION_CONFIG_DIR=/config
|
||||||
|
ENV LIBATION_DB_DIR=/db
|
||||||
|
ENV LIBATION_DB_FILE=
|
||||||
|
ENV LIBATION_CREATE_DB=true
|
||||||
|
ENV LIBATION_BOOKS_DIR=/data
|
||||||
|
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/runtime:8.0
|
RUN apt-get update && apt-get -y upgrade && \
|
||||||
|
apt-get install -y jq && \
|
||||||
|
mkdir -m777 ${LIBATION_CONFIG_INTERNAL} ${LIBATION_BOOKS_DIR}
|
||||||
|
|
||||||
ENV SLEEP_TIME "30m"
|
COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation
|
||||||
|
COPY Docker/* /libation
|
||||||
|
|
||||||
# Sets the character set that will be used for folder and filenames when liberating
|
USER ${USER_UID}:${USER_GID}
|
||||||
ENV LANG C.UTF-8
|
|
||||||
ENV LC_ALL C.UTF-8
|
|
||||||
|
|
||||||
RUN mkdir /db /config /data
|
CMD ["/libation/liberate.sh"]
|
||||||
|
|
||||||
COPY --from=build-env /Source/bin/Publish/Linux-chardonnay /libation
|
|
||||||
|
|
||||||
|
|
||||||
CMD ["./libation/liberate.sh"]
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
- [Settings](#settings)
|
- [Settings](#settings)
|
||||||
- [Custom File Naming](NamingTemplates.md)
|
- [Custom File Naming](NamingTemplates.md)
|
||||||
- [Command Line Interface](#command-line-interface)
|
- [Command Line Interface](#command-line-interface)
|
||||||
|
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -86,3 +87,25 @@ CLI: Full library. No prompt
|
|||||||
libationcli set-status -n
|
libationcli set-status -n
|
||||||
libationcli set-status -d -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -3,38 +3,30 @@
|
|||||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
> [!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
|
# Disclaimer
|
||||||
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
|
||||||
|
|
||||||
### Setup
|
### Configuration
|
||||||
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
|
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.
|
||||||
|
|
||||||
In Settings.json, make the following changes:
|
|
||||||
* Change `Books` to `/data`
|
|
||||||
* Change `InProgress` to `/tmp` *
|
|
||||||
|
|
||||||
*You may have to paste the following at the end of your your Settings.json file if `InProgess` is not present:
|
|
||||||
|
|
||||||
```
|
|
||||||
"InProgress": "/tmp"
|
|
||||||
```
|
|
||||||

|
|
||||||
|
|
||||||
|
|
||||||
### Running
|
### Running
|
||||||
Once the configuration files are copied and edited, the docker image can be run with the following command.
|
Once the configuration files are copied, the docker image can be run with the following command.
|
||||||
```
|
```
|
||||||
sudo docker run -d \
|
sudo docker run -d \
|
||||||
-v /opt/libation/config:/config \
|
-v /opt/libation/config:/config \
|
||||||
-v /opt/libation/books:/data \
|
-v /opt/libation/books:/data \
|
||||||
--name libation \
|
--name libation \
|
||||||
--restart=always \
|
--restart=always \
|
||||||
rmcrackan/libation
|
rmcrackan/libation:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
|
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 \
|
sudo docker run -d \
|
||||||
@@ -43,6 +35,42 @@ sudo docker run -d \
|
|||||||
-e SLEEP_TIME='10m' \
|
-e SLEEP_TIME='10m' \
|
||||||
--name libation \
|
--name libation \
|
||||||
--restart=always \
|
--restart=always \
|
||||||
rmcrackan/libation
|
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.
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
|
|
||||||
# Frequently Asked Questions
|
# 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'?
|
## Q: What's the difference between 'Classic' and 'Chardonnay'?
|
||||||
|
|
||||||
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
|
||||||
@@ -31,6 +35,10 @@ Self-hosting online:
|
|||||||
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
|
* [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)
|
* [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?
|
## 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.
|
**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.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
- [Download PDF attachments](#download-pdf-attachments)
|
- [Download PDF attachments](#download-pdf-attachments)
|
||||||
- [Details of downloaded files](#details-of-downloaded-files)
|
- [Details of downloaded files](#details-of-downloaded-files)
|
||||||
- [Export your library](#export-your-library)
|
- [Export your library](#export-your-library)
|
||||||
|
- [I still need help](#i-still-need-help)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -148,3 +149,7 @@ When you set up Libation, you'll specify a Books directory. Libation looks insid
|
|||||||

|

|
||||||
|
|
||||||
Export your library to Excel, CSV, or JSON
|
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.
|
||||||
@@ -4,25 +4,61 @@
|
|||||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||||
|
|
||||||
|
|
||||||
|
## Packaging status
|
||||||
|
|
||||||
# Install and Run Libation on Ubuntu
|
[](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.
|
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`).
|
||||||
|
|
||||||
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
|
### Debian
|
||||||
|
|
||||||
- Debian
|
|
||||||
```Console
|
```Console
|
||||||
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
|
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
|
sudo apt install ./libation.deb
|
||||||
```
|
```
|
||||||
- Redhat and CentOS
|
### Redhat and CentOS
|
||||||
```Console
|
```Console
|
||||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
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
|
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.
|
If your desktop uses gtk, you should now see Libation among your applications.
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,12 @@
|
|||||||
# Run Libation on MacOS
|
# Run Libation on MacOS
|
||||||
This walkthrough should get you up and running with Libation on your Mac.
|
This walkthrough should get you up and running with Libation on your Mac.
|
||||||
|
|
||||||
## Supports macOS 10.15 (Catalina) and above
|
## Supports macOS 13 (Ventura) and above
|
||||||
|
|
||||||
## Install Libation
|
## Install Libation
|
||||||
|
|
||||||
- Download the file from the latest release and extract it.
|
- Download the file from the latest release and extract it.
|
||||||
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
|
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
|
||||||
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||||
- Move the extracted Libation app bundle to your applications folder.
|
- Move the extracted Libation app bundle to your applications folder.
|
||||||
- Right-click on Libation and then click on open
|
- Right-click on Libation and then click on open
|
||||||
@@ -40,6 +40,15 @@ You can add Libation as a safe app without touching Gatekeeper.
|
|||||||
|
|
||||||
* Close the terminal and use Libation!
|
* 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
|
## Troubleshooting
|
||||||
|
|
||||||
If Libation fails to start after completing the above steps, try the following:
|
If Libation fails to start after completing the above steps, try the following:
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ These templates apply to both GUI and CLI.
|
|||||||
- [Conditional Tags](#conditional-tags)
|
- [Conditional Tags](#conditional-tags)
|
||||||
- [Tag Formatters](#tag-formatters)
|
- [Tag Formatters](#tag-formatters)
|
||||||
- [Text Formatters](#text-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)
|
- [Name List Formatters](#name-list-formatters)
|
||||||
- [Number Formatters](#number-formatters)
|
- [Number Formatters](#number-formatters)
|
||||||
- [Date Formatters](#date-formatters)
|
- [Date Formatters](#date-formatters)
|
||||||
@@ -32,32 +35,33 @@ These tags will be replaced in the template with the audiobook's values.
|
|||||||
|Tag|Description|Type|
|
|Tag|Description|Type|
|
||||||
|-|-|-|
|
|-|-|-|
|
||||||
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||||
|\<title\>|Full title with subtitle|Text|
|
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|
||||||
|\<title short\>|Title. Stop at first colon|Text|
|
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|
||||||
|\<audible title\>|Audible's title (does not include subtitle)|Text|
|
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|
||||||
|\<audible subtitle\>|Audible's subtitle|Text|
|
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|
||||||
|\<author\>|Author(s)|Name List|
|
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|
||||||
|\<first author\>|First author|Text|
|
|\<first author\>|First author|[Name](#name-formatters)|
|
||||||
|\<narrator\>|Narrator(s)|Name List|
|
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|
||||||
|\<first narrator\>|First narrator|Text|
|
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|
||||||
|\<series\>|Name of series|Text|
|
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
||||||
|\<series#\>|Number order in series|Number|
|
|\<first series\>|First series|[Series](#series-formatters)|
|
||||||
|\<bitrate\>|File's original bitrate (Kbps)|Number|
|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||||
|\<samplerate\>|File's original audio sample rate|Number|
|
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|
||||||
|\<channels\>|Number of audio channels|Number|
|
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|
||||||
|\<account\>|Audible account of this book|Text|
|
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|
||||||
|\<account nickname\>|Audible account nickname of this book|Text|
|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|
||||||
|\<locale\>|Region/country|Text|
|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||||
|\<year\>|Year published|Number|
|
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||||
|\<language\>|Book's language|Text|
|
|\<year\>|Year published|[Number](#number-formatters)|
|
||||||
|
|\<language\>|Book's language|[Text](#text-formatters)|
|
||||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||||
|\<file date\>|File creation date/time.|DateTime|
|
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|
||||||
|\<pub date\>|Audiobook publication date|DateTime|
|
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|
||||||
|\<date added\>|Date the book added to your Audible account|DateTime|
|
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|
||||||
|\<ch count\> **‡**|Number of chapters|Number|
|
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|
||||||
|\<ch title\> **‡**|Chapter title|Text|
|
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|
||||||
|\<ch#\> **‡**|Chapter number|Number|
|
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|
||||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number|
|
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
|
||||||
|
|
||||||
**†** Does not support custom formatting
|
**†** Does not support custom formatting
|
||||||
|
|
||||||
@@ -95,11 +99,28 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|
|||||||
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel|
|
|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|
|
|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
|
## Name List Formatters
|
||||||
|Formatter|Description|Example Usage|Example Result|
|
|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|
|
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||||
|format(\{T \| F \| M \| L \| S\})|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<author[format({L}, {F})`<br>`separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
|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|
|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||||
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,36 @@
|
|||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 524 524" enable-background="new 0 0 524 524">
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
|
||||||
<defs>
|
<defs>
|
||||||
<g id="glass">
|
<g id="glass">
|
||||||
<path fill-rule="evenodd" d=
|
<path transform="translate(16 16)" fill-rule="evenodd" d=
|
||||||
"M262,8
|
"M177,16
|
||||||
h-117
|
H79
|
||||||
a 192,200 0 0 0 -36,82
|
A 32.0781 63.7932 -1.5106 0 0 66 80
|
||||||
a 222,334 41 0 0 138,236
|
A 158.789 471.1259 41.9466 0 0 90 131
|
||||||
v158
|
A 81.7197 122.0515 35.3745 0 0 128 143.3484
|
||||||
h-81
|
A 81.7197 122.0515 -35.3745 0 0 166 131
|
||||||
a 16,16 0 0 0 0,32
|
A 158.789 471.1259 -41.9466 0 0 190 80
|
||||||
h192
|
A 32.0781 63.7932 1.5106 0 0 177 16
|
||||||
a 16 16 0 0 0 0,-32
|
L 184 0
|
||||||
h-81
|
A 44.7901 78.5247 1.1521 0 1 194 122
|
||||||
v-158
|
A 97.0039 135.3148 -36.2124 0 1 136 159
|
||||||
a 222,334 -41 0 0 138,-236
|
V 240
|
||||||
a 192,200 0 0 0 -36,-82
|
H 176
|
||||||
h-117
|
A 8 8 0 0 1 176 256
|
||||||
m-99,30
|
H 80
|
||||||
a 192,200 0 0 0 -26,95
|
A 8 8 0 0 1 80 240
|
||||||
a 187.5,334 35 0 0 125,159
|
H 120
|
||||||
a 187.5,334 -35 0 0 125,-159
|
V 159
|
||||||
a 192,200 0 0 0 -26,-95
|
A 97.0039 135.3148 36.2124 0 1 62 122
|
||||||
h-198
|
A 44.7901 78.5247 -1.1521 0 1 72 0
|
||||||
|
H184
|
||||||
z"/>
|
z"/>
|
||||||
</g>
|
</g>
|
||||||
<g id="wine-level">
|
<g transform="translate(16 16)" id="wine-level">
|
||||||
<path d=
|
<path d=
|
||||||
"M158,136
|
"M182,64
|
||||||
a 168,305 35 0 0 104,136
|
H 74
|
||||||
a 168,305 -35 0 0 104,-136
|
A 115.9979 308.8033 38.9474 0 0 128 134.4277
|
||||||
|
A 115.9979 308.8033 -38.9474 0 0 182,64
|
||||||
z"/>
|
z"/>
|
||||||
</g>
|
</g>
|
||||||
</defs>
|
</defs>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 968 B After Width: | Height: | Size: 1.2 KiB |
@@ -1,30 +1,31 @@
|
|||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
|
||||||
|
<g>
|
||||||
<g transform="translate(0 80) rotate(90 256,256)">
|
<path transform="rotate(90 128,128) translate(60 -16)" fill-rule="evenodd" d=
|
||||||
<path id="glass" d=
|
"M177,16
|
||||||
"M139,2
|
H79
|
||||||
A 192,200 0 0 0 103,84
|
A 32.0781 63.7932 -1.5106 0 0 66 80
|
||||||
A 222,334 41 0 0 241,320
|
A 158.789 471.1259 41.9466 0 0 90 131
|
||||||
V478
|
A 81.7197 122.0515 35.3745 0 0 128 143.3484
|
||||||
H160
|
A 81.7197 122.0515 -35.3745 0 0 166 131
|
||||||
A 16,16 0 0 0 160,510
|
A 158.789 471.1259 -41.9466 0 0 190 80
|
||||||
H352
|
A 32.0781 63.7932 1.5106 0 0 177 16
|
||||||
A16 16 0 0 0 352,478
|
L 184 0
|
||||||
H271
|
A 44.7901 78.5247 1.1521 0 1 194 122
|
||||||
V320
|
A 97.0039 135.3148 -36.2124 0 1 136 159
|
||||||
A 222,334 -41 0 0 409,84
|
V 240
|
||||||
A 192,200 0 0 0 373,2
|
H 176
|
||||||
M355,32
|
A 8 8 0 0 1 176 256
|
||||||
A 192,200 0 0 1 381,127
|
H 80
|
||||||
A 187.5,334 -35 0 1 256,286
|
A 8 8 0 0 1 80 240
|
||||||
A 187.5,334 35 0 1 131,127
|
H 120
|
||||||
A 192,200 0 0 1 157,32
|
V 159
|
||||||
H355
|
A 97.0039 135.3148 36.2124 0 1 62 122
|
||||||
z" />
|
A 44.7901 78.5247 -1.1521 0 1 72 0
|
||||||
<path id="wine-level" d=
|
H184
|
||||||
"M345,44
|
M170,115
|
||||||
A 192,184 0 0 1 366,126
|
V24
|
||||||
A 320,180 55 0 1 345,226
|
A 19.5181 45.9183 -3.3549 0 1 182.4322 69.5
|
||||||
z"/>
|
A 19.5181 45.9183 3.3549 0 1 170 115
|
||||||
</g>
|
z"/>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 638 B After Width: | Height: | Size: 936 B |
@@ -22,6 +22,7 @@
|
|||||||
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
|
||||||
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
|
||||||
- [Export your library](Documentation/GettingStarted.md#export-your-library)
|
- [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)
|
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
|
||||||
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
- [Tags](Documentation/SearchingAndFiltering.md#tags)
|
||||||
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
- [Searches](Documentation/SearchingAndFiltering.md#searches)
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
- [Settings](Documentation/Advanced.md#settings)
|
- [Settings](Documentation/Advanced.md#settings)
|
||||||
- [Custom File Naming](Documentation/NamingTemplates.md)
|
- [Custom File Naming](Documentation/NamingTemplates.md)
|
||||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||||
|
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
|
||||||
- [Docker](Documentation/Docker.md)
|
- [Docker](Documentation/Docker.md)
|
||||||
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
||||||
|
|
||||||
|
|||||||
@@ -53,13 +53,7 @@ if [ $? -ne 0 ]
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
||||||
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||||
if [[ "$ARCH" == "arm64" ]]
|
|
||||||
then
|
|
||||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
|
||||||
else
|
|
||||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
|
||||||
fi
|
|
||||||
|
|
||||||
for n in "${delfiles[@]}"
|
for n in "${delfiles[@]}"
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -82,14 +82,7 @@ echo "Set CFBundleVersion to $VERSION"
|
|||||||
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
||||||
|
|
||||||
|
|
||||||
delfiles=( 'libmp3lame.arm64.so' 'libmp3lame.x64.so' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.so' 'ffmpegaac.x64.so' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||||
if [[ "$ARCH" == "arm64" ]]
|
|
||||||
then
|
|
||||||
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
|
|
||||||
else
|
|
||||||
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
|
||||||
for n in "${delfiles[@]}"
|
for n in "${delfiles[@]}"
|
||||||
do
|
do
|
||||||
@@ -111,4 +104,4 @@ mv $APP_FILE ./bundle/$APP_FILE
|
|||||||
|
|
||||||
rm -r $BUNDLE
|
rm -r $BUNDLE
|
||||||
|
|
||||||
echo "Done!"
|
echo "Done!"
|
||||||
@@ -38,14 +38,12 @@ fi
|
|||||||
|
|
||||||
BASEDIR=$(pwd)
|
BASEDIR=$(pwd)
|
||||||
|
|
||||||
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||||
if [[ "$ARCH" == "x64" ]]
|
if [[ "$ARCH" == "x64" ]]
|
||||||
then
|
then
|
||||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
|
||||||
ARCH_RPM="x86_64"
|
ARCH_RPM="x86_64"
|
||||||
ARCH="amd64"
|
ARCH="amd64"
|
||||||
else
|
else
|
||||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
|
||||||
ARCH_RPM="aarch64"
|
ARCH_RPM="aarch64"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AAXClean.Codecs" Version="1.1.3" />
|
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using AAXClean;
|
using AAXClean;
|
||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
@@ -8,7 +9,7 @@ namespace AaxDecrypter
|
|||||||
{
|
{
|
||||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||||
|
|
||||||
protected AaxFile AaxFile { get; private set; }
|
public Mp4File AaxFile { get; private set; }
|
||||||
protected Mp4Operation AaxConversion { get; set; }
|
protected Mp4Operation AaxConversion { get; set; }
|
||||||
|
|
||||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||||
@@ -24,19 +25,51 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
public override async Task CancelAsync()
|
public override async Task CancelAsync()
|
||||||
{
|
{
|
||||||
IsCanceled = true;
|
await base.CancelAsync();
|
||||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||||
FinalizeDownload();
|
}
|
||||||
|
|
||||||
|
private Mp4File Open()
|
||||||
|
{
|
||||||
|
if (DownloadOptions.InputType is FileType.Dash)
|
||||||
|
{
|
||||||
|
//We may have multiple keys , so use the key whose key ID matches
|
||||||
|
//the dash files default Key ID.
|
||||||
|
var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
|
||||||
|
|
||||||
|
var dash = new DashFile(InputFileStream);
|
||||||
|
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
|
||||||
|
|
||||||
|
if (kidIndex == -1)
|
||||||
|
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
|
||||||
|
|
||||||
|
DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex];
|
||||||
|
var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1;
|
||||||
|
var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2;
|
||||||
|
|
||||||
|
dash.SetDecryptionKey(keyId, key);
|
||||||
|
return dash;
|
||||||
|
}
|
||||||
|
else if (DownloadOptions.InputType is FileType.Aax)
|
||||||
|
{
|
||||||
|
var aax = new AaxFile(InputFileStream);
|
||||||
|
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1);
|
||||||
|
return aax;
|
||||||
|
}
|
||||||
|
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||||
|
{
|
||||||
|
var aax = new AaxFile(InputFileStream);
|
||||||
|
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2);
|
||||||
|
return aax;
|
||||||
|
}
|
||||||
|
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected bool Step_GetMetadata()
|
protected bool Step_GetMetadata()
|
||||||
{
|
{
|
||||||
AaxFile = new AaxFile(InputFileStream);
|
AaxFile = Open();
|
||||||
|
|
||||||
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null)
|
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
|
|
||||||
else
|
|
||||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
|
||||||
|
|
||||||
if (DownloadOptions.StripUnabridged)
|
if (DownloadOptions.StripUnabridged)
|
||||||
{
|
{
|
||||||
@@ -83,12 +116,10 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
OnInitialized();
|
OnInitialized();
|
||||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
|
||||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
|
||||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||||
|
|
||||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
|
||||||
|
|
||||||
return !IsCanceled;
|
return !IsCanceled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
DownloadOptions.LameConfig
|
DownloadOptions.LameConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
|
||||||
{
|
{
|
||||||
MultiConvertFileProperties props = new()
|
MultiConvertFileProperties props = new()
|
||||||
{
|
{
|
||||||
@@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
|||||||
{
|
{
|
||||||
return Mp4File.RelocateMoovAsync(filename);
|
return Mp4File.RelocateMoovAsync(filename);
|
||||||
}
|
}
|
||||||
else return Mp4Operation.CompletedOperation;
|
else return Mp4Operation.FromCompleted(AaxFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ namespace AaxDecrypter
|
|||||||
public bool IsCanceled { get; protected set; }
|
public bool IsCanceled { get; protected set; }
|
||||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||||
protected string OutputFileName { get; }
|
protected string OutputFileName { get; }
|
||||||
protected IDownloadOptions DownloadOptions { get; }
|
public IDownloadOptions DownloadOptions { get; }
|
||||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||||
private bool downloadFinished;
|
private bool downloadFinished;
|
||||||
@@ -73,11 +73,16 @@ namespace AaxDecrypter
|
|||||||
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
||||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||||
|
|
||||||
|
//Stop the downloader so it doesn't keep running in the background.
|
||||||
|
if (!success)
|
||||||
|
nfsPersister.Dispose();
|
||||||
|
|
||||||
await progressTask;
|
await progressTask;
|
||||||
|
|
||||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||||
|
|
||||||
|
nfsPersister.Dispose();
|
||||||
return success;
|
return success;
|
||||||
|
|
||||||
async Task reportProgress()
|
async Task reportProgress()
|
||||||
@@ -115,7 +120,12 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task CancelAsync();
|
public virtual Task CancelAsync()
|
||||||
|
{
|
||||||
|
IsCanceled = true;
|
||||||
|
FinalizeDownload();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||||
|
|
||||||
public virtual void SetCoverArt(byte[] coverArt) { }
|
public virtual void SetCoverArt(byte[] coverArt) { }
|
||||||
@@ -177,20 +187,39 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
if (DownloadOptions.DecryptionKeys != null &&
|
||||||
DownloadOptions.RetainEncryptedFile)
|
DownloadOptions.RetainEncryptedFile &&
|
||||||
|
DownloadOptions.InputType is AAXClean.FileType fileType)
|
||||||
{
|
{
|
||||||
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
|
||||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
|
||||||
|
|
||||||
//Write aax decryption key
|
//Write aax decryption key
|
||||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
string keyPath = Path.ChangeExtension(tempFilePath, ".key");
|
||||||
FileUtility.SaferDelete(keyPath);
|
FileUtility.SaferDelete(keyPath);
|
||||||
|
string aaxPath;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
|
if (fileType is AAXClean.FileType.Aax)
|
||||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
{
|
||||||
|
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}");
|
||||||
|
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||||
|
}
|
||||||
|
else if (fileType is AAXClean.FileType.Aaxc)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(keyPath,
|
||||||
|
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
||||||
|
$"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
||||||
|
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
|
||||||
|
}
|
||||||
|
else if (fileType is AAXClean.FileType.Dash)
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(keyPath,
|
||||||
|
$"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
|
||||||
|
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
|
||||||
|
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
|
||||||
|
}
|
||||||
else
|
else
|
||||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
throw new InvalidOperationException($"Unknown file type: {fileType}");
|
||||||
|
|
||||||
|
if (tempFilePath != aaxPath)
|
||||||
|
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||||
|
|
||||||
OnFileCreated(aaxPath);
|
OnFileCreated(aaxPath);
|
||||||
OnFileCreated(keyPath);
|
OnFileCreated(keyPath);
|
||||||
@@ -217,6 +246,7 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
nfsp?.Target?.Dispose();
|
||||||
FileUtility.SaferDelete(jsonDownloadState);
|
FileUtility.SaferDelete(jsonDownloadState);
|
||||||
FileUtility.SaferDelete(tempFilePath);
|
FileUtility.SaferDelete(tempFilePath);
|
||||||
return nfsp = newNetworkFilePersister();
|
return nfsp = newNetworkFilePersister();
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ public class AverageSpeed
|
|||||||
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
||||||
|
|
||||||
/// <param name="slowWindow">Total moving average time window</param>
|
/// <param name="slowWindow">Total moving average time window</param>
|
||||||
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
/// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||||
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
|
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
|
||||||
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
|
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
|
||||||
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
|
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
|
||||||
@@ -119,7 +119,7 @@ public class AverageSpeed
|
|||||||
/// <summary>Add a new position to the moving average</summary>
|
/// <summary>Add a new position to the moving average</summary>
|
||||||
public void AddPosition(double position)
|
public void AddPosition(double position)
|
||||||
{
|
{
|
||||||
var now = DateTime.Now;
|
var now = DateTime.UtcNow;
|
||||||
if (start == default)
|
if (start == default)
|
||||||
start = now;
|
start = now;
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,35 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
namespace AaxDecrypter
|
||||||
{
|
{
|
||||||
public interface IDownloadOptions
|
public class KeyData
|
||||||
|
{
|
||||||
|
public byte[] KeyPart1 { get; }
|
||||||
|
public byte[]? KeyPart2 { get; }
|
||||||
|
|
||||||
|
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
|
||||||
|
{
|
||||||
|
KeyPart1 = keyPart1;
|
||||||
|
KeyPart2 = keyPart2;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyData(string keyPart1, string? keyPart2 = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
|
||||||
|
KeyPart1 = Convert.FromHexString(keyPart1);
|
||||||
|
if (keyPart2 != null)
|
||||||
|
KeyPart2 = Convert.FromHexString(keyPart2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IDownloadOptions
|
||||||
{
|
{
|
||||||
event EventHandler<long> DownloadSpeedChanged;
|
event EventHandler<long> DownloadSpeedChanged;
|
||||||
string DownloadUrl { get; }
|
string DownloadUrl { get; }
|
||||||
string UserAgent { get; }
|
string UserAgent { get; }
|
||||||
string AudibleKey { get; }
|
KeyData[]? DecryptionKeys { get; }
|
||||||
string AudibleIV { get; }
|
|
||||||
TimeSpan RuntimeLength { get; }
|
TimeSpan RuntimeLength { get; }
|
||||||
OutputFormat OutputFormat { get; }
|
OutputFormat OutputFormat { get; }
|
||||||
bool TrimOutputToChapterLength { get; }
|
bool TrimOutputToChapterLength { get; }
|
||||||
@@ -21,19 +41,20 @@ namespace AaxDecrypter
|
|||||||
long DownloadSpeedBps { get; }
|
long DownloadSpeedBps { get; }
|
||||||
ChapterInfo ChapterInfo { get; }
|
ChapterInfo ChapterInfo { get; }
|
||||||
bool FixupFile { get; }
|
bool FixupFile { get; }
|
||||||
string AudibleProductId { get; }
|
string? AudibleProductId { get; }
|
||||||
string Title { get; }
|
string? Title { get; }
|
||||||
string Subtitle { get; }
|
string? Subtitle { get; }
|
||||||
string Publisher { get; }
|
string? Publisher { get; }
|
||||||
string Language { get; }
|
string? Language { get; }
|
||||||
string SeriesName { get; }
|
string? SeriesName { get; }
|
||||||
float? SeriesNumber { get; }
|
float? SeriesNumber { get; }
|
||||||
NAudio.Lame.LameConfig LameConfig { get; }
|
NAudio.Lame.LameConfig? LameConfig { get; }
|
||||||
bool Downsample { get; }
|
bool Downsample { get; }
|
||||||
bool MatchSourceBitrate { get; }
|
bool MatchSourceBitrate { get; }
|
||||||
bool MoveMoovToBeginning { get; }
|
bool MoveMoovToBeginning { get; }
|
||||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||||
|
public FileType? InputType { get; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,18 +55,20 @@ namespace AaxDecrypter
|
|||||||
private CancellationTokenSource _cancellationSource { get; } = new();
|
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||||
private EventWaitHandle _downloadedPiece { get; set; }
|
private EventWaitHandle _downloadedPiece { get; set; }
|
||||||
|
|
||||||
|
private DateTime NextUpdateTime { get; set; }
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constants
|
#region Constants
|
||||||
|
|
||||||
//Download buffer size
|
//Download memory buffer size
|
||||||
private const int DOWNLOAD_BUFF_SZ = 32 * 1024;
|
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
||||||
|
|
||||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||||
|
|
||||||
//Number of times per second the download rate is checkd and throttled
|
//Number of times per second the download rate is checked and throttled
|
||||||
private const int THROTTLE_FREQUENCY = 8;
|
private const int THROTTLE_FREQUENCY = 8;
|
||||||
|
|
||||||
//Minimum throttle rate. The minimum amount of data that can be throttled
|
//Minimum throttle rate. The minimum amount of data that can be throttled
|
||||||
@@ -108,12 +110,18 @@ namespace AaxDecrypter
|
|||||||
#region Downloader
|
#region Downloader
|
||||||
|
|
||||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||||
private void OnUpdate()
|
private void OnUpdate(bool waitForWrite = false)
|
||||||
{
|
{
|
||||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Updated?.Invoke(this, EventArgs.Empty);
|
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
|
||||||
|
{
|
||||||
|
Updated?.Invoke(this, EventArgs.Empty);
|
||||||
|
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
|
||||||
|
//If an update is called less than 100 ms since the last update, persister will
|
||||||
|
//sleep the thread until 100 ms has elapsed.
|
||||||
|
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -135,7 +143,6 @@ namespace AaxDecrypter
|
|||||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||||
|
|
||||||
Uri = uriToSameFile;
|
Uri = uriToSameFile;
|
||||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||||
@@ -151,39 +158,103 @@ namespace AaxDecrypter
|
|||||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
//Initiate connection with the first request block and
|
||||||
|
//get the total content length before returning.
|
||||||
|
var client = new HttpClient();
|
||||||
|
var response = await RequestNextByteRangeAsync(client);
|
||||||
|
|
||||||
|
if (ContentLength != 0 && ContentLength != response.FileSize)
|
||||||
|
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
|
||||||
|
|
||||||
|
ContentLength = response.FileSize;
|
||||||
|
|
||||||
|
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||||
|
//Hand off the client and the open request to the downloader to download and write data to file.
|
||||||
|
DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long startPosition = WritePosition;
|
||||||
|
|
||||||
|
while (WritePosition < ContentLength && !IsCancelled)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DownloadToFile(blockResponse);
|
||||||
|
}
|
||||||
|
catch (HttpIOException e)
|
||||||
|
when (e.HttpRequestError is HttpRequestError.ResponseEnded
|
||||||
|
&& WritePosition != startPosition
|
||||||
|
&& WritePosition < ContentLength && !IsCancelled)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
|
||||||
|
|
||||||
|
//the download made *some* progress since the last attempt.
|
||||||
|
//Try again to complete the download from where it left off.
|
||||||
|
//Make sure to rewind file to last flush position.
|
||||||
|
_writeFile.Position = startPosition = WritePosition;
|
||||||
|
blockResponse.Dispose();
|
||||||
|
blockResponse = await RequestNextByteRangeAsync(client);
|
||||||
|
|
||||||
|
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_writeFile.Dispose();
|
||||||
|
blockResponse.Dispose();
|
||||||
|
client.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||||
|
|
||||||
|
//Just in case it snuck in the saved json (Issue #1232)
|
||||||
|
RequestHeaders.Remove("Range");
|
||||||
|
|
||||||
foreach (var header in RequestHeaders)
|
foreach (var header in RequestHeaders)
|
||||||
request.Headers.Add(header.Key, header.Value);
|
request.Headers.Add(header.Key, header.Value);
|
||||||
|
|
||||||
var response = await new HttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
request.Headers.Add("Range", $"bytes={WritePosition}-");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||||
|
|
||||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||||
|
|
||||||
//Content length is the length of the range request, and it is only equal
|
var totalSize = response.Content.Headers.ContentRange?.Length ??
|
||||||
//to the complete file length if requesting Range: bytes=0-
|
throw new WebException("The response did not contain a total content length.");
|
||||||
if (WritePosition == 0)
|
|
||||||
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
|
|
||||||
|
|
||||||
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
var rangeSize = response.Content.Headers.ContentLength ??
|
||||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
|
||||||
|
|
||||||
//Download the file in the background.
|
return new BlockResponse(response, rangeSize, totalSize);
|
||||||
|
}
|
||||||
|
|
||||||
DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
|
||||||
|
{
|
||||||
|
public void Dispose() => Response?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||||
private async Task DownloadFile(Stream networkStream)
|
private async Task DownloadToFile(BlockResponse block)
|
||||||
{
|
{
|
||||||
|
var endPosition = WritePosition + block.BlockSize;
|
||||||
|
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||||
|
|
||||||
var downloadPosition = WritePosition;
|
var downloadPosition = WritePosition;
|
||||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DateTime startTime = DateTime.Now;
|
DateTime startTime = DateTime.UtcNow;
|
||||||
long bytesReadSinceThrottle = 0;
|
long bytesReadSinceThrottle = 0;
|
||||||
int bytesRead;
|
int bytesRead;
|
||||||
do
|
do
|
||||||
@@ -208,24 +279,25 @@ namespace AaxDecrypter
|
|||||||
|
|
||||||
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
|
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
|
||||||
{
|
{
|
||||||
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
|
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
|
||||||
if (delayMS > 0)
|
if (delayMS > 0)
|
||||||
await Task.Delay(delayMS, _cancellationSource.Token);
|
await Task.Delay(delayMS, _cancellationSource.Token);
|
||||||
|
|
||||||
startTime = DateTime.Now;
|
startTime = DateTime.UtcNow;
|
||||||
bytesReadSinceThrottle = 0;
|
bytesReadSinceThrottle = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
|
||||||
|
|
||||||
|
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||||
WritePosition = downloadPosition;
|
WritePosition = downloadPosition;
|
||||||
|
|
||||||
if (!IsCancelled && WritePosition < ContentLength)
|
if (!IsCancelled && WritePosition < endPosition)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
|
|
||||||
if (WritePosition > ContentLength)
|
if (WritePosition > endPosition)
|
||||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
@@ -234,10 +306,8 @@ namespace AaxDecrypter
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
networkStream.Close();
|
|
||||||
_writeFile.Close();
|
|
||||||
_downloadedPiece.Set();
|
_downloadedPiece.Set();
|
||||||
OnUpdate();
|
OnUpdate(waitForWrite: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +404,7 @@ namespace AaxDecrypter
|
|||||||
_cancellationSource?.Dispose();
|
_cancellationSource?.Dispose();
|
||||||
_readFile.Dispose();
|
_readFile.Dispose();
|
||||||
_writeFile.Dispose();
|
_writeFile.Dispose();
|
||||||
OnUpdate();
|
OnUpdate(waitForWrite: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ namespace AaxDecrypter
|
|||||||
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override Task CancelAsync()
|
|
||||||
{
|
|
||||||
IsCanceled = true;
|
|
||||||
FinalizeDownload();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||||
{
|
{
|
||||||
await InputFileStream.DownloadTask;
|
await InputFileStream.DownloadTask;
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<Version>11.5.0.1</Version>
|
<Version>12.4.9.1</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Octokit" Version="11.0.1" />
|
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||||
<!-- Do not remove unused Serilog.Sinks -->
|
<!-- Do not remove unused Serilog.Sinks -->
|
||||||
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
|
<!-- 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.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
Migrations.migrate_to_v6_6_9(config);
|
Migrations.migrate_to_v6_6_9(config);
|
||||||
Migrations.migrate_to_v11_5_0(config);
|
Migrations.migrate_to_v11_5_0(config);
|
||||||
}
|
Migrations.migrate_to_v11_6_5(config);
|
||||||
|
Migrations.migrate_to_v12_0_1(config);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||||
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
||||||
@@ -113,11 +115,22 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
if (config.GetObject("Serilog") is JObject serilog)
|
if (config.GetObject("Serilog") is JObject serilog)
|
||||||
{
|
{
|
||||||
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink)
|
bool fileChanged = false;
|
||||||
|
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
|
||||||
{
|
{
|
||||||
fileSink["Name"] = "ZipFile";
|
zipFileSink["Name"] = "File";
|
||||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
fileChanged = true;
|
||||||
}
|
}
|
||||||
|
var hooks = $"{nameof(LibationFileManager)}.{nameof(FileSinkHook)}, {nameof(LibationFileManager)}";
|
||||||
|
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
|
||||||
|
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
|
||||||
|
{
|
||||||
|
fileSinkArgs["hooks"] = hooks;
|
||||||
|
fileChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileChanged)
|
||||||
|
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,17 +140,17 @@ namespace AppScaffolding
|
|||||||
{ "WriteTo", new JArray
|
{ "WriteTo", new JArray
|
||||||
{
|
{
|
||||||
// ABOUT SINKS
|
// ABOUT SINKS
|
||||||
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||||
|
|
||||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
{ "Name", "ZipFile" },
|
{ "Name", "File" },
|
||||||
{ "Args",
|
{ "Args",
|
||||||
new JObject
|
new JObject
|
||||||
{
|
{
|
||||||
// for this sink to work, a path must be provided. we override this below
|
// 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, "Log.log") },
|
||||||
{ "rollingInterval", "Month" },
|
{ "rollingInterval", "Month" },
|
||||||
// Serilog template formatting examples
|
// Serilog template formatting examples
|
||||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||||
@@ -272,46 +285,40 @@ namespace AppScaffolding
|
|||||||
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (InteropFactory.InteropFunctionsType is null)
|
if (InteropFactory.InteropFunctionsType is null)
|
||||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void wireUpSystemEvents(Configuration configuration)
|
private static void wireUpSystemEvents(Configuration configuration)
|
||||||
{
|
{
|
||||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
|
||||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||||
|
|
||||||
|
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
||||||
|
=> SearchEngineCommands.UpdateBooks(books);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static UpgradeProperties GetLatestRelease()
|
public static UpgradeProperties GetLatestRelease()
|
||||||
{
|
{
|
||||||
// timed out
|
// timed out
|
||||||
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
(var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
if (latest is null || zip is null)
|
if (version is null || latest is null || zip is null)
|
||||||
return null;
|
|
||||||
|
|
||||||
var latestVersionString = latest.TagName.Trim('v');
|
|
||||||
if (!Version.TryParse(latestVersionString, out var latestRelease))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// we're up to date
|
|
||||||
if (latestRelease <= BuildVersion)
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// we have an update
|
// we have an update
|
||||||
|
|
||||||
var zipUrl = zip?.BrowserDownloadUrl;
|
var zipUrl = zip?.BrowserDownloadUrl;
|
||||||
|
|
||||||
Log.Logger.Information("Update available: {@DebugInfo}", new
|
Log.Logger.Information("Update available: {@DebugInfo}", new
|
||||||
{
|
{
|
||||||
latestRelease = latestRelease.ToString(),
|
latestRelease = version.ToString(),
|
||||||
latest.HtmlUrl,
|
latest.HtmlUrl,
|
||||||
zipUrl
|
zipUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
|
return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body);
|
||||||
}
|
}
|
||||||
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -325,15 +332,23 @@ namespace AppScaffolding
|
|||||||
{
|
{
|
||||||
Log.Logger.Error(aggEx, "Checking for new version too often");
|
Log.Logger.Error(aggEx, "Checking for new version too often");
|
||||||
}
|
}
|
||||||
return (null, null);
|
return (null, null, null);
|
||||||
}
|
}
|
||||||
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
|
||||||
{
|
{
|
||||||
const string ownerAccount = "rmcrackan";
|
const string ownerAccount = "rmcrackan";
|
||||||
const string repoName = "Libation";
|
const string repoName = "Libation";
|
||||||
|
|
||||||
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
|
||||||
|
|
||||||
|
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
||||||
|
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
||||||
|
|
||||||
|
//Ensure that latest release is greater than the current version
|
||||||
|
var latestVersionString = latestRelease.TagName.Trim('v');
|
||||||
|
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
|
||||||
|
return (null, null, null);
|
||||||
|
|
||||||
//Download the release index
|
//Download the release index
|
||||||
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
|
||||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||||
@@ -351,10 +366,7 @@ namespace AppScaffolding
|
|||||||
|
|
||||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
|
return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
||||||
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
|
|
||||||
|
|
||||||
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +425,91 @@ namespace AppScaffolding
|
|||||||
public List<string> Filters { get; set; } = new();
|
public List<string> Filters { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void migrate_to_v11_5_0(Configuration config)
|
public static void migrate_to_v12_0_1(Configuration config)
|
||||||
|
{
|
||||||
|
#nullable enable
|
||||||
|
//Migrate from version 1 file cache to the dictionary-based version 2 cache
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//FilePathCache loads the cache in its static constructor,
|
||||||
|
//so perform migration without using FilePathCache.CacheEntry
|
||||||
|
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Dictionary<string, JArray> cache = new();
|
||||||
|
|
||||||
|
//Convert to c# objects to speed up searching by ID inside the iterator
|
||||||
|
var allItems
|
||||||
|
= v1Cache
|
||||||
|
.Select(i => new
|
||||||
|
{
|
||||||
|
Id = i["Id"]?.Value<string>(),
|
||||||
|
Path = i["Path"]?["Path"]?.Value<string>()
|
||||||
|
}).Where(i => i.Id != null)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var id in allItems.Select(i => i.Id).OfType<string>().Distinct())
|
||||||
|
{
|
||||||
|
//Use this opportunity to purge non-existent files and re-classify file types
|
||||||
|
//(due to *.aax files previously not being classified as FileType.AAXC)
|
||||||
|
var items = allItems
|
||||||
|
.Where(i => i.Id == id && File.Exists(i.Path))
|
||||||
|
.Select(i => new JObject
|
||||||
|
{
|
||||||
|
{ "Id", i.Id },
|
||||||
|
{ "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) },
|
||||||
|
{ "Path", new JObject{ { "Path", i.Path } } }
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (items.Length == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
cache[id] = new JArray(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } };
|
||||||
|
var cacheFileText = cacheJson.ToString(Formatting.Indented);
|
||||||
|
|
||||||
|
void migrate()
|
||||||
|
{
|
||||||
|
File.WriteAllText(jsonFileV2, cacheFileText);
|
||||||
|
File.Delete(jsonFileV1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try { migrate(); }
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
try { migrate(); }
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
migrate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* eat */ }
|
||||||
|
}
|
||||||
|
#nullable restore
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void migrate_to_v11_6_5(Configuration config)
|
||||||
|
{
|
||||||
|
//Settings migration for unsupported sample rates (#1116)
|
||||||
|
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
|
||||||
|
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
|
||||||
|
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
|
||||||
|
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void migrate_to_v11_5_0(Configuration config)
|
||||||
{
|
{
|
||||||
// Read file, but convert old format to new (with Name field) as necessary.
|
// Read file, but convert old format to new (with Name field) as necessary.
|
||||||
if (!File.Exists(QuickFilters.JsonFile))
|
if (!File.Exists(QuickFilters.JsonFile))
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||||
<PackageReference Include="NPOI" Version="2.7.0" />
|
<PackageReference Include="NPOI" Version="2.7.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ using Newtonsoft.Json.Linq;
|
|||||||
using Serilog;
|
using Serilog;
|
||||||
using static DtoImporterService.PerfLogger;
|
using static DtoImporterService.PerfLogger;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace ApplicationServices
|
namespace ApplicationServices
|
||||||
{
|
{
|
||||||
public static class LibraryCommands
|
public static class LibraryCommands
|
||||||
{
|
{
|
||||||
public static event EventHandler<int> ScanBegin;
|
public static event EventHandler<int>? ScanBegin;
|
||||||
public static event EventHandler<int> ScanEnd;
|
public static event EventHandler<int>? ScanEnd;
|
||||||
|
|
||||||
public static bool Scanning { get; private set; }
|
public static bool Scanning { get; private set; }
|
||||||
private static object _lock { get; } = new();
|
private static object _lock { get; } = new();
|
||||||
@@ -31,7 +32,7 @@ namespace ApplicationServices
|
|||||||
ScanEnd += (_, __) => Scanning = false;
|
ScanEnd += (_, __) => Scanning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
|
||||||
{
|
{
|
||||||
logRestart();
|
logRestart();
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ namespace ApplicationServices
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||||
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = libraryItems.Count;
|
var totalCount = libraryItems.Count;
|
||||||
@@ -100,7 +101,7 @@ namespace ApplicationServices
|
|||||||
}
|
}
|
||||||
|
|
||||||
#region FULL LIBRARY scan and import
|
#region FULL LIBRARY scan and import
|
||||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
|
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||||
{
|
{
|
||||||
logRestart();
|
logRestart();
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ namespace ApplicationServices
|
|||||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||||
};
|
};
|
||||||
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
|
var importItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||||
|
|
||||||
var totalCount = importItems.Count;
|
var totalCount = importItems.Count;
|
||||||
@@ -222,7 +223,7 @@ namespace ApplicationServices
|
|||||||
{
|
{
|
||||||
int qtyChanged = await Task.Run(() => SaveContext(context));
|
int qtyChanged = await Task.Run(() => SaveContext(context));
|
||||||
if (qtyChanged > 0)
|
if (qtyChanged > 0)
|
||||||
await Task.Run(finalizeLibrarySizeChange);
|
await Task.Run(() => finalizeLibrarySizeChange(context));
|
||||||
return qtyChanged;
|
return qtyChanged;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -232,13 +233,42 @@ namespace ApplicationServices
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
private static LogArchiver? openLogArchive(string? archivePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(archivePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new LogArchiver(archivePath);
|
||||||
|
}
|
||||||
|
catch (System.IO.InvalidDataException)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Log.Logger.Warning($"Deleting corrupted {nameof(LogArchiver)} at {archivePath}");
|
||||||
|
FileUtility.SaferDelete(archivePath);
|
||||||
|
return new LogArchiver(archivePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
|
||||||
{
|
{
|
||||||
var tasks = new List<Task<List<ImportItem>>>();
|
var tasks = new List<Task<List<ImportItem>>>();
|
||||||
|
|
||||||
await using LogArchiver archiver
|
await using LogArchiver? archiver
|
||||||
= Log.Logger.IsDebugEnabled()
|
= Log.Logger.IsDebugEnabled()
|
||||||
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
|
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
|
||||||
: default;
|
: default;
|
||||||
|
|
||||||
archiver?.DeleteAllButNewestN(20);
|
archiver?.DeleteAllButNewestN(20);
|
||||||
@@ -248,7 +278,7 @@ namespace ApplicationServices
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||||
var apiExtended = await apiExtendedfunc(account);
|
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||||
|
|
||||||
// add scanAccountAsync as a TASK: do not await
|
// add scanAccountAsync as a TASK: do not await
|
||||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||||
@@ -266,13 +296,13 @@ namespace ApplicationServices
|
|||||||
return importItems;
|
return importItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver)
|
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||||
|
|
||||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||||
{
|
{
|
||||||
Account = account?.MaskedLogEntry ?? "[null]"
|
Account = account.MaskedLogEntry ?? "[null]"
|
||||||
});
|
});
|
||||||
|
|
||||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||||
@@ -294,7 +324,7 @@ namespace ApplicationServices
|
|||||||
throw new AggregateException(ex.InnerExceptions);
|
throw new AggregateException(ex.InnerExceptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception> exceptions = null)
|
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception>? exceptions = null)
|
||||||
{
|
{
|
||||||
if (archiver is not null)
|
if (archiver is not null)
|
||||||
{
|
{
|
||||||
@@ -329,7 +359,7 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
// this is any changes at all to the database, not just new books
|
// this is any changes at all to the database, not just new books
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
await Task.Run(() => finalizeLibrarySizeChange());
|
await Task.Run(() => finalizeLibrarySizeChange(context));
|
||||||
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
|
||||||
|
|
||||||
return newCount;
|
return newCount;
|
||||||
@@ -369,16 +399,16 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
// Entry() NoTracking entities before SaveChanges()
|
||||||
foreach (var lb in removeLibraryBooks)
|
foreach (var lb in removeLibraryBooks)
|
||||||
{
|
{
|
||||||
lb.IsDeleted = true;
|
lb.IsDeleted = true;
|
||||||
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
var qtyChanges = context.SaveChanges();
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
finalizeLibrarySizeChange();
|
finalizeLibrarySizeChange(context);
|
||||||
|
|
||||||
return qtyChanges;
|
return qtyChanges;
|
||||||
}
|
}
|
||||||
@@ -398,16 +428,16 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
// Entry() NoTracking entities before SaveChanges()
|
||||||
foreach (var lb in libraryBooks)
|
foreach (var lb in libraryBooks)
|
||||||
{
|
{
|
||||||
lb.IsDeleted = false;
|
lb.IsDeleted = false;
|
||||||
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
var qtyChanges = context.SaveChanges();
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
finalizeLibrarySizeChange();
|
finalizeLibrarySizeChange(context);
|
||||||
|
|
||||||
return qtyChanges;
|
return qtyChanges;
|
||||||
}
|
}
|
||||||
@@ -432,7 +462,7 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
var qtyChanges = context.SaveChanges();
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
finalizeLibrarySizeChange();
|
finalizeLibrarySizeChange(context);
|
||||||
|
|
||||||
return qtyChanges;
|
return qtyChanges;
|
||||||
}
|
}
|
||||||
@@ -445,31 +475,35 @@ namespace ApplicationServices
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
// call this whenever books are added or removed from library
|
// call this whenever books are added or removed from library
|
||||||
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
|
private static void finalizeLibrarySizeChange(LibationContext context)
|
||||||
|
{
|
||||||
|
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
|
||||||
|
LibrarySizeChanged?.Invoke(null, library);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
||||||
public static event EventHandler LibrarySizeChanged;
|
public static event EventHandler<List<LibraryBook>>? LibrarySizeChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
|
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
|
||||||
|
|
||||||
#region Update book details
|
#region Update book details
|
||||||
public static int UpdateUserDefinedItem(
|
public static int UpdateUserDefinedItem(
|
||||||
this LibraryBook lb,
|
this LibraryBook lb,
|
||||||
string tags = null,
|
string? tags = null,
|
||||||
LiberatedStatus? bookStatus = null,
|
LiberatedStatus? bookStatus = null,
|
||||||
LiberatedStatus? pdfStatus = null,
|
LiberatedStatus? pdfStatus = null,
|
||||||
Rating rating = null)
|
Rating? rating = null)
|
||||||
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||||
|
|
||||||
public static int UpdateUserDefinedItem(
|
public static int UpdateUserDefinedItem(
|
||||||
this IEnumerable<LibraryBook> lb,
|
this IEnumerable<LibraryBook> lb,
|
||||||
string tags = null,
|
string? tags = null,
|
||||||
LiberatedStatus? bookStatus = null,
|
LiberatedStatus? bookStatus = null,
|
||||||
LiberatedStatus? pdfStatus = null,
|
LiberatedStatus? pdfStatus = null,
|
||||||
Rating rating = null)
|
Rating? rating = null)
|
||||||
=> updateUserDefinedItem(
|
=> updateUserDefinedItem(
|
||||||
lb,
|
lb,
|
||||||
udi => {
|
udi => {
|
||||||
@@ -518,17 +552,19 @@ namespace ApplicationServices
|
|||||||
if (libraryBooks is null || !libraryBooks.Any())
|
if (libraryBooks is null || !libraryBooks.Any())
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
foreach (var book in libraryBooks)
|
|
||||||
action?.Invoke(book.Book.UserDefinedItem);
|
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
using var context = DbContexts.GetContext();
|
||||||
|
|
||||||
// Attach() NoTracking entities before SaveChanges()
|
// Entry() instead of Attach() due to possible stack overflow with large tables
|
||||||
foreach (var book in libraryBooks)
|
foreach (var book in libraryBooks)
|
||||||
{
|
{
|
||||||
context.Attach(book.Book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
action?.Invoke(book.Book.UserDefinedItem);
|
||||||
context.Attach(book.Book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
|
||||||
}
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
var qtyChanges = context.SaveChanges();
|
var qtyChanges = context.SaveChanges();
|
||||||
if (qtyChanges > 0)
|
if (qtyChanges > 0)
|
||||||
@@ -593,13 +629,15 @@ namespace ApplicationServices
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
|
|
||||||
|
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
|
||||||
{
|
{
|
||||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
|
|
||||||
var results = libraryBooks
|
var results = libraryBooks
|
||||||
.AsParallel()
|
.AsParallel()
|
||||||
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
|
.WithoutParents()
|
||||||
|
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
|
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
|
||||||
|
|||||||
@@ -104,9 +104,6 @@ namespace ApplicationServices
|
|||||||
[Name("Content Type")]
|
[Name("Content Type")]
|
||||||
public string ContentType { get; set; }
|
public string ContentType { get; set; }
|
||||||
|
|
||||||
[Name("Audio Format")]
|
|
||||||
public string AudioFormat { get; set; }
|
|
||||||
|
|
||||||
[Name("Language")]
|
[Name("Language")]
|
||||||
public string Language { get; set; }
|
public string Language { get; set; }
|
||||||
|
|
||||||
@@ -144,7 +141,7 @@ namespace ApplicationServices
|
|||||||
PictureId = a.Book.PictureId,
|
PictureId = a.Book.PictureId,
|
||||||
IsAbridged = a.Book.IsAbridged,
|
IsAbridged = a.Book.IsAbridged,
|
||||||
DatePublished = a.Book.DatePublished,
|
DatePublished = a.Book.DatePublished,
|
||||||
CategoriesNames = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "",
|
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
|
||||||
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
|
||||||
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
|
||||||
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
|
||||||
@@ -152,7 +149,6 @@ namespace ApplicationServices
|
|||||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||||
ContentType = a.Book.ContentType.ToString(),
|
ContentType = a.Book.ContentType.ToString(),
|
||||||
AudioFormat = a.Book.AudioFormat.ToString(),
|
|
||||||
Language = a.Book.Language,
|
Language = a.Book.Language,
|
||||||
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||||
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||||
@@ -228,7 +224,6 @@ namespace ApplicationServices
|
|||||||
nameof(ExportDto.BookStatus),
|
nameof(ExportDto.BookStatus),
|
||||||
nameof(ExportDto.PdfStatus),
|
nameof(ExportDto.PdfStatus),
|
||||||
nameof(ExportDto.ContentType),
|
nameof(ExportDto.ContentType),
|
||||||
nameof(ExportDto.AudioFormat),
|
|
||||||
nameof(ExportDto.Language),
|
nameof(ExportDto.Language),
|
||||||
nameof(ExportDto.LastDownloaded),
|
nameof(ExportDto.LastDownloaded),
|
||||||
nameof(ExportDto.LastDownloadedVersion),
|
nameof(ExportDto.LastDownloadedVersion),
|
||||||
@@ -299,7 +294,6 @@ namespace ApplicationServices
|
|||||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
|
||||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||||
|
|
||||||
if (dto.LastDownloaded.HasValue)
|
if (dto.LastDownloaded.HasValue)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ namespace ApplicationServices
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||||
|
public static void FullReIndex(List<LibraryBook> libraryBooks)
|
||||||
|
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
|
||||||
|
|
||||||
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
||||||
{
|
{
|
||||||
@@ -94,8 +96,11 @@ namespace ApplicationServices
|
|||||||
private static void fullReIndex(SearchEngine engine)
|
private static void fullReIndex(SearchEngine engine)
|
||||||
{
|
{
|
||||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||||
engine.CreateNewIndex(library);
|
fullReIndex(engine, library);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
|
||||||
|
=> engine.CreateNewIndex(libraryBooks);
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,22 @@ namespace AudibleUtilities
|
|||||||
update_no_validate();
|
update_no_validate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string _cdm;
|
||||||
|
[JsonProperty]
|
||||||
|
public string Cdm
|
||||||
|
{
|
||||||
|
get => _cdm;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_cdm = value;
|
||||||
|
update_no_validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ using Polly;
|
|||||||
using Polly.Retry;
|
using Polly.Retry;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AudibleUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
|
||||||
public class ApiExtended
|
public class ApiExtended
|
||||||
{
|
{
|
||||||
|
public static Func<Account, ILoginChoiceEager>? LoginChoiceFactory { get; set; }
|
||||||
public Api Api { get; private set; }
|
public Api Api { get; private set; }
|
||||||
|
|
||||||
private const int MaxConcurrency = 10;
|
private const int MaxConcurrency = 10;
|
||||||
@@ -24,52 +26,46 @@ namespace AudibleUtilities
|
|||||||
private ApiExtended(Api api) => Api = api;
|
private ApiExtended(Api api) => Api = api;
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
|
|
||||||
{
|
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
|
||||||
{
|
|
||||||
LoginType = nameof(ILoginChoiceEager),
|
|
||||||
Account = account?.MaskedLogEntry ?? "[null]",
|
|
||||||
LocaleName = account?.Locale?.Name
|
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
|
||||||
loginChoiceEager,
|
|
||||||
account.Locale,
|
|
||||||
AudibleApiStorage.AccountsSettingsFile,
|
|
||||||
account.GetIdentityTokensJsonPath());
|
|
||||||
return new ApiExtended(api);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
|
||||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||||
|
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||||
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||||
|
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
try
|
||||||
{
|
{
|
||||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||||
});
|
{
|
||||||
|
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||||
|
});
|
||||||
|
|
||||||
return await CreateAsync(account.AccountId, account.Locale.Name);
|
var api = await EzApiCreator.GetApiAsync(
|
||||||
}
|
account.Locale,
|
||||||
|
AudibleApiStorage.AccountsSettingsFile,
|
||||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
account.GetIdentityTokensJsonPath());
|
||||||
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
|
return new ApiExtended(api);
|
||||||
{
|
}
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
catch
|
||||||
{
|
{
|
||||||
Username = username.ToMask(),
|
if (LoginChoiceFactory is null)
|
||||||
LocaleName = localeName,
|
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||||
Localization.Get(localeName),
|
{
|
||||||
|
LoginType = nameof(ILoginChoiceEager),
|
||||||
|
Account = account.MaskedLogEntry ?? "[null]",
|
||||||
|
LocaleName = account.Locale?.Name
|
||||||
|
});
|
||||||
|
|
||||||
|
var api = await EzApiCreator.GetApiAsync(
|
||||||
|
LoginChoiceFactory(account),
|
||||||
|
account.Locale,
|
||||||
AudibleApiStorage.AccountsSettingsFile,
|
AudibleApiStorage.AccountsSettingsFile,
|
||||||
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
|
account.GetIdentityTokensJsonPath());
|
||||||
return new ApiExtended(api);
|
|
||||||
}
|
return new ApiExtended(api);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static AsyncRetryPolicy policy { get; }
|
private static AsyncRetryPolicy policy { get; }
|
||||||
= Policy.Handle<Exception>()
|
= Policy.Handle<Exception>()
|
||||||
|
|||||||
@@ -5,19 +5,58 @@ using Newtonsoft.Json;
|
|||||||
|
|
||||||
namespace AudibleUtilities
|
namespace AudibleUtilities
|
||||||
{
|
{
|
||||||
|
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Create a new, empty <see cref="AccountsSettings"/> file if true, otherwise throw
|
||||||
|
/// </summary>
|
||||||
|
public bool Handled { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// The file path of the AccountsSettings.json file
|
||||||
|
/// </summary>
|
||||||
|
public string SettingsFilePath { get; }
|
||||||
|
|
||||||
|
public AccountSettingsLoadErrorEventArgs(string path, Exception exception)
|
||||||
|
: base(exception)
|
||||||
|
{
|
||||||
|
SettingsFilePath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static class AudibleApiStorage
|
public static class AudibleApiStorage
|
||||||
{
|
{
|
||||||
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
|
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
|
||||||
|
|
||||||
|
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
|
||||||
|
|
||||||
public static void EnsureAccountsSettingsFileExists()
|
public static void EnsureAccountsSettingsFileExists()
|
||||||
{
|
{
|
||||||
// saves. BEWARE: this will overwrite an existing file
|
// saves. BEWARE: this will overwrite an existing file
|
||||||
if (!File.Exists(AccountsSettingsFile))
|
if (!File.Exists(AccountsSettingsFile))
|
||||||
_ = new AccountsSettingsPersister(new AccountsSettings(), AccountsSettingsFile);
|
{
|
||||||
|
//Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved
|
||||||
|
//are not fired. There's no need to fire those events on an empty AccountsSettings file.
|
||||||
|
var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings();
|
||||||
|
File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
|
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
|
||||||
public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile);
|
public static AccountsSettingsPersister GetAccountsSettingsPersister()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new AccountsSettingsPersister(AccountsSettingsFile);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex);
|
||||||
|
LoadError?.Invoke(null, args);
|
||||||
|
if (args.Handled)
|
||||||
|
return GetAccountsSettingsPersister();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static string GetIdentityTokensJsonPath(this Account account)
|
public static string GetIdentityTokensJsonPath(this Account account)
|
||||||
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AudibleApi" Version="9.2.0.1" />
|
<PackageReference Include="AudibleApi" Version="9.4.1.1" />
|
||||||
|
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -20,4 +21,9 @@
|
|||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="Widevine\Cdm.*.cs">
|
||||||
|
<DependentUpon>Cdm.cs</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using AudibleApi;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Net.Http;
|
||||||
|
using AudibleApi.Cryptography;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Dinah.Core.Net.Http;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
public partial class Cdm
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get a <see cref="Cdm"/> from <see cref="AccountsSettings"/> or from the API.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Cdm"/> if successful, otherwise <see cref="null"/></returns>
|
||||||
|
public static async Task<Cdm?> GetCdmAsync()
|
||||||
|
{
|
||||||
|
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||||
|
|
||||||
|
//Check if there are any Android accounts. If not, we can't use Widevine.
|
||||||
|
if (!persister.Target.Accounts.Any(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(persister.Target.Cdm))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cdm = Convert.FromBase64String(persister.Target.Cdm);
|
||||||
|
return new Cdm(new Device(cdm));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error loading CDM from account settings.");
|
||||||
|
persister.Target.Cdm = string.Empty;
|
||||||
|
//Clear the stored Cdm and try getting a fresh one from the server.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(persister.Target.Cdm))
|
||||||
|
{
|
||||||
|
using var client = new HttpClient();
|
||||||
|
if (await GetCdmUris(client) is not Uri[] uris)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
//try to get a CDM file for any account that's registered as an android device.
|
||||||
|
//CDMs are not account-specific, so it doesn't matter which account we're successful with.
|
||||||
|
foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestMessage = CreateApiRequest(account);
|
||||||
|
|
||||||
|
await TestApiRequest(client, new JsonObject { { "body", requestMessage.ToString() } });
|
||||||
|
|
||||||
|
//Try all CDM URIs until a CDM has been retrieved successfully
|
||||||
|
foreach (var uri in uris)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await client.PostAsync(uri, ((HttpBody)requestMessage).Content);
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var message = await resp.Content.ReadAsStringAsync();
|
||||||
|
throw new ApiErrorException(uri, null, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
var cdmBts = await resp.Content.ReadAsByteArrayAsync();
|
||||||
|
var device = new Device(cdmBts);
|
||||||
|
persister.Target.Cdm = Convert.ToBase64String(cdmBts);
|
||||||
|
return new Cdm(device);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error getting a CDM from URI: " + uri);
|
||||||
|
//try the next URI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error getting a CDM for account: " + account.MaskedLogEntry);
|
||||||
|
//try the next Account
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get a list of CDM API URIs from the main Gitgub repository's .cdmurls.json file.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>If successful, an array of URIs to try. Otherwise null</returns>
|
||||||
|
private static async Task<Uri[]?> GetCdmUris(HttpClient httpClient)
|
||||||
|
{
|
||||||
|
const string CdmUrlListFile = "https://raw.githubusercontent.com/rmcrackan/Libation/refs/heads/master/.cdmurls.json";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var fileContents = await httpClient.GetStringAsync(CdmUrlListFile);
|
||||||
|
var releaseIndex = JObject.Parse(fileContents);
|
||||||
|
var urlArray = releaseIndex["CdmUrls"] as JArray;
|
||||||
|
if (urlArray is null)
|
||||||
|
throw new System.IO.InvalidDataException("CDM url list not found in JSON: " + fileContents);
|
||||||
|
|
||||||
|
var uris = urlArray.Select(u => u.Value<string>()).OfType<string>().Select(u => new Uri(u)).ToArray();
|
||||||
|
|
||||||
|
if (uris.Length == 0)
|
||||||
|
throw new System.IO.InvalidDataException("No CDM url found in JSON: " + fileContents);
|
||||||
|
|
||||||
|
return uris;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Error getting CDM URLs");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static readonly string[] TLDs = ["com", "co.uk", "com.au", "com.br", "ca", "fr", "de", "in", "it", "co.jp", "es"];
|
||||||
|
|
||||||
|
//Ensure that the request can be made successfully before sending it to the API
|
||||||
|
//The API uses System.Text.Json, so perform test with same.
|
||||||
|
private static async Task TestApiRequest(HttpClient client, JsonObject input)
|
||||||
|
{
|
||||||
|
if (input["body"]?.GetValue<string>() is not string body
|
||||||
|
|| JsonNode.Parse(body) is not JsonNode bodyJson)
|
||||||
|
throw new Exception("Api request doesn't contain a body");
|
||||||
|
|
||||||
|
if (bodyJson?["Url"]?.GetValue<string>() is not string url
|
||||||
|
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
|
throw new Exception("Api request doesn't contain a url");
|
||||||
|
|
||||||
|
if (!TLDs.Select(tld => "api.audible." + tld).Contains(uri.Host.ToLower()))
|
||||||
|
throw new Exception($"Unknown Audible Api domain: {uri.Host}");
|
||||||
|
|
||||||
|
if (bodyJson?["Headers"] is not JsonObject headers)
|
||||||
|
throw new Exception($"Api request doesn't contain any headers");
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
|
||||||
|
Dictionary<string, string>? headersDict = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
headersDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(headers);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
throw new Exception("Failed to read Audible Api headers.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headersDict is null)
|
||||||
|
throw new Exception("Failed to read Audible Api headers.");
|
||||||
|
|
||||||
|
foreach (var kvp in headersDict)
|
||||||
|
request.Headers.Add(kvp.Key, kvp.Value);
|
||||||
|
|
||||||
|
using var resp = await client.SendAsync(request);
|
||||||
|
resp.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a request body to send to the API
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">An authenticated account</param>
|
||||||
|
private static JObject CreateApiRequest(Account account)
|
||||||
|
{
|
||||||
|
const string ACCOUNT_INFO_PATH = "/1.0/account/information";
|
||||||
|
|
||||||
|
var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH);
|
||||||
|
message.SignRequest(
|
||||||
|
DateTime.UtcNow,
|
||||||
|
account.IdentityTokens.AdpToken,
|
||||||
|
account.IdentityTokens.PrivateKey);
|
||||||
|
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
{ "Url", new Uri(account.Locale.AudibleApiUri(), ACCOUNT_INFO_PATH) },
|
||||||
|
{ "Headers", JObject.FromObject(message.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Single())) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
using Google.Protobuf;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
public enum KeyType
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exactly one key of this type must appear.
|
||||||
|
/// </summary>
|
||||||
|
Signing = 1,
|
||||||
|
/// <summary>
|
||||||
|
/// Content key.
|
||||||
|
/// </summary>
|
||||||
|
Content = 2,
|
||||||
|
/// <summary>
|
||||||
|
/// Key control block for license renewals. No key.
|
||||||
|
/// </summary>
|
||||||
|
KeyControl = 3,
|
||||||
|
/// <summary>
|
||||||
|
/// wrapped keys for auxiliary crypto operations.
|
||||||
|
/// </summary>
|
||||||
|
OperatorSession = 4,
|
||||||
|
/// <summary>
|
||||||
|
/// Entitlement keys.
|
||||||
|
/// </summary>
|
||||||
|
Entitlement = 5,
|
||||||
|
/// <summary>
|
||||||
|
/// Partner-specific content key.
|
||||||
|
/// </summary>
|
||||||
|
OemContent = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ISession : IDisposable
|
||||||
|
{
|
||||||
|
string? GetLicenseChallenge(MpegDash dash);
|
||||||
|
WidevineKey[] ParseLicense(string licenseMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WidevineKey
|
||||||
|
{
|
||||||
|
public Guid Kid { get; }
|
||||||
|
public KeyType Type { get; }
|
||||||
|
public byte[] Key { get; }
|
||||||
|
internal WidevineKey(Guid kid, License.Types.KeyContainer.Types.KeyType type, byte[] key)
|
||||||
|
{
|
||||||
|
Kid = kid;
|
||||||
|
Type = (KeyType)type;
|
||||||
|
Key = key;
|
||||||
|
}
|
||||||
|
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Cdm
|
||||||
|
{
|
||||||
|
public static Guid WidevineContentProtection { get; } = new("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
|
||||||
|
private const int MAX_NUM_OF_SESSIONS = 16;
|
||||||
|
internal Device Device { get; }
|
||||||
|
|
||||||
|
private ConcurrentDictionary<Guid, Session> Sessions { get; } = new(-1, MAX_NUM_OF_SESSIONS);
|
||||||
|
|
||||||
|
internal Cdm(Device device)
|
||||||
|
{
|
||||||
|
Device = device;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ISession OpenSession()
|
||||||
|
{
|
||||||
|
if (Sessions.Count == MAX_NUM_OF_SESSIONS)
|
||||||
|
throw new Exception("Too Many Sessions");
|
||||||
|
|
||||||
|
var session = new Session(Sessions.Count + 1, this);
|
||||||
|
|
||||||
|
var ddd = Sessions.TryAdd(session.Id, session);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Session
|
||||||
|
|
||||||
|
internal class Session : ISession
|
||||||
|
{
|
||||||
|
public Guid Id { get; } = Guid.NewGuid();
|
||||||
|
private int SessionNumber { get; }
|
||||||
|
private Cdm Cdm { get; }
|
||||||
|
private byte[]? EncryptionContext { get; set; }
|
||||||
|
private byte[]? AuthenticationContext { get; set; }
|
||||||
|
|
||||||
|
public Session(int number, Cdm cdm)
|
||||||
|
{
|
||||||
|
SessionNumber = number;
|
||||||
|
Cdm = cdm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetRequestId()
|
||||||
|
=> $"{RandomUint():x8}00000000{Convert.ToHexString(BitConverter.GetBytes((long)SessionNumber)).ToLowerInvariant()}";
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Cdm.Sessions.ContainsKey(Id))
|
||||||
|
Cdm.Sessions.TryRemove(Id, out var session);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetLicenseChallenge(MpegDash dash)
|
||||||
|
{
|
||||||
|
if (!dash.TryGetPssh(Cdm.WidevineContentProtection, out var pssh))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var licRequest = new LicenseRequest
|
||||||
|
{
|
||||||
|
ClientId = Cdm.Device.ClientId,
|
||||||
|
ContentId = new()
|
||||||
|
{
|
||||||
|
WidevinePsshData = new()
|
||||||
|
{
|
||||||
|
LicenseType = LicenseType.Offline,
|
||||||
|
RequestId = ByteString.CopyFrom(GetRequestId(), Encoding.ASCII)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Type = LicenseRequest.Types.RequestType.New,
|
||||||
|
RequestTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
|
ProtocolVersion = ProtocolVersion.Version21,
|
||||||
|
KeyControlNonce = RandomUint()
|
||||||
|
};
|
||||||
|
|
||||||
|
licRequest.ContentId.WidevinePsshData.PsshData.Add(ByteString.CopyFrom(pssh.InitData));
|
||||||
|
|
||||||
|
var licRequestBts = licRequest.ToByteArray();
|
||||||
|
EncryptionContext = CreateContext("ENCRYPTION", 128, licRequestBts);
|
||||||
|
AuthenticationContext = CreateContext("AUTHENTICATION", 512, licRequestBts);
|
||||||
|
|
||||||
|
var signedMessage = new SignedMessage
|
||||||
|
{
|
||||||
|
Type = SignedMessage.Types.MessageType.LicenseRequest,
|
||||||
|
Msg = ByteString.CopyFrom(licRequestBts),
|
||||||
|
Signature = ByteString.CopyFrom(Cdm.Device.SignMessage(licRequestBts))
|
||||||
|
};
|
||||||
|
|
||||||
|
return Convert.ToBase64String(signedMessage.ToByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public WidevineKey[] ParseLicense(string licenseMessage)
|
||||||
|
{
|
||||||
|
if (EncryptionContext is null || AuthenticationContext is null)
|
||||||
|
throw new InvalidOperationException($"{nameof(GetLicenseChallenge)}() must be called before calling {nameof(ParseLicense)}()");
|
||||||
|
|
||||||
|
var signedMessage = SignedMessage.Parser.ParseFrom(Convert.FromBase64String(licenseMessage));
|
||||||
|
if (signedMessage.Type != SignedMessage.Types.MessageType.License)
|
||||||
|
throw new InvalidDataException("Invalid license");
|
||||||
|
|
||||||
|
var sessionKey = Cdm.Device.DecryptSessionKey(signedMessage.SessionKey.ToByteArray());
|
||||||
|
|
||||||
|
if (!VerifySignature(signedMessage, AuthenticationContext, sessionKey))
|
||||||
|
throw new InvalidDataException("Message signature is invalid");
|
||||||
|
|
||||||
|
var license = License.Parser.ParseFrom(signedMessage.Msg);
|
||||||
|
var keyToTheKeys = DeriveKey(sessionKey, EncryptionContext, 1);
|
||||||
|
|
||||||
|
return DecryptKeys(keyToTheKeys, license.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WidevineKey[] DecryptKeys(byte[] keyToTheKeys, IList<License.Types.KeyContainer> licenseKeys)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = keyToTheKeys;
|
||||||
|
var keys = new WidevineKey[licenseKeys.Count];
|
||||||
|
|
||||||
|
for (int i = 0; i < licenseKeys.Count; i++)
|
||||||
|
{
|
||||||
|
var keyContainer = licenseKeys[i];
|
||||||
|
|
||||||
|
var keyBytes = aes.DecryptCbc(keyContainer.Key.ToByteArray(), keyContainer.Iv.ToByteArray(), PaddingMode.PKCS7);
|
||||||
|
var id = keyContainer.Id.ToByteArray();
|
||||||
|
|
||||||
|
if (id.Length > 16)
|
||||||
|
{
|
||||||
|
var tryB64 = new byte[id.Length * 3 / 4];
|
||||||
|
if (Convert.TryFromBase64String(Encoding.ASCII.GetString(id), tryB64, out int bytesWritten))
|
||||||
|
{
|
||||||
|
id = tryB64;
|
||||||
|
}
|
||||||
|
Array.Resize(ref id, 16);
|
||||||
|
}
|
||||||
|
else if (id.Length < 16)
|
||||||
|
{
|
||||||
|
id = id.Append(new byte[16 - id.Length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
keys[i] = new WidevineKey(new Guid(id,bigEndian: true), keyContainer.Type, keyBytes);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool VerifySignature(SignedMessage signedMessage, byte[] authContext, byte[] sessionKey)
|
||||||
|
{
|
||||||
|
var mac_key_server = DeriveKey(sessionKey, authContext, 1).Append(DeriveKey(sessionKey, authContext, 2));
|
||||||
|
|
||||||
|
var hmacData = (signedMessage.OemcryptoCoreMessage?.ToByteArray() ?? []).Append(signedMessage.Msg?.ToByteArray() ?? []);
|
||||||
|
|
||||||
|
var computed_signature = HMACSHA256.HashData(mac_key_server, hmacData);
|
||||||
|
|
||||||
|
return computed_signature.SequenceEqual(signedMessage.Signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] DeriveKey(byte[] session_key, byte[] context, int counter)
|
||||||
|
{
|
||||||
|
var data = new byte[context.Length + 1];
|
||||||
|
Array.Copy(context, 0, data, 1, context.Length);
|
||||||
|
data[0] = (byte)counter;
|
||||||
|
|
||||||
|
return AESCMAC(session_key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] AESCMAC(byte[] key, byte[] data)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = key;
|
||||||
|
|
||||||
|
// SubKey generation
|
||||||
|
// step 1, AES-128 with key K is applied to an all-zero input block.
|
||||||
|
byte[] subKey = aes.EncryptCbc(new byte[16], new byte[16], PaddingMode.None);
|
||||||
|
|
||||||
|
nextSubKey();
|
||||||
|
|
||||||
|
// MAC computing
|
||||||
|
if ((data.Length == 0) || (data.Length % 16 != 0))
|
||||||
|
{
|
||||||
|
// If the size of the input message block is not equal to a positive
|
||||||
|
// multiple of the block size (namely, 128 bits), the last block shall
|
||||||
|
// be padded with 10^i
|
||||||
|
nextSubKey();
|
||||||
|
var padLen = 16 - data.Length % 16;
|
||||||
|
Array.Resize(ref data, data.Length + padLen);
|
||||||
|
data[^padLen] = 0x80;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the last block shall be exclusive-OR'ed with K1 before processing
|
||||||
|
for (int j = 0; j < subKey.Length; j++)
|
||||||
|
data[data.Length - 16 + j] ^= subKey[j];
|
||||||
|
|
||||||
|
// The result of the previous process will be the input of the last encryption.
|
||||||
|
byte[] encResult = aes.EncryptCbc(data, new byte[16], PaddingMode.None);
|
||||||
|
|
||||||
|
byte[] HashValue = new byte[16];
|
||||||
|
Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length);
|
||||||
|
|
||||||
|
return HashValue;
|
||||||
|
|
||||||
|
void nextSubKey()
|
||||||
|
{
|
||||||
|
const byte const_Rb = 0x87;
|
||||||
|
if (Rol(subKey) != 0)
|
||||||
|
subKey[15] ^= const_Rb;
|
||||||
|
|
||||||
|
static int Rol(byte[] b)
|
||||||
|
{
|
||||||
|
int carry = 0;
|
||||||
|
|
||||||
|
for (int i = b.Length - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
ushort u = (ushort)(b[i] << 1);
|
||||||
|
b[i] = (byte)((u & 0xff) + carry);
|
||||||
|
carry = (u & 0xff00) >> 8;
|
||||||
|
}
|
||||||
|
return carry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CreateContext(string label, int keySize, byte[] licRequestBts)
|
||||||
|
{
|
||||||
|
var contextSize = label.Length + 1 + licRequestBts.Length + sizeof(int);
|
||||||
|
|
||||||
|
var context = new byte[contextSize];
|
||||||
|
var numChars = Encoding.ASCII.GetBytes(label.AsSpan(), context);
|
||||||
|
Array.Copy(licRequestBts, 0, context, numChars + 1, licRequestBts.Length);
|
||||||
|
|
||||||
|
var numBts = BitConverter.GetBytes(keySize);
|
||||||
|
if (BitConverter.IsLittleEndian)
|
||||||
|
Array.Reverse(numBts);
|
||||||
|
Array.Copy(numBts, 0, context, context.Length - sizeof(int), sizeof(int));
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint RandomUint()
|
||||||
|
{
|
||||||
|
var bts = new byte[4];
|
||||||
|
new Random().NextBytes(bts);
|
||||||
|
return BitConverter.ToUInt32(bts, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
internal enum DeviceTypes : byte
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Chrome = 1,
|
||||||
|
Android = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Device
|
||||||
|
{
|
||||||
|
public DeviceTypes Type { get; }
|
||||||
|
public int FileVersion { get; }
|
||||||
|
public int SecurityLevel { get; }
|
||||||
|
public int Flags { get; }
|
||||||
|
|
||||||
|
public RSA CdmKey { get; }
|
||||||
|
internal ClientIdentification ClientId { get; }
|
||||||
|
|
||||||
|
public Device(Span<byte> fileData)
|
||||||
|
{
|
||||||
|
if (fileData.Length < 7 || fileData[0] != 'W' || fileData[1] != 'V' || fileData[2] != 'D')
|
||||||
|
throw new InvalidDataException();
|
||||||
|
|
||||||
|
FileVersion = fileData[3];
|
||||||
|
Type = (DeviceTypes)fileData[4];
|
||||||
|
SecurityLevel = fileData[5];
|
||||||
|
Flags = fileData[6];
|
||||||
|
|
||||||
|
if (FileVersion != 2)
|
||||||
|
throw new InvalidDataException($"Unknown CDM File Version: '{FileVersion}'");
|
||||||
|
if (Type != DeviceTypes.Android)
|
||||||
|
throw new InvalidDataException($"Unknown CDM Type: '{Type}'");
|
||||||
|
if (SecurityLevel != 3)
|
||||||
|
throw new InvalidDataException($"Unknown CDM Security Level: '{SecurityLevel}'");
|
||||||
|
|
||||||
|
var privateKeyLength = (fileData[7] << 8) | fileData[8];
|
||||||
|
|
||||||
|
if (privateKeyLength <= 0 || fileData.Length < 9 + privateKeyLength + 2)
|
||||||
|
throw new InvalidDataException($"Invalid private key length: '{privateKeyLength}'");
|
||||||
|
|
||||||
|
var clientIdLength = (fileData[9 + privateKeyLength] << 8) | fileData[10 + privateKeyLength];
|
||||||
|
|
||||||
|
if (clientIdLength <= 0 || fileData.Length < 11 + privateKeyLength + clientIdLength)
|
||||||
|
throw new InvalidDataException($"Invalid client id length: '{clientIdLength}'");
|
||||||
|
|
||||||
|
ClientId = ClientIdentification.Parser.ParseFrom(fileData.Slice(11 + privateKeyLength));
|
||||||
|
CdmKey = RSA.Create();
|
||||||
|
CdmKey.ImportRSAPrivateKey(fileData.Slice(9, privateKeyLength), out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] SignMessage(byte[] message)
|
||||||
|
{
|
||||||
|
using var sha1 = SHA1.Create();
|
||||||
|
var digestion = sha1.ComputeHash(message);
|
||||||
|
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool VerifyMessage(byte[] message, byte[] signature)
|
||||||
|
{
|
||||||
|
using var sha1 = SHA1.Create();
|
||||||
|
var digestion = sha1.ComputeHash(message);
|
||||||
|
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] DecryptSessionKey(byte[] sessionKey)
|
||||||
|
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
internal static class Extensions
|
||||||
|
{
|
||||||
|
public static T[] Append<T>(this T[] message, T[] appendData)
|
||||||
|
{
|
||||||
|
var origLength = message.Length;
|
||||||
|
Array.Resize(ref message, origLength + appendData.Length);
|
||||||
|
Array.Copy(appendData, 0, message, origLength, appendData.Length);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Mpeg4Lib.Boxes;
|
||||||
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.IO;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using System.Xml.XPath;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace AudibleUtilities.Widevine;
|
||||||
|
|
||||||
|
public class MpegDash
|
||||||
|
{
|
||||||
|
private const string MpegDashNamespace = "urn:mpeg:dash:schema:mpd:2011";
|
||||||
|
private const string CencNamespace = "urn:mpeg:cenc:2013";
|
||||||
|
private const string UuidPreamble = "urn:uuid:";
|
||||||
|
private XElement DashMpd { get; }
|
||||||
|
private static XmlNamespaceManager NamespaceManager { get; } = new(new NameTable());
|
||||||
|
static MpegDash()
|
||||||
|
{
|
||||||
|
NamespaceManager.AddNamespace("dash", MpegDashNamespace);
|
||||||
|
NamespaceManager.AddNamespace("cenc", CencNamespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MpegDash(Stream contents)
|
||||||
|
{
|
||||||
|
DashMpd = XElement.Load(contents);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetUri(Uri baseUri, [NotNullWhen(true)] out Uri? fileUri)
|
||||||
|
{
|
||||||
|
foreach (var baseUrl in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:Representation/dash:BaseURL", NamespaceManager))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fileUri = new Uri(baseUri, baseUrl.Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
fileUri = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileUri = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetPssh(Guid protectionSystemId, [NotNullWhen(true)] out PsshBox? pssh)
|
||||||
|
{
|
||||||
|
foreach (var psshEle in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:ContentProtection/cenc:pssh", NamespaceManager))
|
||||||
|
{
|
||||||
|
if (psshEle?.Value?.Trim() is string psshStr
|
||||||
|
&& psshEle.Parent?.Attribute(XName.Get("schemeIdUri")) is XAttribute scheme
|
||||||
|
&& scheme.Value is string uuid
|
||||||
|
&& uuid.Equals(UuidPreamble + protectionSystemId.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Span<byte> buffer = new byte[psshStr.Length * 3 / 4];
|
||||||
|
if (Convert.TryFromBase64String(psshStr, buffer, out var written))
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream(buffer.Slice(0, written).ToArray());
|
||||||
|
pssh = BoxFactory.CreateBox(ms, null) as PsshBox;
|
||||||
|
return pssh is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pssh = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,6 @@ namespace DataLayer.Configurations
|
|||||||
//
|
//
|
||||||
entity.Ignore(nameof(Book.Authors));
|
entity.Ignore(nameof(Book.Authors));
|
||||||
entity.Ignore(nameof(Book.Narrators));
|
entity.Ignore(nameof(Book.Narrators));
|
||||||
entity.Ignore(nameof(Book.AudioFormat));
|
|
||||||
entity.Ignore(nameof(Book.TitleWithSubtitle));
|
entity.Ignore(nameof(Book.TitleWithSubtitle));
|
||||||
entity.Ignore(b => b.Categories);
|
entity.Ignore(b => b.Categories);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
@@ -10,14 +10,14 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="8.0.0.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="8.0.0.1" />
|
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.5">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace DataLayer
|
|
||||||
{
|
|
||||||
internal enum AudioFormatEnum : long
|
|
||||||
{
|
|
||||||
//Defining the enum this way ensures that when comparing:
|
|
||||||
//LC_128_44100_stereo > LC_64_44100_stereo > LC_64_22050_stereo > LC_64_22050_stereo
|
|
||||||
//This matches how audible interprets these codecs when specifying quality using AudibleApi.DownloadQuality
|
|
||||||
//I've never seen mono formats.
|
|
||||||
Unknown = 0,
|
|
||||||
LC_32_22050_stereo = (32L << 18) | (22050 << 2) | 2,
|
|
||||||
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
|
|
||||||
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
|
|
||||||
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
|
|
||||||
AAX_22_32 = LC_32_22050_stereo,
|
|
||||||
AAX_22_64 = LC_64_22050_stereo,
|
|
||||||
AAX_44_64 = LC_64_44100_stereo,
|
|
||||||
AAX_44_128 = LC_128_44100_stereo
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AudioFormat : IComparable<AudioFormat>, IComparable
|
|
||||||
{
|
|
||||||
internal int AudioFormatID { get; private set; }
|
|
||||||
public int Bitrate { get; private init; }
|
|
||||||
public int SampleRate { get; private init; }
|
|
||||||
public int Channels { get; private init; }
|
|
||||||
public bool IsValid => Bitrate != 0 && SampleRate != 0 && Channels != 0;
|
|
||||||
|
|
||||||
public static AudioFormat FromString(string formatStr)
|
|
||||||
{
|
|
||||||
if (Enum.TryParse(formatStr, ignoreCase: true, out AudioFormatEnum enumVal))
|
|
||||||
return FromEnum(enumVal);
|
|
||||||
return FromEnum(AudioFormatEnum.Unknown);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static AudioFormat FromEnum(AudioFormatEnum enumVal)
|
|
||||||
{
|
|
||||||
var val = (long)enumVal;
|
|
||||||
|
|
||||||
return new()
|
|
||||||
{
|
|
||||||
Bitrate = (int)(val >> 18),
|
|
||||||
SampleRate = (int)(val >> 2) & ushort.MaxValue,
|
|
||||||
Channels = (int)(val & 3)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
internal AudioFormatEnum ToEnum()
|
|
||||||
{
|
|
||||||
var val = (AudioFormatEnum)(((long)Bitrate << 18) | ((long)SampleRate << 2) | (long)Channels);
|
|
||||||
|
|
||||||
return Enum.IsDefined(val) ?
|
|
||||||
val : AudioFormatEnum.Unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString()
|
|
||||||
=> IsValid ?
|
|
||||||
$"{Bitrate} Kbps, {SampleRate / 1000d:F1} kHz, {(Channels == 2 ? "Stereo" : Channels)}" :
|
|
||||||
"Unknown";
|
|
||||||
|
|
||||||
public int CompareTo(AudioFormat other) => ToEnum().CompareTo(other.ToEnum());
|
|
||||||
|
|
||||||
public int CompareTo(object obj) => CompareTo(obj as AudioFormat);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -43,9 +43,11 @@ namespace DataLayer
|
|||||||
public ContentType ContentType { get; private set; }
|
public ContentType ContentType { get; private set; }
|
||||||
public string Locale { get; private set; }
|
public string Locale { get; private set; }
|
||||||
|
|
||||||
internal AudioFormatEnum _audioFormat;
|
//This field is now unused, however, there is little sense in adding a
|
||||||
|
//database migration to remove an unused field. Leave it for compatibility.
|
||||||
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); }
|
#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0
|
||||||
|
internal long _audioFormat;
|
||||||
|
#pragma warning restore CS0649
|
||||||
|
|
||||||
// mutable
|
// mutable
|
||||||
public string PictureId { get; set; }
|
public string PictureId { get; set; }
|
||||||
|
|||||||
@@ -43,5 +43,7 @@ namespace DataLayer
|
|||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
|
public void SetAudibleContributorId(string audibleContributorId)
|
||||||
|
=> AudibleContributorId = audibleContributorId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace DataLayer
|
|||||||
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
|
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
|
||||||
{
|
{
|
||||||
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
|
||||||
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
|
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
|
||||||
|
=> optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,11 @@ namespace DataLayer
|
|||||||
|
|
||||||
public static bool IsEpisodeParent(this Book book)
|
public static bool IsEpisodeParent(this Book book)
|
||||||
=> book.ContentType is ContentType.Parent;
|
=> book.ContentType is ContentType.Parent;
|
||||||
public static bool HasLiberated(this Book book)
|
|
||||||
|
public static IEnumerable<LibraryBook> WithoutParents(this IEnumerable<LibraryBook> libraryBooks)
|
||||||
|
=> libraryBooks.Where(lb => !lb.Book.IsEpisodeParent());
|
||||||
|
|
||||||
|
public static bool HasLiberated(this Book book)
|
||||||
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
|
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
|
||||||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
|
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ namespace DataLayer
|
|||||||
.AsNoTrackingWithIdentityResolution()
|
.AsNoTrackingWithIdentityResolution()
|
||||||
.GetLibrary()
|
.GetLibrary()
|
||||||
.AsEnumerable()
|
.AsEnumerable()
|
||||||
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
|
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
|
||||||
=> context
|
=> context
|
||||||
@@ -91,7 +91,7 @@ namespace DataLayer
|
|||||||
}
|
}
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
|
public static List<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
|
||||||
=> bookList
|
=> bookList
|
||||||
.Where(
|
.Where(
|
||||||
lb =>
|
lb =>
|
||||||
@@ -103,13 +103,11 @@ namespace DataLayer
|
|||||||
) == true
|
) == true
|
||||||
).ToList();
|
).ToList();
|
||||||
|
|
||||||
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
public static bool NeedsPdfDownload(this LibraryBook libraryBook)
|
||||||
=> bookList
|
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
|
||||||
.Where(
|
public static bool NeedsBookDownload(this LibraryBook libraryBook)
|
||||||
lb =>
|
=> !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload;
|
||||||
!lb.AbsentFromLastScan &&
|
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
|
||||||
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
=> bookList.Where(lb => lb.NeedsPdfDownload() || lb.NeedsBookDownload());
|
||||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,9 +154,6 @@ namespace DtoImporterService
|
|||||||
// Update the book titles, since formatting can change
|
// Update the book titles, since formatting can change
|
||||||
book.UpdateTitle(item.Title, item.Subtitle);
|
book.UpdateTitle(item.Title, item.Subtitle);
|
||||||
|
|
||||||
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
|
|
||||||
book.AudioFormat = codec;
|
|
||||||
|
|
||||||
// set/update book-specific info which may have changed
|
// set/update book-specific info which may have changed
|
||||||
if (item.PictureId is not null)
|
if (item.PictureId is not null)
|
||||||
book.PictureId = item.PictureId;
|
book.PictureId = item.PictureId;
|
||||||
|
|||||||
@@ -61,19 +61,19 @@ namespace DtoImporterService
|
|||||||
|
|
||||||
private int upsertPeople(List<Person> people)
|
private int upsertPeople(List<Person> people)
|
||||||
{
|
{
|
||||||
var hash = people
|
var qtyNew = 0;
|
||||||
// new people only
|
foreach (var person in people)
|
||||||
.Where(p => !Cache.ContainsKey(p.Name))
|
|
||||||
// remove duplicates by Name. first in wins
|
|
||||||
.ToDictionarySafe(p => p.Name);
|
|
||||||
|
|
||||||
foreach (var kvp in hash)
|
|
||||||
{
|
{
|
||||||
var person = kvp.Value;
|
if (!Cache.TryGetValue(person.Name, out var contributor))
|
||||||
addContributor(person.Name, person.Asin);
|
{
|
||||||
|
contributor = createContributor(person.Name, person.Asin);
|
||||||
|
qtyNew++;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContributor(person, contributor);
|
||||||
}
|
}
|
||||||
|
|
||||||
return hash.Count;
|
return qtyNew;
|
||||||
}
|
}
|
||||||
|
|
||||||
// only use after loading contributors => local
|
// only use after loading contributors => local
|
||||||
@@ -86,16 +86,22 @@ namespace DtoImporterService
|
|||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
foreach (var pub in hash)
|
foreach (var pub in hash)
|
||||||
addContributor(pub);
|
createContributor(pub);
|
||||||
|
|
||||||
return hash.Count;
|
return hash.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Contributor addContributor(string name, string id = null)
|
private void updateContributor(Person person, Contributor contributor)
|
||||||
|
{
|
||||||
|
if (person.Asin != contributor.AudibleContributorId)
|
||||||
|
contributor.SetAudibleContributorId(person.Asin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Contributor createContributor(string name, string id = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var newContrib = new Contributor(name);
|
var newContrib = new Contributor(name, id);
|
||||||
|
|
||||||
var entityEntry = DbContext.Contributors.Add(newContrib);
|
var entityEntry = DbContext.Contributors.Add(newContrib);
|
||||||
var entity = entityEntry.Entity;
|
var entity = entityEntry.Entity;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ namespace DtoImporterService
|
|||||||
existing.SetAccount(item.AccountId);
|
existing.SetAccount(item.AccountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.AbsentFromLastScan = isPlusTitleUnavailable(item);
|
existing.AbsentFromLastScan = isUnavailable(item);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -71,7 +71,7 @@ namespace DtoImporterService
|
|||||||
item.DtoItem.DateAdded,
|
item.DtoItem.DateAdded,
|
||||||
item.AccountId)
|
item.AccountId)
|
||||||
{
|
{
|
||||||
AbsentFromLastScan = isPlusTitleUnavailable(item)
|
AbsentFromLastScan = isUnavailable(item)
|
||||||
};
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -113,7 +113,13 @@ namespace DtoImporterService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
|
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
|
||||||
=> isPlusTitleUnavailable(item1) && !isPlusTitleUnavailable(item2) ? item2 : item1;
|
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
|
||||||
|
|
||||||
|
private static bool isUnavailable(ImportItem item)
|
||||||
|
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
|
||||||
|
|
||||||
|
private static bool isFutureRelease(ImportItem item)
|
||||||
|
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
private static bool isPlusTitleUnavailable(ImportItem item)
|
private static bool isPlusTitleUnavailable(ImportItem item)
|
||||||
=> item.DtoItem.ContentType is null
|
=> item.DtoItem.ContentType is null
|
||||||
|
|||||||
@@ -15,53 +15,28 @@ namespace FileLiberator
|
|||||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||||
public abstract Task CancelAsync();
|
public abstract Task CancelAsync();
|
||||||
|
|
||||||
protected LameConfig GetLameOptions(Configuration config)
|
|
||||||
{
|
|
||||||
LameConfig lameConfig = new()
|
|
||||||
{
|
|
||||||
Mode = MPEGMode.Mono,
|
|
||||||
Quality = config.LameEncoderQuality,
|
|
||||||
OutputSampleRate = (int)config.MaxSampleRate
|
|
||||||
};
|
|
||||||
|
|
||||||
if (config.LameTargetBitrate)
|
|
||||||
{
|
|
||||||
if (config.LameConstantBitrate)
|
|
||||||
lameConfig.BitRate = config.LameBitrate;
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lameConfig.ABRRateKbps = config.LameBitrate;
|
|
||||||
lameConfig.VBR = VBRMode.ABR;
|
|
||||||
lameConfig.WriteVBRTag = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
lameConfig.VBR = VBRMode.Default;
|
|
||||||
lameConfig.VBRQuality = config.LameVBRQuality;
|
|
||||||
lameConfig.WriteVBRTag = true;
|
|
||||||
}
|
|
||||||
return lameConfig;
|
|
||||||
}
|
|
||||||
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
||||||
protected void OnTitleDiscovered(object _, string title)
|
protected void OnTitleDiscovered(object _, string title)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
|
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
|
||||||
TitleDiscovered?.Invoke(this, title);
|
if (title != null)
|
||||||
|
TitleDiscovered?.Invoke(this, title);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors);
|
protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors);
|
||||||
protected void OnAuthorsDiscovered(object _, string authors)
|
protected void OnAuthorsDiscovered(object _, string authors)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
|
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
|
||||||
AuthorsDiscovered?.Invoke(this, authors);
|
if (authors != null)
|
||||||
|
AuthorsDiscovered?.Invoke(this, authors);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators);
|
protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators);
|
||||||
protected void OnNarratorsDiscovered(object _, string narrators)
|
protected void OnNarratorsDiscovered(object _, string narrators)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
|
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
|
||||||
NarratorsDiscovered?.Invoke(this, narrators);
|
if (narrators != null)
|
||||||
|
NarratorsDiscovered?.Invoke(this, narrators);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected byte[] OnRequestCoverArt()
|
protected byte[] OnRequestCoverArt()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
@@ -38,14 +39,20 @@ namespace FileLiberator
|
|||||||
/// Path: in progress directory.
|
/// Path: in progress directory.
|
||||||
/// File name: final file name.
|
/// File name: final file name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension)
|
||||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
=> Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PDF: audio file does not exist
|
/// PDF: audio file does not exist
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
|
||||||
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PDF: audio file does not exist
|
||||||
|
/// </summary>
|
||||||
|
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
|
||||||
|
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PDF: audio file already exists
|
/// PDF: audio file already exists
|
||||||
|
|||||||
@@ -44,13 +44,17 @@ namespace FileLiberator
|
|||||||
|
|
||||||
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||||
|
|
||||||
|
//AAXClean.Codecs only supports decoding AAC and E-AC-3 audio.
|
||||||
|
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||||
|
|
||||||
var config = Configuration.Instance;
|
var config = Configuration.Instance;
|
||||||
var lameConfig = GetLameOptions(config);
|
var lameConfig = DownloadOptions.GetLameOptions(config);
|
||||||
var chapters = m4bBook.GetChaptersFromMetadata();
|
var chapters = m4bBook.GetChaptersFromMetadata();
|
||||||
//Finishing configuring lame encoder.
|
//Finishing configuring lame encoder.
|
||||||
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AaxDecrypter;
|
using AaxDecrypter;
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
@@ -18,10 +19,15 @@ namespace FileLiberator
|
|||||||
{
|
{
|
||||||
public override string Name => "Download & Decrypt";
|
public override string Name => "Download & Decrypt";
|
||||||
private AudiobookDownloadBase abDownloader;
|
private AudiobookDownloadBase abDownloader;
|
||||||
|
private readonly CancellationTokenSource cancellationTokenSource = new();
|
||||||
|
|
||||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||||
|
public override async Task CancelAsync()
|
||||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
{
|
||||||
|
cancellationTokenSource.Cancel();
|
||||||
|
if (abDownloader is not null)
|
||||||
|
await abDownloader.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
@@ -41,19 +47,25 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
|
|
||||||
OnBegin(libraryBook);
|
OnBegin(libraryBook);
|
||||||
|
var cancellationToken = cancellationTokenSource.Token;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (libraryBook.Book.Audio_Exists())
|
if (libraryBook.Book.Audio_Exists())
|
||||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||||
|
|
||||||
|
downloadValidation(libraryBook);
|
||||||
|
var api = await libraryBook.GetApiAsync();
|
||||||
|
var config = Configuration.Instance;
|
||||||
|
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook, cancellationToken);
|
||||||
|
|
||||||
bool success = false;
|
bool success = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||||
FilePathCache.Removed += FilePathCache_Removed;
|
FilePathCache.Removed += FilePathCache_Removed;
|
||||||
|
|
||||||
success = await downloadAudiobookAsync(libraryBook);
|
success = await downloadAudiobookAsync(api, config, downloadOptions);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -69,38 +81,32 @@ namespace FileLiberator
|
|||||||
.Where(f => f.FileType != FileType.AAXC)
|
.Where(f => f.FileType != FileType.AAXC)
|
||||||
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
||||||
|
|
||||||
return
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
abDownloader?.IsCanceled is true
|
return new StatusHandler { "Decrypt failed" };
|
||||||
? new StatusHandler { "Cancelled" }
|
|
||||||
: new StatusHandler { "Decrypt failed" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var finalStorageDir = getDestinationDirectory(libraryBook);
|
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||||
|
|
||||||
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken));
|
||||||
Task[] finalTasks = new[]
|
Task[] finalTasks =
|
||||||
{
|
[
|
||||||
Task.Run(() => downloadCoverArt(libraryBook)),
|
Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)),
|
||||||
moveFilesTask,
|
moveFilesTask,
|
||||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
|
||||||
};
|
];
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.WhenAll(finalTasks);
|
await Task.WhenAll(finalTasks);
|
||||||
}
|
}
|
||||||
catch
|
catch when (!moveFilesTask.IsFaulted)
|
||||||
{
|
{
|
||||||
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
|
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
|
||||||
//Only fail if the downloaded audio files failed to move to Books directory
|
//Only fail if the downloaded audio files failed to move to Books directory
|
||||||
if (moveFilesTask.IsFaulted)
|
}
|
||||||
{
|
finally
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
{
|
||||||
if (moveFilesTask.IsCompletedSuccessfully)
|
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
|
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
|
||||||
|
|
||||||
@@ -109,28 +115,24 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new StatusHandler();
|
return new StatusHandler();
|
||||||
}
|
}
|
||||||
finally
|
catch when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Information("Download/Decrypt was cancelled. {@Book}", libraryBook.LogFriendly());
|
||||||
|
return new StatusHandler { "Cancelled" };
|
||||||
|
}
|
||||||
|
finally
|
||||||
{
|
{
|
||||||
OnCompleted(libraryBook);
|
OnCompleted(libraryBook);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
|
||||||
{
|
{
|
||||||
var config = Configuration.Instance;
|
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
|
||||||
|
|
||||||
downloadValidation(libraryBook);
|
|
||||||
|
|
||||||
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
|
|
||||||
var api = await libraryBook.GetApiAsync();
|
|
||||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
|
|
||||||
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
|
||||||
|
|
||||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
|
||||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||||
|
|
||||||
if (contentLic.DrmType != DrmType.Adrm)
|
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
||||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -140,7 +142,7 @@ namespace FileLiberator
|
|||||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||||
|
|
||||||
if (config.AllowLibationFixup)
|
if (config.AllowLibationFixup)
|
||||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
|
converter.RetrievedMetadata += Converter_RetrievedMetadata;
|
||||||
|
|
||||||
abDownloader = converter;
|
abDownloader = converter;
|
||||||
}
|
}
|
||||||
@@ -151,201 +153,70 @@ namespace FileLiberator
|
|||||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path);
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
// REAL WORK DONE HERE
|
||||||
var success = await abDownloader.RunAsync();
|
var success = await abDownloader.RunAsync();
|
||||||
|
|
||||||
if (success && config.SaveMetadataToFile)
|
if (success && config.SaveMetadataToFile)
|
||||||
{
|
{
|
||||||
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
|
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
|
||||||
|
|
||||||
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
|
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
|
||||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
|
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
|
||||||
|
|
||||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
||||||
OnFileCreated(libraryBook, metadataFile);
|
OnFileCreated(dlOptions.LibraryBook, metadataFile);
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags)
|
||||||
{
|
{
|
||||||
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
|
||||||
|
return;
|
||||||
|
|
||||||
var outputFormat
|
#region Prevent erroneous truncation due to incorrect chapter info
|
||||||
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
|
||||||
? OutputFormat.Mp3
|
|
||||||
: OutputFormat.M4b;
|
|
||||||
|
|
||||||
long chapterStartMs
|
//Sometimes the chapter info is not accurate. Since AAXClean trims audio
|
||||||
= config.StripAudibleBrandAudio
|
//files to the chapters start and end, if the last chapter's end time is
|
||||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
//before the end of the audio file, the file will be truncated to match
|
||||||
: 0;
|
//the chapter. This is never desirable, so pad the last chapter to match
|
||||||
|
//the original audio length.
|
||||||
|
|
||||||
//Set the requested AudioFormat for use in file naming templates
|
var fileDuration = converter.AaxFile.Duration;
|
||||||
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
|
if (options.Config.StripAudibleBrandAudio)
|
||||||
|
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||||
|
|
||||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
|
||||||
|
//Remove the last chapter and re-add it with the durationDelta that will
|
||||||
|
//make the chapter's end coincide with the end of the audio file.
|
||||||
|
var chapters = options.ChapterInfo.Chapters as List<AAXClean.Chapter>;
|
||||||
|
var lastChapter = chapters[^1];
|
||||||
|
|
||||||
|
chapters.Remove(lastChapter);
|
||||||
|
options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta);
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
||||||
|
tags.Album ??= tags.Title;
|
||||||
|
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
||||||
|
tags.AlbumArtists ??= tags.Artist;
|
||||||
|
tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
|
||||||
|
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||||
|
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||||
|
tags.LongDescription ??= tags.Comment;
|
||||||
|
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
||||||
|
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
|
||||||
|
tags.Asin = options.LibraryBook.Book.AudibleProductId;
|
||||||
|
tags.Acr = options.ContentMetadata.ContentReference.Acr;
|
||||||
|
tags.Version = options.ContentMetadata.ContentReference.Version;
|
||||||
|
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
|
||||||
{
|
{
|
||||||
AudibleKey = contentLic?.Voucher?.Key,
|
tags.Year ??= pubDate.Year.ToString();
|
||||||
AudibleIV = contentLic?.Voucher?.Iv,
|
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||||
OutputFormat = outputFormat,
|
|
||||||
LameConfig = GetLameOptions(config),
|
|
||||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
|
||||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
|
||||||
var chapters
|
|
||||||
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
|
||||||
.OrderBy(c => c.StartOffsetMs)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (config.MergeOpeningAndEndCredits)
|
|
||||||
combineCredits(chapters);
|
|
||||||
|
|
||||||
for (int i = 0; i < chapters.Count; i++)
|
|
||||||
{
|
|
||||||
var chapter = chapters[i];
|
|
||||||
long chapLenMs = chapter.LengthMs;
|
|
||||||
|
|
||||||
if (i == 0)
|
|
||||||
chapLenMs -= chapterStartMs;
|
|
||||||
|
|
||||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
|
||||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
|
||||||
|
|
||||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
return dlOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
|
|
||||||
Flatten Audible's new hierarchical chapters, combining children into parents.
|
|
||||||
|
|
||||||
Audible may deliver chapters like this:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 00:12 Book 1
|
|
||||||
00:12 - 00:14 | Part 1
|
|
||||||
00:14 - 01:40 | | Chapter 1
|
|
||||||
01:40 - 03:20 | | Chapter 2
|
|
||||||
03:20 - 03:22 | Part 2
|
|
||||||
03:22 - 05:00 | | Chapter 3
|
|
||||||
05:00 - 06:40 | | Chapter 4
|
|
||||||
06:40 - 06:42 Book 2
|
|
||||||
06:42 - 06:44 | Part 3
|
|
||||||
06:44 - 08:20 | | Chapter 5
|
|
||||||
08:20 - 10:00 | | Chapter 6
|
|
||||||
10:00 - 10:02 | Part 4
|
|
||||||
10:02 - 11:40 | | Chapter 7
|
|
||||||
11:40 - 13:20 | | Chapter 8
|
|
||||||
13:20 - 13:30 End Credits
|
|
||||||
|
|
||||||
And flattenChapters will combine them into this:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
|
||||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
|
||||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
|
||||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
|
||||||
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
|
||||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
|
||||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
|
||||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
|
||||||
13:20 - 13:40 End Credits
|
|
||||||
|
|
||||||
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
|
||||||
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
|
||||||
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
|
||||||
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 00:25 Book 1
|
|
||||||
00:25 - 00:27 | Part 1
|
|
||||||
00:27 - 01:40 | | Chapter 1
|
|
||||||
01:40 - 03:20 | | Chapter 2
|
|
||||||
03:20 - 03:22 | Part 2
|
|
||||||
03:22 - 05:00 | | Chapter 3
|
|
||||||
05:00 - 06:40 | | Chapter 4
|
|
||||||
06:40 - 06:42 Book 2
|
|
||||||
06:42 - 07:02 | Part 3
|
|
||||||
07:02 - 08:20 | | Chapter 5
|
|
||||||
08:20 - 10:00 | | Chapter 6
|
|
||||||
10:00 - 10:02 | Part 4
|
|
||||||
10:02 - 11:40 | | Chapter 7
|
|
||||||
11:40 - 13:20 | | Chapter 8
|
|
||||||
13:20 - 13:30 End Credits
|
|
||||||
|
|
||||||
then flattenChapters will combine them into this:
|
|
||||||
|
|
||||||
00:00 - 00:10 Opening Credits
|
|
||||||
00:10 - 00:25 Book 1
|
|
||||||
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
|
||||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
|
||||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
|
||||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
|
||||||
06:40 - 07:02 Book 2: Part 3
|
|
||||||
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
|
||||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
|
||||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
|
||||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
|
||||||
13:20 - 13:40 End Credits
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string titleConcat = ": ")
|
|
||||||
{
|
|
||||||
List<Chapter> chaps = new();
|
|
||||||
|
|
||||||
foreach (var c in chapters)
|
|
||||||
{
|
|
||||||
if (c.Chapters is null)
|
|
||||||
chaps.Add(c);
|
|
||||||
else if (titleConcat is null)
|
|
||||||
{
|
|
||||||
chaps.Add(c);
|
|
||||||
chaps.AddRange(flattenChapters(c.Chapters));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (c.LengthMs < 10000)
|
|
||||||
{
|
|
||||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
|
||||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
|
||||||
c.Chapters[0].LengthMs += c.LengthMs;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
chaps.Add(c);
|
|
||||||
|
|
||||||
var children = flattenChapters(c.Chapters);
|
|
||||||
|
|
||||||
foreach (var child in children)
|
|
||||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
|
||||||
|
|
||||||
chaps.AddRange(children);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chaps;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void combineCredits(IList<Chapter> chapters)
|
|
||||||
{
|
|
||||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
|
||||||
{
|
|
||||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
|
||||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
|
||||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
|
||||||
chapters.RemoveAt(0);
|
|
||||||
}
|
|
||||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
|
||||||
{
|
|
||||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
|
||||||
chapters.Remove(chapters[^1]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,16 +263,17 @@ namespace FileLiberator
|
|||||||
|
|
||||||
/// <summary>Move new files to 'Books' directory</summary>
|
/// <summary>Move new files to 'Books' directory</summary>
|
||||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||||
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// create final directory. move each file into it
|
// create final directory. move each file into it
|
||||||
var destinationDir = getDestinationDirectory(libraryBook);
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
for (var i = 0; i < entries.Count; i++)
|
for (var i = 0; i < entries.Count; i++)
|
||||||
{
|
{
|
||||||
var entry = entries[i];
|
var entry = entries[i];
|
||||||
|
|
||||||
var realDest
|
var realDest
|
||||||
= FileUtility.SaferMoveToValidPath(
|
= FileUtility.SaferMoveToValidPath(
|
||||||
entry.Path,
|
entry.Path,
|
||||||
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
|
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
|
||||||
@@ -413,7 +285,8 @@ namespace FileLiberator
|
|||||||
|
|
||||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||||
entries[i] = entry with { Path = realDest };
|
entries[i] = entry with { Path = realDest };
|
||||||
}
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
}
|
||||||
|
|
||||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||||
if (cue != default)
|
if (cue != default)
|
||||||
@@ -422,7 +295,8 @@ namespace FileLiberator
|
|||||||
SetFileTime(libraryBook, cue.Path);
|
SetFileTime(libraryBook, cue.Path);
|
||||||
}
|
}
|
||||||
|
|
||||||
AudibleFileStorage.Audio.Refresh();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
AudibleFileStorage.Audio.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string getDestinationDirectory(LibraryBook libraryBook)
|
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||||
@@ -436,7 +310,7 @@ namespace FileLiberator
|
|||||||
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
||||||
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||||
|
|
||||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
private static void downloadCoverArt(DownloadOptions options, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||||
|
|
||||||
@@ -444,24 +318,25 @@ namespace FileLiberator
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var destinationDir = getDestinationDirectory(libraryBook);
|
var destinationDir = getDestinationDirectory(options.LibraryBook);
|
||||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||||
|
|
||||||
if (File.Exists(coverPath))
|
if (File.Exists(coverPath))
|
||||||
FileUtility.SaferDelete(coverPath);
|
FileUtility.SaferDelete(coverPath);
|
||||||
|
|
||||||
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken);
|
||||||
if (picBytes.Length > 0)
|
if (picBytes.Length > 0)
|
||||||
{
|
{
|
||||||
File.WriteAllBytes(coverPath, picBytes);
|
File.WriteAllBytes(coverPath, picBytes);
|
||||||
SetFileTime(libraryBook, coverPath);
|
SetFileTime(options.LibraryBook, coverPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
//Failure to download cover art should not be considered a failure to download the book
|
//Failure to download cover art should not be considered a failure to download the book
|
||||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
using AaxDecrypter;
|
||||||
|
using AudibleApi;
|
||||||
|
using AudibleApi.Common;
|
||||||
|
using AudibleUtilities.Widevine;
|
||||||
|
using DataLayer;
|
||||||
|
using LibationFileManager;
|
||||||
|
using NAudio.Lame;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
|
namespace FileLiberator;
|
||||||
|
|
||||||
|
public partial class DownloadOptions
|
||||||
|
{
|
||||||
|
private const string Ec3Codec = "ec+3";
|
||||||
|
private const string Ac4Codec = "ac-4";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initiate an audiobook download from the audible api.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
|
||||||
|
{
|
||||||
|
var license = await ChooseContent(api, libraryBook, config, token);
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
//Some audiobooks will have incorrect chapters in the metadata returned from the license request,
|
||||||
|
//but the metadata returned by the content metadata endpoint will be correct. Call the content
|
||||||
|
//metadata endpoint and use its chapters. Only replace the license request chapters if the total
|
||||||
|
//lengths match (defensive against different audio formats having slightly different lengths).
|
||||||
|
var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId);
|
||||||
|
if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs)
|
||||||
|
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
return BuildDownloadOptions(libraryBook, config, license);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LicenseInfo
|
||||||
|
{
|
||||||
|
public DrmType DrmType { get; }
|
||||||
|
public ContentMetadata ContentMetadata { get; set; }
|
||||||
|
public KeyData[]? DecryptionKeys { get; }
|
||||||
|
public LicenseInfo(ContentLicense license, IEnumerable<KeyData>? keys = null)
|
||||||
|
{
|
||||||
|
DrmType = license.DrmType;
|
||||||
|
ContentMetadata = license.ContentMetadata;
|
||||||
|
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyData[]? ToKeys(VoucherDtoV10? voucher)
|
||||||
|
=> voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||||
|
{
|
||||||
|
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||||
|
|
||||||
|
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||||
|
return new LicenseInfo(license);
|
||||||
|
}
|
||||||
|
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
//try to request a widevine content license using the user's spatial audio settings
|
||||||
|
var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec;
|
||||||
|
|
||||||
|
var contentLic
|
||||||
|
= await api.GetDownloadLicenseAsync(
|
||||||
|
libraryBook.Book.AudibleProductId,
|
||||||
|
dlQuality,
|
||||||
|
ChapterTitlesType.Tree,
|
||||||
|
DrmType.Widevine,
|
||||||
|
config.RequestSpatial,
|
||||||
|
codecChoice);
|
||||||
|
|
||||||
|
if (contentLic.DrmType is not DrmType.Widevine)
|
||||||
|
return new LicenseInfo(contentLic);
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token);
|
||||||
|
var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
|
||||||
|
|
||||||
|
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
|
||||||
|
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
|
||||||
|
|
||||||
|
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
|
||||||
|
|
||||||
|
using var session = cdm.OpenSession();
|
||||||
|
var challenge = session.GetLicenseChallenge(dash);
|
||||||
|
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
|
||||||
|
var keys = session.ParseLicense(licenseMessage);
|
||||||
|
return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
||||||
|
//We failed to get a widevine content license. Depending on the
|
||||||
|
//failure reason, users can potentially still download this audiobook
|
||||||
|
//by disabling the "Use Widevine DRM" feature.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||||
|
{
|
||||||
|
long chapterStartMs
|
||||||
|
= config.StripAudibleBrandAudio
|
||||||
|
? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
|
||||||
|
{
|
||||||
|
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||||
|
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
|
||||||
|
{
|
||||||
|
dlOptions.LibraryBookDto.BitRate = bitrate;
|
||||||
|
dlOptions.LibraryBookDto.SampleRate = sampleRate;
|
||||||
|
dlOptions.LibraryBookDto.Channels = channels;
|
||||||
|
}
|
||||||
|
|
||||||
|
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||||
|
var chapters
|
||||||
|
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||||
|
.OrderBy(c => c.StartOffsetMs)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (config.MergeOpeningAndEndCredits)
|
||||||
|
combineCredits(chapters);
|
||||||
|
|
||||||
|
for (int i = 0; i < chapters.Count; i++)
|
||||||
|
{
|
||||||
|
var chapter = chapters[i];
|
||||||
|
long chapLenMs = chapter.LengthMs;
|
||||||
|
|
||||||
|
if (i == 0)
|
||||||
|
chapLenMs -= chapterStartMs;
|
||||||
|
|
||||||
|
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||||
|
chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||||
|
|
||||||
|
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dlOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The most reliable way to get these audio file properties is from the filename itself.
|
||||||
|
/// Using AAXClean to read the metadata works well for everything except AC-4 bitrate.
|
||||||
|
/// </summary>
|
||||||
|
private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels)
|
||||||
|
{
|
||||||
|
bitrate = sampleRate = channels = null;
|
||||||
|
|
||||||
|
if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var file = Path.GetFileName(uri.LocalPath);
|
||||||
|
|
||||||
|
var match = AdrmAudioProperties().Match(file);
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
bitrate = int.Parse(match.Groups[1].Value);
|
||||||
|
sampleRate = int.Parse(match.Groups[2].Value);
|
||||||
|
channels = int.Parse(match.Groups[3].Value);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else if ((match = WidevineAudioProperties().Match(file)).Success)
|
||||||
|
{
|
||||||
|
bitrate = int.Parse(match.Groups[2].Value);
|
||||||
|
sampleRate = int.Parse(match.Groups[1].Value) * 1000;
|
||||||
|
channels = match.Groups[3].Value switch
|
||||||
|
{
|
||||||
|
"ec3" => 6,
|
||||||
|
"ac4" => 3,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LameConfig GetLameOptions(Configuration config)
|
||||||
|
{
|
||||||
|
LameConfig lameConfig = new()
|
||||||
|
{
|
||||||
|
Mode = MPEGMode.Mono,
|
||||||
|
Quality = config.LameEncoderQuality,
|
||||||
|
OutputSampleRate = (int)config.MaxSampleRate
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.LameTargetBitrate)
|
||||||
|
{
|
||||||
|
if (config.LameConstantBitrate)
|
||||||
|
lameConfig.BitRate = config.LameBitrate;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lameConfig.ABRRateKbps = config.LameBitrate;
|
||||||
|
lameConfig.VBR = VBRMode.ABR;
|
||||||
|
lameConfig.WriteVBRTag = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lameConfig.VBR = VBRMode.Default;
|
||||||
|
lameConfig.VBRQuality = config.LameVBRQuality;
|
||||||
|
lameConfig.WriteVBRTag = true;
|
||||||
|
}
|
||||||
|
return lameConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||||
|
|
||||||
|
Audible may deliver chapters like this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:12 Book 1
|
||||||
|
00:12 - 00:14 | Part 1
|
||||||
|
00:14 - 01:40 | | Chapter 1
|
||||||
|
01:40 - 03:20 | | Chapter 2
|
||||||
|
03:20 - 03:22 | Part 2
|
||||||
|
03:22 - 05:00 | | Chapter 3
|
||||||
|
05:00 - 06:40 | | Chapter 4
|
||||||
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 06:44 | Part 3
|
||||||
|
06:44 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
|
And flattenChapters will combine them into this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
|
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
|
13:20 - 13:40 End Credits
|
||||||
|
|
||||||
|
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
||||||
|
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
||||||
|
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
||||||
|
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:25 Book 1
|
||||||
|
00:25 - 00:27 | Part 1
|
||||||
|
00:27 - 01:40 | | Chapter 1
|
||||||
|
01:40 - 03:20 | | Chapter 2
|
||||||
|
03:20 - 03:22 | Part 2
|
||||||
|
03:22 - 05:00 | | Chapter 3
|
||||||
|
05:00 - 06:40 | | Chapter 4
|
||||||
|
06:40 - 06:42 Book 2
|
||||||
|
06:42 - 07:02 | Part 3
|
||||||
|
07:02 - 08:20 | | Chapter 5
|
||||||
|
08:20 - 10:00 | | Chapter 6
|
||||||
|
10:00 - 10:02 | Part 4
|
||||||
|
10:02 - 11:40 | | Chapter 7
|
||||||
|
11:40 - 13:20 | | Chapter 8
|
||||||
|
13:20 - 13:30 End Credits
|
||||||
|
|
||||||
|
then flattenChapters will combine them into this:
|
||||||
|
|
||||||
|
00:00 - 00:10 Opening Credits
|
||||||
|
00:10 - 00:25 Book 1
|
||||||
|
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
||||||
|
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||||
|
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||||
|
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||||
|
06:40 - 07:02 Book 2: Part 3
|
||||||
|
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
||||||
|
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||||
|
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||||
|
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||||
|
13:20 - 13:40 End Credits
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string? titleConcat = ": ")
|
||||||
|
{
|
||||||
|
List<Chapter> chaps = new();
|
||||||
|
|
||||||
|
foreach (var c in chapters)
|
||||||
|
{
|
||||||
|
if (c.Chapters is null)
|
||||||
|
chaps.Add(c);
|
||||||
|
else if (titleConcat is null)
|
||||||
|
{
|
||||||
|
chaps.Add(c);
|
||||||
|
chaps.AddRange(flattenChapters(c.Chapters, titleConcat));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (c.LengthMs < 10000)
|
||||||
|
{
|
||||||
|
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||||
|
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||||
|
c.Chapters[0].LengthMs += c.LengthMs;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
chaps.Add(c);
|
||||||
|
|
||||||
|
var children = flattenChapters(c.Chapters, titleConcat);
|
||||||
|
|
||||||
|
foreach (var child in children)
|
||||||
|
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||||
|
|
||||||
|
chaps.AddRange(children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void combineCredits(IList<Chapter> chapters)
|
||||||
|
{
|
||||||
|
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||||
|
{
|
||||||
|
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||||
|
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||||
|
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||||
|
chapters.RemoveAt(0);
|
||||||
|
}
|
||||||
|
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||||
|
{
|
||||||
|
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||||
|
chapters.Remove(chapters[^1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static double RelativePercentDifference(long num1, long num2)
|
||||||
|
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
|
||||||
|
|
||||||
|
[GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex WidevineAudioProperties();
|
||||||
|
[GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex AdrmAudioProperties();
|
||||||
|
}
|
||||||
@@ -7,45 +7,49 @@ using System.Threading.Tasks;
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public class DownloadOptions : IDownloadOptions, IDisposable
|
public partial class DownloadOptions : IDownloadOptions, IDisposable
|
||||||
{
|
{
|
||||||
public event EventHandler<long> DownloadSpeedChanged;
|
public event EventHandler<long>? DownloadSpeedChanged;
|
||||||
public LibraryBook LibraryBook { get; }
|
public LibraryBook LibraryBook { get; }
|
||||||
public LibraryBookDto LibraryBookDto { get; }
|
public LibraryBookDto LibraryBookDto { get; }
|
||||||
public string DownloadUrl { get; }
|
public string DownloadUrl { get; }
|
||||||
public string AudibleKey { get; init; }
|
public KeyData[]? DecryptionKeys { get; }
|
||||||
public string AudibleIV { get; init; }
|
public required TimeSpan RuntimeLength { get; init; }
|
||||||
public TimeSpan RuntimeLength { get; init; }
|
public OutputFormat OutputFormat { get; }
|
||||||
public OutputFormat OutputFormat { get; init; }
|
public required ChapterInfo ChapterInfo { get; init; }
|
||||||
public ChapterInfo ChapterInfo { get; init; }
|
|
||||||
public string Title => LibraryBook.Book.Title;
|
public string Title => LibraryBook.Book.Title;
|
||||||
public string Subtitle => LibraryBook.Book.Subtitle;
|
public string Subtitle => LibraryBook.Book.Subtitle;
|
||||||
public string Publisher => LibraryBook.Book.Publisher;
|
public string Publisher => LibraryBook.Book.Publisher;
|
||||||
public string Language => LibraryBook.Book.Language;
|
public string Language => LibraryBook.Book.Language;
|
||||||
public string AudibleProductId => LibraryBookDto.AudibleProductId;
|
public string? AudibleProductId => LibraryBookDto.AudibleProductId;
|
||||||
public string SeriesName => LibraryBookDto.SeriesName;
|
public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
|
||||||
public float? SeriesNumber => LibraryBookDto.SeriesNumber;
|
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
||||||
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
public NAudio.Lame.LameConfig? LameConfig { get; }
|
||||||
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||||
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio;
|
||||||
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
|
public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
|
||||||
public bool CreateCueSheet => config.CreateCueSheet;
|
public bool CreateCueSheet => Config.CreateCueSheet;
|
||||||
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks;
|
public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks;
|
||||||
public long DownloadSpeedBps => config.DownloadSpeedLimit;
|
public long DownloadSpeedBps => Config.DownloadSpeedLimit;
|
||||||
public bool RetainEncryptedFile => config.RetainAaxFile;
|
public bool RetainEncryptedFile => Config.RetainAaxFile;
|
||||||
public bool FixupFile => config.AllowLibationFixup;
|
public bool FixupFile => Config.AllowLibationFixup;
|
||||||
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
|
public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
|
||||||
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
|
public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
|
||||||
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
public bool MoveMoovToBeginning => Config.MoveMoovToBeginning;
|
||||||
|
public AAXClean.FileType? InputType { get; }
|
||||||
|
public AudibleApi.Common.DrmType DrmType { get; }
|
||||||
|
public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
|
||||||
|
|
||||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||||
{
|
{
|
||||||
var baseDir = Path.GetDirectoryName(props.OutputFileName);
|
var baseDir = Path.GetDirectoryName(props.OutputFileName);
|
||||||
var extension = Path.GetExtension(props.OutputFileName);
|
var extension = Path.GetExtension(props.OutputFileName);
|
||||||
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension);
|
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetMultipartTitle(MultiConvertFileProperties props)
|
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||||
@@ -55,7 +59,7 @@ namespace FileLiberator
|
|||||||
{
|
{
|
||||||
if (DownloadClipsBookmarks)
|
if (DownloadClipsBookmarks)
|
||||||
{
|
{
|
||||||
var format = config.ClipsBookmarksFileFormat;
|
var format = Config.ClipsBookmarksFileFormat;
|
||||||
|
|
||||||
var formatExtension = format.ToString().ToLowerInvariant();
|
var formatExtension = format.ToString().ToLowerInvariant();
|
||||||
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
||||||
@@ -80,18 +84,46 @@ namespace FileLiberator
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly Configuration config;
|
public Configuration Config { get; }
|
||||||
private readonly IDisposable cancellation;
|
private readonly IDisposable cancellation;
|
||||||
public void Dispose() => cancellation?.Dispose();
|
public void Dispose()
|
||||||
|
|
||||||
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
|
|
||||||
{
|
{
|
||||||
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
cancellation?.Dispose();
|
||||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
GC.SuppressFinalize(this);
|
||||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
}
|
||||||
// no null/empty check for key/iv. unencrypted files do not have them
|
|
||||||
|
|
||||||
|
private DownloadOptions(Configuration config, LibraryBook libraryBook, LicenseInfo licInfo)
|
||||||
|
{
|
||||||
|
Config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||||
|
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||||
|
|
||||||
|
ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo));
|
||||||
|
|
||||||
|
if (licInfo.ContentMetadata.ContentUrl.OfflineUrl is not string licUrl)
|
||||||
|
throw new InvalidDataException("Content license doesn't contain an offline Url");
|
||||||
|
|
||||||
|
DownloadUrl = licUrl;
|
||||||
|
DecryptionKeys = licInfo.DecryptionKeys;
|
||||||
|
DrmType = licInfo.DrmType;
|
||||||
|
ContentMetadata = licInfo.ContentMetadata;
|
||||||
|
InputType
|
||||||
|
= licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash
|
||||||
|
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 4 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax
|
||||||
|
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 16 && licInfo.DecryptionKeys[0].KeyPart2?.Length == 16 ? AAXClean.FileType.Aaxc
|
||||||
|
: null;
|
||||||
|
|
||||||
|
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
|
||||||
|
OutputFormat
|
||||||
|
= licInfo.DrmType is not AudibleApi.Common.DrmType.Adrm and not AudibleApi.Common.DrmType.Widevine ||
|
||||||
|
(config.AllowLibationFixup && config.DecryptToLossy && licInfo.ContentMetadata.ContentReference.Codec != Ac4Codec)
|
||||||
|
? OutputFormat.Mp3
|
||||||
|
: OutputFormat.M4b;
|
||||||
|
|
||||||
|
LameConfig = OutputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null;
|
||||||
|
|
||||||
|
// no null/empty check for key/iv. unencrypted files do not have them
|
||||||
LibraryBookDto = LibraryBook.ToDto();
|
LibraryBookDto = LibraryBook.ToDto();
|
||||||
|
LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec;
|
||||||
|
|
||||||
cancellation =
|
cancellation =
|
||||||
config
|
config
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -19,5 +19,10 @@
|
|||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="DownloadOptions.*.cs">
|
||||||
|
<DependentUpon>DownloadOptions.cs</DependentUpon>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -5,8 +5,9 @@ using System.Threading.Tasks;
|
|||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using LibationFileManager;
|
using LibationFileManager.Templates;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace FileLiberator
|
namespace FileLiberator
|
||||||
{
|
{
|
||||||
public static class UtilityExtensions
|
public static class UtilityExtensions
|
||||||
@@ -19,9 +20,15 @@ namespace FileLiberator
|
|||||||
account: libraryBook.Account.ToMask()
|
account: libraryBook.Account.ToMask()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
|
||||||
|
|
||||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
var apiExtended = await ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
Account account;
|
||||||
|
using (var accounts = AudibleApiStorage.GetAccountsSettingsPersister())
|
||||||
|
account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
|
||||||
|
|
||||||
|
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||||
return apiExtended.Api;
|
return apiExtended.Api;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,20 +54,31 @@ namespace FileLiberator
|
|||||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||||
DatePublished = libraryBook.Book.DatePublished,
|
DatePublished = libraryBook.Book.DatePublished,
|
||||||
|
|
||||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
Authors = libraryBook.Book.Authors.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
|
||||||
|
Narrators = libraryBook.Book.Narrators.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
|
||||||
|
|
||||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
Series = getSeries(libraryBook.Book.SeriesLink),
|
||||||
|
|
||||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
|
||||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
|
||||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||||
|
|
||||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
|
||||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
|
||||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
|
||||||
Language = libraryBook.Book.Language
|
Language = libraryBook.Book.Language
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<SeriesDto>? getSeries(IEnumerable<SeriesBook> seriesBooks)
|
||||||
|
{
|
||||||
|
if (!seriesBooks.Any())
|
||||||
|
return null;
|
||||||
|
|
||||||
|
//I don't remember why or if there was a good reason not to have series numbers for
|
||||||
|
//podcast parents, but preserving the behavior for backwards compatibility.
|
||||||
|
return seriesBooks
|
||||||
|
.Select(sb
|
||||||
|
=> new SeriesDto(
|
||||||
|
sb.Series.Name,
|
||||||
|
sb.Book.IsEpisodeParent() ? null : sb.Index,
|
||||||
|
sb.Series.AudibleSeriesId)
|
||||||
|
).ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dinah.Core" Version="8.0.0.1" />
|
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||||
<PackageReference Include="Polly" Version="8.4.1" />
|
<PackageReference Include="Polly" Version="8.6.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ namespace FileManager
|
|||||||
/// <param name="extension">File extension override to use for <paramref name="destination"/></param>
|
/// <param name="extension">File extension override to use for <paramref name="destination"/></param>
|
||||||
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
|
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
|
||||||
/// <returns>The actual destination filename</returns>
|
/// <returns>The actual destination filename</returns>
|
||||||
public static string SaferMoveToValidPath(
|
public static LongPath SaferMoveToValidPath(
|
||||||
LongPath source,
|
LongPath source,
|
||||||
LongPath destination,
|
LongPath destination,
|
||||||
ReplacementCharacters replacements,
|
ReplacementCharacters replacements,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ namespace FileManager
|
|||||||
Replacement.OtherQuote("""),
|
Replacement.OtherQuote("""),
|
||||||
Replacement.OpenAngleBracket("<"),
|
Replacement.OpenAngleBracket("<"),
|
||||||
Replacement.CloseAngleBracket(">"),
|
Replacement.CloseAngleBracket(">"),
|
||||||
Replacement.Colon("꞉"),
|
Replacement.Colon("_"),
|
||||||
Replacement.Asterisk("✱"),
|
Replacement.Asterisk("✱"),
|
||||||
Replacement.QuestionMark("?"),
|
Replacement.QuestionMark("?"),
|
||||||
Replacement.Pipe("⏐"),
|
Replacement.Pipe("⏐"),
|
||||||
|
|||||||
@@ -6,7 +6,30 @@
|
|||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme/>
|
<FluentTheme>
|
||||||
</Application.Styles>
|
<FluentTheme.Palettes>
|
||||||
|
<ColorPaletteResources x:Key="Light" />
|
||||||
|
<ColorPaletteResources x:Key="Dark" />
|
||||||
|
</FluentTheme.Palettes>
|
||||||
|
</FluentTheme>
|
||||||
|
|
||||||
|
<Style Selector="TextBox[IsReadOnly=true]">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
|
||||||
|
<Style Selector="^ /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||||
|
<Style Selector="^">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
|
||||||
|
</Style>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="ScrollBar">
|
||||||
|
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
|
||||||
|
<Setter Property="AllowAutoHide" Value="false"/>
|
||||||
|
</Style>
|
||||||
|
</Application.Styles>
|
||||||
</Application>
|
</Application>
|
||||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 36 KiB |
@@ -4,27 +4,16 @@
|
|||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||||
x:Class="HangoverAvalonia.Controls.CheckedListBox">
|
x:Class="HangoverAvalonia.Controls.CheckedListBox">
|
||||||
|
|
||||||
<UserControl.Resources>
|
|
||||||
<RecyclePool x:Key="RecyclePool" />
|
|
||||||
<DataTemplate x:Key="queuedBook">
|
|
||||||
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
|
||||||
</DataTemplate>
|
|
||||||
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
|
|
||||||
<RecyclingElementFactory.Templates>
|
|
||||||
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
|
|
||||||
</RecyclingElementFactory.Templates>
|
|
||||||
</RecyclingElementFactory>
|
|
||||||
</UserControl.Resources>
|
|
||||||
|
|
||||||
<ScrollViewer
|
<ScrollViewer
|
||||||
Name="scroller"
|
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
HorizontalScrollBarVisibility="Disabled"
|
||||||
VerticalScrollBarVisibility="Auto">
|
VerticalScrollBarVisibility="Auto">
|
||||||
<ItemsRepeater IsVisible="True"
|
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
|
||||||
VerticalCacheLength="1.2"
|
<ItemsControl.ItemTemplate>
|
||||||
HorizontalCacheLength="1"
|
<DataTemplate>
|
||||||
ItemsSource="{Binding CheckboxItems}"
|
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||||
ItemTemplate="{StaticResource elementFactory}" />
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -2,103 +2,18 @@ using Avalonia;
|
|||||||
using Avalonia.Collections;
|
using Avalonia.Collections;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using HangoverAvalonia.ViewModels;
|
using HangoverAvalonia.ViewModels;
|
||||||
using ReactiveUI;
|
|
||||||
using System;
|
|
||||||
using System.Collections;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace HangoverAvalonia.Controls
|
namespace HangoverAvalonia.Controls;
|
||||||
|
|
||||||
|
public partial class CheckedListBox : UserControl
|
||||||
{
|
{
|
||||||
public partial class CheckedListBox : UserControl
|
public static readonly StyledProperty<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
|
||||||
|
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
|
||||||
|
|
||||||
|
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
|
||||||
|
|
||||||
|
public CheckedListBox()
|
||||||
{
|
{
|
||||||
public event EventHandler<ItemCheckEventArgs> ItemCheck;
|
InitializeComponent();
|
||||||
|
|
||||||
public static readonly StyledProperty<IEnumerable> ItemsProperty =
|
|
||||||
AvaloniaProperty.Register<CheckedListBox, IEnumerable>(nameof(Items));
|
|
||||||
|
|
||||||
public IEnumerable Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
|
|
||||||
private CheckedListBoxViewModel _viewModel = new();
|
|
||||||
|
|
||||||
public IEnumerable<object> CheckedItems =>
|
|
||||||
_viewModel
|
|
||||||
.CheckboxItems
|
|
||||||
.Where(i => i.IsChecked)
|
|
||||||
.Select(i => i.Item);
|
|
||||||
|
|
||||||
public void SetItemChecked(int i, bool isChecked) => _viewModel.CheckboxItems[i].IsChecked = isChecked;
|
|
||||||
public void SetItemChecked(object item, bool isChecked)
|
|
||||||
{
|
|
||||||
var obj = _viewModel.CheckboxItems.SingleOrDefault(i => i.Item == item);
|
|
||||||
if (obj is not null)
|
|
||||||
obj.IsChecked = isChecked;
|
|
||||||
}
|
|
||||||
|
|
||||||
public CheckedListBox()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
scroller.DataContext = _viewModel;
|
|
||||||
_viewModel.CheckedChanged += _viewModel_CheckedChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void _viewModel_CheckedChanged(object sender, CheckBoxViewModel e)
|
|
||||||
{
|
|
||||||
var args = new ItemCheckEventArgs { Item = e.Item, ItemIndex = _viewModel.CheckboxItems.IndexOf(e), IsChecked = e.IsChecked };
|
|
||||||
ItemCheck?.Invoke(this, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
|
||||||
{
|
|
||||||
if (change.Property.Name == nameof(Items) && Items != null)
|
|
||||||
_viewModel.SetItems(Items);
|
|
||||||
base.OnPropertyChanged(change);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class CheckedListBoxViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
public event EventHandler<CheckBoxViewModel> CheckedChanged;
|
|
||||||
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get; private set; }
|
|
||||||
|
|
||||||
public void SetItems(IEnumerable items)
|
|
||||||
{
|
|
||||||
UnsubscribeFromItems(CheckboxItems);
|
|
||||||
CheckboxItems = new(items.OfType<object>().Select(o => new CheckBoxViewModel { Item = o }));
|
|
||||||
SubscribeToItems(CheckboxItems);
|
|
||||||
this.RaisePropertyChanged(nameof(CheckboxItems));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SubscribeToItems(IEnumerable objects)
|
|
||||||
{
|
|
||||||
foreach (var i in objects.OfType<INotifyPropertyChanged>())
|
|
||||||
i.PropertyChanged += I_PropertyChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void UnsubscribeFromItems(AvaloniaList<CheckBoxViewModel> objects)
|
|
||||||
{
|
|
||||||
if (objects is null) return;
|
|
||||||
|
|
||||||
foreach (var i in objects)
|
|
||||||
i.PropertyChanged -= I_PropertyChanged;
|
|
||||||
}
|
|
||||||
private void I_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
CheckedChanged?.Invoke(this, (CheckBoxViewModel)sender);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public class CheckBoxViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
private bool _isChecked;
|
|
||||||
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
|
|
||||||
private object _bookText;
|
|
||||||
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ItemCheckEventArgs : EventArgs
|
|
||||||
{
|
|
||||||
public int ItemIndex { get; init; }
|
|
||||||
public bool IsChecked { get; init; }
|
|
||||||
public object Item { get; init; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
|
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
|
||||||
<TrimMode>copyused</TrimMode>
|
<TrimMode>copyused</TrimMode>
|
||||||
@@ -71,13 +71,12 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
||||||
<PackageReference Include="Avalonia" Version="11.0.5" />
|
<PackageReference Include="Avalonia" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
|
||||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.5" />
|
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.5" />
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.5" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.5" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
|||||||
<Platform>Any CPU</Platform>
|
<Platform>Any CPU</Platform>
|
||||||
<PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir>
|
<PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir>
|
||||||
<PublishProtocol>FileSystem</PublishProtocol>
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
<PublishSingleFile>false</PublishSingleFile>
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
|||||||
<Platform>Any CPU</Platform>
|
<Platform>Any CPU</Platform>
|
||||||
<PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir>
|
<PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir>
|
||||||
<PublishProtocol>FileSystem</PublishProtocol>
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
<PublishSingleFile>false</PublishSingleFile>
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
|||||||
<Platform>Any CPU</Platform>
|
<Platform>Any CPU</Platform>
|
||||||
<PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir>
|
<PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir>
|
||||||
<PublishProtocol>FileSystem</PublishProtocol>
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
<PublishSingleFile>false</PublishSingleFile>
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using ReactiveUI;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.ViewModels;
|
||||||
|
|
||||||
|
public class CheckBoxViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
private bool _isChecked;
|
||||||
|
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
|
||||||
|
private object _bookText;
|
||||||
|
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
|
||||||
|
}
|
||||||
@@ -1,41 +1,8 @@
|
|||||||
using ApplicationServices;
|
namespace HangoverAvalonia.ViewModels;
|
||||||
using DataLayer;
|
|
||||||
using ReactiveUI;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace HangoverAvalonia.ViewModels
|
public partial class MainVM
|
||||||
{
|
{
|
||||||
public partial class MainVM
|
public TrashBinViewModel TrashBinViewModel { get; } = new();
|
||||||
{
|
|
||||||
private List<LibraryBook> _deletedBooks;
|
|
||||||
public List<LibraryBook> DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); }
|
|
||||||
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
|
|
||||||
|
|
||||||
private int _totalBooksCount = 0;
|
private void Load_deletedVM() { }
|
||||||
private int _checkedBooksCount = 0;
|
|
||||||
public int CheckedBooksCount
|
|
||||||
{
|
|
||||||
get => _checkedBooksCount;
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (_checkedBooksCount != value)
|
|
||||||
{
|
|
||||||
_checkedBooksCount = value;
|
|
||||||
this.RaisePropertyChanged(nameof(CheckedCountText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private void Load_deletedVM()
|
|
||||||
{
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void reload()
|
|
||||||
{
|
|
||||||
DeletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
|
||||||
_checkedBooksCount = 0;
|
|
||||||
_totalBooksCount = DeletedBooks.Count;
|
|
||||||
this.RaisePropertyChanged(nameof(CheckedCountText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using ApplicationServices;
|
||||||
|
using Avalonia.Collections;
|
||||||
|
using DataLayer;
|
||||||
|
using ReactiveUI;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace HangoverAvalonia.ViewModels;
|
||||||
|
|
||||||
|
public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
|
||||||
|
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
|
||||||
|
|
||||||
|
private bool _controlsEnabled = true;
|
||||||
|
public bool ControlsEnabled { get => _controlsEnabled; set => this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
|
||||||
|
|
||||||
|
private bool? everythingChecked = false;
|
||||||
|
public bool? EverythingChecked
|
||||||
|
{
|
||||||
|
get => everythingChecked;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
everythingChecked = value ?? false;
|
||||||
|
|
||||||
|
if (everythingChecked is true)
|
||||||
|
CheckAll();
|
||||||
|
else if (everythingChecked is false)
|
||||||
|
UncheckAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _totalBooksCount = 0;
|
||||||
|
private int _checkedBooksCount = -1;
|
||||||
|
public int CheckedBooksCount
|
||||||
|
{
|
||||||
|
get => _checkedBooksCount;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_checkedBooksCount = value;
|
||||||
|
this.RaisePropertyChanged(nameof(CheckedCountText));
|
||||||
|
|
||||||
|
everythingChecked
|
||||||
|
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
|
||||||
|
: _checkedBooksCount == _totalBooksCount ? true
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.RaisePropertyChanged(nameof(EverythingChecked));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
|
||||||
|
|
||||||
|
public TrashBinViewModel()
|
||||||
|
{
|
||||||
|
DeletedBooks = new()
|
||||||
|
{
|
||||||
|
ResetBehavior = ResetBehavior.Remove
|
||||||
|
};
|
||||||
|
|
||||||
|
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
|
||||||
|
Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CheckAll()
|
||||||
|
{
|
||||||
|
foreach (var item in DeletedBooks)
|
||||||
|
item.IsChecked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UncheckAll()
|
||||||
|
{
|
||||||
|
foreach (var item in DeletedBooks)
|
||||||
|
item.IsChecked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RestoreCheckedAsync()
|
||||||
|
{
|
||||||
|
ControlsEnabled = false;
|
||||||
|
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
Reload();
|
||||||
|
ControlsEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PermanentlyDeleteCheckedAsync()
|
||||||
|
{
|
||||||
|
ControlsEnabled = false;
|
||||||
|
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
|
||||||
|
if (qtyChanges > 0)
|
||||||
|
Reload();
|
||||||
|
ControlsEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
||||||
|
|
||||||
|
DeletedBooks.Clear();
|
||||||
|
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
|
||||||
|
|
||||||
|
_totalBooksCount = DeletedBooks.Count;
|
||||||
|
CheckedBooksCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IDisposable tracker;
|
||||||
|
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
|
||||||
|
{
|
||||||
|
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
|
||||||
|
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => tracker?.Dispose();
|
||||||
|
}
|
||||||
@@ -1,40 +1,12 @@
|
|||||||
using ApplicationServices;
|
namespace HangoverAvalonia.Views;
|
||||||
using DataLayer;
|
|
||||||
using HangoverAvalonia.Controls;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace HangoverAvalonia.Views
|
public partial class MainWindow
|
||||||
{
|
{
|
||||||
public partial class MainWindow
|
private void deletedTab_VisibleChanged(bool isVisible)
|
||||||
{
|
{
|
||||||
private void deletedTab_VisibleChanged(bool isVisible)
|
if (!isVisible)
|
||||||
{
|
return;
|
||||||
if (!isVisible)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (_viewModel.DeletedBooks.Count == 0)
|
_viewModel.TrashBinViewModel.Reload();
|
||||||
_viewModel.reload();
|
|
||||||
}
|
|
||||||
public void Deleted_CheckedListBox_ItemCheck(object sender, ItemCheckEventArgs args)
|
|
||||||
{
|
|
||||||
_viewModel.CheckedBooksCount = deletedCbl.CheckedItems.Count();
|
|
||||||
}
|
|
||||||
public void Deleted_CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
foreach (var item in deletedCbl.Items)
|
|
||||||
deletedCbl.SetItemChecked(item, true);
|
|
||||||
}
|
|
||||||
public void Deleted_UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
foreach (var item in deletedCbl.Items)
|
|
||||||
deletedCbl.SetItemChecked(item, false);
|
|
||||||
}
|
|
||||||
public void Deleted_Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
|
||||||
var qtyChanges = libraryBooksToRestore.RestoreBooks();
|
|
||||||
if (qtyChanges > 0)
|
|
||||||
_viewModel.reload();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,13 +15,11 @@
|
|||||||
|
|
||||||
<TabControl Name="tabControl1" Grid.Row="0">
|
<TabControl Name="tabControl1" Grid.Row="0">
|
||||||
<TabControl.Styles>
|
<TabControl.Styles>
|
||||||
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
|
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
|
||||||
<Setter Property="Height" Value="23"/>
|
<Setter Property="Height" Value="33"/>
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem">
|
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
|
||||||
<Setter Property="MinHeight" Value="40"/>
|
<Setter Property="Height" Value="33"/>
|
||||||
<Setter Property="Height" Value="40"/>
|
|
||||||
<Setter Property="Padding" Value="8,2,8,5"/>
|
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="TabItem#Header TextBlock">
|
<Style Selector="TabItem#Header TextBlock">
|
||||||
<Setter Property="MinHeight" Value="5"/>
|
<Setter Property="MinHeight" Value="5"/>
|
||||||
@@ -51,6 +49,7 @@
|
|||||||
|
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="0,5,0,5"
|
Margin="0,5,0,5"
|
||||||
|
AcceptsReturn="True"
|
||||||
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -73,33 +72,58 @@
|
|||||||
<TabItem.Header>
|
<TabItem.Header>
|
||||||
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
|
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
|
||||||
</TabItem.Header>
|
</TabItem.Header>
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
|
DataContext="{Binding TrashBinViewModel}"
|
||||||
RowDefinitions="Auto,*,Auto">
|
RowDefinitions="Auto,*,Auto">
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Margin="5"
|
Margin="5"
|
||||||
Text="To restore deleted book, check box and save" />
|
Text="Check books you want to permanently delete from or restore to Libation" />
|
||||||
|
|
||||||
<controls:CheckedListBox
|
<controls:CheckedListBox
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Margin="5,0,5,0"
|
Margin="5,0,5,0"
|
||||||
BorderThickness="1"
|
BorderThickness="1"
|
||||||
BorderBrush="Gray"
|
BorderBrush="Gray"
|
||||||
Name="deletedCbl"
|
IsEnabled="{Binding ControlsEnabled}"
|
||||||
Items="{Binding DeletedBooks}" />
|
Items="{Binding DeletedBooks}" />
|
||||||
|
|
||||||
<Grid
|
<Grid
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
Margin="5"
|
Margin="5"
|
||||||
ColumnDefinitions="Auto,Auto,Auto,*">
|
ColumnDefinitions="Auto,Auto,*,Auto">
|
||||||
|
|
||||||
<Button Grid.Column="0" Margin="0,0,20,0" Content="Check All" Click="Deleted_CheckAll_Click" />
|
<CheckBox
|
||||||
<Button Grid.Column="1" Margin="0,0,20,0" Content="Uncheck All" Click="Deleted_UncheckAll_Click" />
|
IsEnabled="{Binding ControlsEnabled}"
|
||||||
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding CheckedCountText}" />
|
IsThreeState="True"
|
||||||
<Button Grid.Column="3" HorizontalAlignment="Right" Content="Save" Click="Deleted_Save_Click" />
|
Margin="0,0,20,0"
|
||||||
|
IsChecked="{Binding EverythingChecked}"
|
||||||
|
Content="Everything" />
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="1"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{Binding CheckedCountText}" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
IsEnabled="{Binding ControlsEnabled}"
|
||||||
|
Grid.Column="2"
|
||||||
|
Margin="0,0,20,0"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Stretch"
|
||||||
|
VerticalContentAlignment="Center"
|
||||||
|
Content="Restore"
|
||||||
|
Command="{Binding RestoreCheckedAsync}"/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
IsEnabled="{Binding ControlsEnabled}"
|
||||||
|
Grid.Column="3"
|
||||||
|
Command="{Binding PermanentlyDeleteCheckedAsync}" >
|
||||||
|
<TextBlock
|
||||||
|
TextAlignment="Center"
|
||||||
|
Text="Permanently Delete
from Libation" />
|
||||||
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ namespace HangoverAvalonia.Views
|
|||||||
|
|
||||||
public void OnLoad()
|
public void OnLoad()
|
||||||
{
|
{
|
||||||
deletedCbl.ItemCheck += Deleted_CheckedListBox_ItemCheck;
|
|
||||||
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
|
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
|
||||||
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
|
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
|
||||||
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
|
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 36 KiB |
@@ -1,7 +1,7 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net8.0-windows7.0</TargetFramework>
|
<TargetFramework>net9.0-windows7.0</TargetFramework>
|
||||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
<AssemblyName>Hangover</AssemblyName>
|
<AssemblyName>Hangover</AssemblyName>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
|||||||
<Platform>Any CPU</Platform>
|
<Platform>Any CPU</Platform>
|
||||||
<PublishDir>..\bin\Publish\classic</PublishDir>
|
<PublishDir>..\bin\Publish\classic</PublishDir>
|
||||||
<PublishProtocol>FileSystem</PublishProtocol>
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
<TargetFramework>net8.0-windows</TargetFramework>
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
<SelfContained>true</SelfContained>
|
<SelfContained>true</SelfContained>
|
||||||
<PublishSingleFile>false</PublishSingleFile>
|
<PublishSingleFile>false</PublishSingleFile>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 498 B After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 754 B |
|
After Width: | Height: | Size: 929 B |