Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
5
.cdmurls.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"CdmUrls": [
|
||||
"https://ollj0gz40d.execute-api.us-west-2.amazonaws.com/default/AudibleCdm"
|
||||
]
|
||||
}
|
||||
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -6,10 +6,10 @@ labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
@@ -17,15 +17,14 @@ Steps to reproduce the behavior:
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Platform**
|
||||
|
||||
**Platform**
|
||||
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
|
||||
|
||||
**Log Files**
|
||||
Attach your Libation log file here.
|
||||
**Log Files**
|
||||
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'
|
||||
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -6,14 +6,26 @@ labels: enhancement
|
||||
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 [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
**Describe the solution you'd like**
|
||||
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.
|
||||
|
||||
**Additional context**
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
8
.github/workflows/build-windows.yml
vendored
@@ -89,14 +89,6 @@ jobs:
|
||||
run: |
|
||||
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
|
||||
$delfiles = @(
|
||||
"libmp3lame.x64.so",
|
||||
"libmp3lame.arm64.so",
|
||||
"libmp3lame.x64.dylib",
|
||||
"libmp3lame.arm64.dylib",
|
||||
"ffmpegaac.x64.so",
|
||||
"ffmpegaac.arm64.so",
|
||||
"ffmpegaac.x64.dylib",
|
||||
"ffmpegaac.arm64.dylib",
|
||||
"WindowsConfigApp.exe",
|
||||
"WindowsConfigApp.runtimeconfig.json",
|
||||
"WindowsConfigApp.deps.json"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- [Settings](#settings)
|
||||
- [Custom File Naming](NamingTemplates.md)
|
||||
- [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 -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)
|
||||
|
||||
|
||||
|
||||
@@ -69,3 +69,8 @@ If the user it's running as is correct, and it still cannot write, be sure to ch
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -33,7 +33,7 @@ Self-hosting online:
|
||||
|
||||
## 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. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
|
||||
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?
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ Run this command in your terminal to download and install Libation, replacing th
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||
sudo yum install ./libation.rpm
|
||||
```
|
||||
### Fedora
|
||||
```Console
|
||||
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
|
||||
sudo dnf5 install ./libation.rpm
|
||||
```
|
||||
---
|
||||
### Arch Linux
|
||||
```Console
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
# Run Libation on MacOS
|
||||
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
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ These templates apply to both GUI and CLI.
|
||||
- [Conditional Tags](#conditional-tags)
|
||||
- [Tag Formatters](#tag-formatters)
|
||||
- [Text Formatters](#text-formatters)
|
||||
- [Series Formatters](#series-formatters)
|
||||
- [Series List Formatters](#series-list-formatters)
|
||||
- [Name Formatters](#name-formatters)
|
||||
- [Name List Formatters](#name-list-formatters)
|
||||
- [Number Formatters](#number-formatters)
|
||||
- [Date Formatters](#date-formatters)
|
||||
@@ -32,32 +35,33 @@ These tags will be replaced in the template with the audiobook's values.
|
||||
|Tag|Description|Type|
|
||||
|-|-|-|
|
||||
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||
|\<title\>|Full title with subtitle|Text|
|
||||
|\<title short\>|Title. Stop at first colon|Text|
|
||||
|\<audible title\>|Audible's title (does not include subtitle)|Text|
|
||||
|\<audible subtitle\>|Audible's subtitle|Text|
|
||||
|\<author\>|Author(s)|Name List|
|
||||
|\<first author\>|First author|Text|
|
||||
|\<narrator\>|Narrator(s)|Name List|
|
||||
|\<first narrator\>|First narrator|Text|
|
||||
|\<series\>|Name of series|Text|
|
||||
|\<series#\>|Number order in series|Number|
|
||||
|\<bitrate\>|File's original bitrate (Kbps)|Number|
|
||||
|\<samplerate\>|File's original audio sample rate|Number|
|
||||
|\<channels\>|Number of audio channels|Number|
|
||||
|\<account\>|Audible account of this book|Text|
|
||||
|\<account nickname\>|Audible account nickname of this book|Text|
|
||||
|\<locale\>|Region/country|Text|
|
||||
|\<year\>|Year published|Number|
|
||||
|\<language\>|Book's language|Text|
|
||||
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|
||||
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|
||||
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|
||||
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|
||||
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|
||||
|\<first author\>|First author|[Name](#name-formatters)|
|
||||
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|
||||
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|
||||
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|
||||
|\<first series\>|First series|[Series](#series-formatters)|
|
||||
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|
||||
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|
||||
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|
||||
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|
||||
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|
||||
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|
||||
|\<locale\>|Region/country|[Text](#text-formatters)|
|
||||
|\<year\>|Year published|[Number](#number-formatters)|
|
||||
|\<language\>|Book's language|[Text](#text-formatters)|
|
||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||
|\<file date\>|File creation date/time.|DateTime|
|
||||
|\<pub date\>|Audiobook publication date|DateTime|
|
||||
|\<date added\>|Date the book added to your Audible account|DateTime|
|
||||
|\<ch count\> **‡**|Number of chapters|Number|
|
||||
|\<ch title\> **‡**|Chapter title|Text|
|
||||
|\<ch#\> **‡**|Chapter number|Number|
|
||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number|
|
||||
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|
||||
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|
||||
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|
||||
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|
||||
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|
||||
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|
||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
|
||||
|
||||
**†** Does not support custom formatting
|
||||
|
||||
@@ -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|
|
||||
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
|
||||
|
||||
## Series Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1, B08376S3R2|
|
||||
|
||||
## Series List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|separator()|Speficy the text used to join<br>multiple series names.<br><br>Default is ", "|`<series[separator(; )]>`|Sherlock Holmes; Some Other Series|
|
||||
|format(\{N \| # \| ID\})|Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above.|`<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<author[format({L}, {ID}) separator(; )]>`|Sherlock Holmes, 1; Some Other Series, 1<hr>herlock Holmes, B08376S3R2; Some Other Series, B000000000|
|
||||
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
|
||||
|
||||
## Name Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\{T \| F \| M \| L \| S \| ID\}|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|
||||
|
||||
## Name List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||
|format(\{T \| F \| M \| L \| S\})|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|
|
||||
|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>
|
||||
<g id="glass">
|
||||
<path fill-rule="evenodd" d=
|
||||
"M262,8
|
||||
h-117
|
||||
a 192,200 0 0 0 -36,82
|
||||
a 222,334 41 0 0 138,236
|
||||
v158
|
||||
h-81
|
||||
a 16,16 0 0 0 0,32
|
||||
h192
|
||||
a 16 16 0 0 0 0,-32
|
||||
h-81
|
||||
v-158
|
||||
a 222,334 -41 0 0 138,-236
|
||||
a 192,200 0 0 0 -36,-82
|
||||
h-117
|
||||
m-99,30
|
||||
a 192,200 0 0 0 -26,95
|
||||
a 187.5,334 35 0 0 125,159
|
||||
a 187.5,334 -35 0 0 125,-159
|
||||
a 192,200 0 0 0 -26,-95
|
||||
h-198
|
||||
<path transform="translate(16 16)" fill-rule="evenodd" d=
|
||||
"M177,16
|
||||
H79
|
||||
A 32.0781 63.7932 -1.5106 0 0 66 80
|
||||
A 158.789 471.1259 41.9466 0 0 90 131
|
||||
A 81.7197 122.0515 35.3745 0 0 128 143.3484
|
||||
A 81.7197 122.0515 -35.3745 0 0 166 131
|
||||
A 158.789 471.1259 -41.9466 0 0 190 80
|
||||
A 32.0781 63.7932 1.5106 0 0 177 16
|
||||
L 184 0
|
||||
A 44.7901 78.5247 1.1521 0 1 194 122
|
||||
A 97.0039 135.3148 -36.2124 0 1 136 159
|
||||
V 240
|
||||
H 176
|
||||
A 8 8 0 0 1 176 256
|
||||
H 80
|
||||
A 8 8 0 0 1 80 240
|
||||
H 120
|
||||
V 159
|
||||
A 97.0039 135.3148 36.2124 0 1 62 122
|
||||
A 44.7901 78.5247 -1.1521 0 1 72 0
|
||||
H184
|
||||
z"/>
|
||||
</g>
|
||||
<g id="wine-level">
|
||||
<g transform="translate(16 16)" id="wine-level">
|
||||
<path d=
|
||||
"M158,136
|
||||
a 168,305 35 0 0 104,136
|
||||
a 168,305 -35 0 0 104,-136
|
||||
"M182,64
|
||||
H 74
|
||||
A 115.9979 308.8033 38.9474 0 0 128 134.4277
|
||||
A 115.9979 308.8033 -38.9474 0 0 182,64
|
||||
z"/>
|
||||
</g>
|
||||
</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">
|
||||
|
||||
<g transform="translate(0 80) rotate(90 256,256)">
|
||||
<path id="glass" d=
|
||||
"M139,2
|
||||
A 192,200 0 0 0 103,84
|
||||
A 222,334 41 0 0 241,320
|
||||
V478
|
||||
H160
|
||||
A 16,16 0 0 0 160,510
|
||||
H352
|
||||
A16 16 0 0 0 352,478
|
||||
H271
|
||||
V320
|
||||
A 222,334 -41 0 0 409,84
|
||||
A 192,200 0 0 0 373,2
|
||||
M355,32
|
||||
A 192,200 0 0 1 381,127
|
||||
A 187.5,334 -35 0 1 256,286
|
||||
A 187.5,334 35 0 1 131,127
|
||||
A 192,200 0 0 1 157,32
|
||||
H355
|
||||
z" />
|
||||
<path id="wine-level" d=
|
||||
"M345,44
|
||||
A 192,184 0 0 1 366,126
|
||||
A 320,180 55 0 1 345,226
|
||||
z"/>
|
||||
</g>
|
||||
<svg 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>
|
||||
<path transform="rotate(90 128,128) translate(60 -16)" fill-rule="evenodd" d=
|
||||
"M177,16
|
||||
H79
|
||||
A 32.0781 63.7932 -1.5106 0 0 66 80
|
||||
A 158.789 471.1259 41.9466 0 0 90 131
|
||||
A 81.7197 122.0515 35.3745 0 0 128 143.3484
|
||||
A 81.7197 122.0515 -35.3745 0 0 166 131
|
||||
A 158.789 471.1259 -41.9466 0 0 190 80
|
||||
A 32.0781 63.7932 1.5106 0 0 177 16
|
||||
L 184 0
|
||||
A 44.7901 78.5247 1.1521 0 1 194 122
|
||||
A 97.0039 135.3148 -36.2124 0 1 136 159
|
||||
V 240
|
||||
H 176
|
||||
A 8 8 0 0 1 176 256
|
||||
H 80
|
||||
A 8 8 0 0 1 80 240
|
||||
H 120
|
||||
V 159
|
||||
A 97.0039 135.3148 36.2124 0 1 62 122
|
||||
A 44.7901 78.5247 -1.1521 0 1 72 0
|
||||
H184
|
||||
M170,115
|
||||
V24
|
||||
A 19.5181 45.9183 -3.3549 0 1 182.4322 69.5
|
||||
A 19.5181 45.9183 3.3549 0 1 170 115
|
||||
z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 638 B After Width: | Height: | Size: 936 B |
@@ -32,6 +32,7 @@
|
||||
- [Settings](Documentation/Advanced.md#settings)
|
||||
- [Custom File Naming](Documentation/NamingTemplates.md)
|
||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
|
||||
- [Docker](Documentation/Docker.md)
|
||||
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
|
||||
|
||||
|
||||
@@ -53,13 +53,7 @@ if [ $? -ne 0 ]
|
||||
fi
|
||||
|
||||
|
||||
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
if [[ "$ARCH" == "arm64" ]]
|
||||
then
|
||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
||||
else
|
||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
||||
fi
|
||||
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
|
||||
for n in "${delfiles[@]}"
|
||||
do
|
||||
|
||||
@@ -82,18 +82,7 @@ echo "Set CFBundleVersion to $VERSION"
|
||||
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
||||
|
||||
|
||||
delfiles=( 'libmp3lame.arm64.so' 'libmp3lame.x64.so' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.so' 'ffmpegaac.x64.so' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||
if [[ "$ARCH" == "arm64" ]]
|
||||
then
|
||||
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
|
||||
mv $BUNDLE_MACOS/ffmpegaac.arm64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
|
||||
mv $BUNDLE_MACOS/libmp3lame.arm64.dylib $BUNDLE_MACOS/libmp3lame.dylib
|
||||
else
|
||||
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
|
||||
mv $BUNDLE_MACOS/ffmpegaac.x64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
|
||||
mv $BUNDLE_MACOS/libmp3lame.x64.dylib $BUNDLE_MACOS/libmp3lame.dylib
|
||||
fi
|
||||
|
||||
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||
|
||||
for n in "${delfiles[@]}"
|
||||
do
|
||||
|
||||
@@ -38,14 +38,12 @@ fi
|
||||
|
||||
BASEDIR=$(pwd)
|
||||
|
||||
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
|
||||
if [[ "$ARCH" == "x64" ]]
|
||||
then
|
||||
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
|
||||
ARCH_RPM="x86_64"
|
||||
ARCH="amd64"
|
||||
else
|
||||
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
|
||||
ARCH_RPM="aarch64"
|
||||
fi
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="1.1.4" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="2.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||
|
||||
protected AaxFile AaxFile { get; private set; }
|
||||
protected Mp4File AaxFile { get; private set; }
|
||||
protected Mp4Operation AaxConversion { get; set; }
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
@@ -29,14 +29,34 @@ namespace AaxDecrypter
|
||||
FinalizeDownload();
|
||||
}
|
||||
|
||||
private Mp4File Open()
|
||||
{
|
||||
if (DownloadOptions.InputType is FileType.Dash)
|
||||
{
|
||||
var dash = new DashFile(InputFileStream);
|
||||
dash.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
return dash;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aax)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||
return aax;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
return aax;
|
||||
}
|
||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
AaxFile = new AaxFile(InputFileStream);
|
||||
AaxFile = Open();
|
||||
|
||||
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null)
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||
else
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||
|
||||
if (DownloadOptions.StripUnabridged)
|
||||
{
|
||||
@@ -87,8 +107,6 @@ namespace AaxDecrypter
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
||||
DownloadOptions.LameConfig
|
||||
);
|
||||
|
||||
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
|
||||
{
|
||||
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);
|
||||
}
|
||||
else return Mp4Operation.CompletedOperation;
|
||||
else return Mp4Operation.FromCompleted(AaxFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace AaxDecrypter
|
||||
public bool IsCanceled { get; protected set; }
|
||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||
protected string OutputFileName { get; }
|
||||
protected IDownloadOptions DownloadOptions { get; }
|
||||
public IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||
private bool downloadFinished;
|
||||
@@ -178,19 +178,33 @@ namespace AaxDecrypter
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
|
||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||
DownloadOptions.RetainEncryptedFile)
|
||||
DownloadOptions.RetainEncryptedFile &&
|
||||
DownloadOptions.InputType is AAXClean.FileType fileType)
|
||||
{
|
||||
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
//Write aax decryption key
|
||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||
string keyPath = Path.ChangeExtension(tempFilePath, ".key");
|
||||
FileUtility.SaferDelete(keyPath);
|
||||
string aaxPath;
|
||||
|
||||
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
|
||||
if (fileType is AAXClean.FileType.Aax)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
||||
else
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Aaxc)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Dash)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"KeyId={DownloadOptions.AudibleKey}{Environment.NewLine}Key={DownloadOptions.AudibleIV}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"Unknown file type: {fileType}");
|
||||
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
@@ -217,6 +231,7 @@ namespace AaxDecrypter
|
||||
}
|
||||
catch
|
||||
{
|
||||
nfsp?.Target?.Dispose();
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
return nfsp = newNetworkFilePersister();
|
||||
|
||||
@@ -105,7 +105,7 @@ public class AverageSpeed
|
||||
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
||||
|
||||
/// <param name="slowWindow">Total moving average time window</param>
|
||||
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||
/// <param name="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="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)
|
||||
@@ -119,7 +119,7 @@ public class AverageSpeed
|
||||
/// <summary>Add a new position to the moving average</summary>
|
||||
public void AddPosition(double position)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var now = DateTime.UtcNow;
|
||||
if (start == default)
|
||||
start = now;
|
||||
|
||||
|
||||
@@ -35,5 +35,6 @@ namespace AaxDecrypter
|
||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||
public FileType? InputType { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,18 +55,23 @@ namespace AaxDecrypter
|
||||
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||
private EventWaitHandle _downloadedPiece { get; set; }
|
||||
|
||||
private DateTime NextUpdateTime { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
//Download buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 32 * 1024;
|
||||
//Size of each range request. Android app uses 64MB chunks.
|
||||
private const int RANGE_REQUEST_SZ = 64 * 1024 * 1024;
|
||||
|
||||
//Download memory buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
||||
|
||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||
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;
|
||||
|
||||
//Minimum throttle rate. The minimum amount of data that can be throttled
|
||||
@@ -110,10 +115,14 @@ namespace AaxDecrypter
|
||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||
private void OnUpdate()
|
||||
{
|
||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||
try
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
if (DateTime.UtcNow > NextUpdateTime)
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
|
||||
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -135,7 +144,6 @@ namespace AaxDecrypter
|
||||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||
|
||||
Uri = uriToSameFile;
|
||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||
}
|
||||
|
||||
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||
@@ -151,39 +159,82 @@ namespace AaxDecrypter
|
||||
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
//Initiate connection with the first request block and
|
||||
//get the total content length before returning.
|
||||
using 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 open request to the downloader to download and write data to file.
|
||||
DownloadTask = Task.Run(() => DownloadLoopInternal(response), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
private async Task DownloadLoopInternal(BlockResponse initialResponse)
|
||||
{
|
||||
await DownloadToFile(initialResponse);
|
||||
initialResponse.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
using var response = await RequestNextByteRangeAsync(client);
|
||||
await DownloadToFile(response);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeFile.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||
|
||||
foreach (var header in RequestHeaders)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
var response = await new HttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||
request.Headers.Add("Range", $"bytes={WritePosition}-{WritePosition + RANGE_REQUEST_SZ - 1}");
|
||||
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
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
|
||||
//to the complete file length if requesting Range: bytes=0-
|
||||
if (WritePosition == 0)
|
||||
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
|
||||
var totalSize = response.Content.Headers.ContentRange?.Length ??
|
||||
throw new WebException("The response did not contain a total content length.");
|
||||
|
||||
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
var rangeSize = response.Content.Headers.ContentLength ??
|
||||
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>
|
||||
private async Task DownloadFile(Stream networkStream)
|
||||
private async Task DownloadToFile(BlockResponse block)
|
||||
{
|
||||
var endPosition = WritePosition + block.BlockSize;
|
||||
var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
|
||||
try
|
||||
{
|
||||
DateTime startTime = DateTime.Now;
|
||||
DateTime startTime = DateTime.UtcNow;
|
||||
long bytesReadSinceThrottle = 0;
|
||||
int bytesRead;
|
||||
do
|
||||
@@ -218,14 +269,15 @@ namespace AaxDecrypter
|
||||
|
||||
#endregion
|
||||
|
||||
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
||||
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
|
||||
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
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}).");
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
if (WritePosition > endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
@@ -235,7 +287,6 @@ namespace AaxDecrypter
|
||||
finally
|
||||
{
|
||||
networkStream.Close();
|
||||
_writeFile.Close();
|
||||
_downloadedPiece.Set();
|
||||
OnUpdate();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>12.0.2.1</Version>
|
||||
<Version>12.3.0.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||
|
||||
@@ -418,7 +418,6 @@ namespace AppScaffolding
|
||||
public List<string> Filters { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
public static void migrate_to_v12_0_1(Configuration config)
|
||||
{
|
||||
#nullable enable
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.0.1" />
|
||||
<PackageReference Include="NPOI" Version="2.7.2" />
|
||||
<PackageReference Include="NPOI" Version="2.7.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -15,12 +15,13 @@ using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
#nullable enable
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public static class LibraryCommands
|
||||
{
|
||||
public static event EventHandler<int> ScanBegin;
|
||||
public static event EventHandler<int> ScanEnd;
|
||||
public static event EventHandler<int>? ScanBegin;
|
||||
public static event EventHandler<int>? ScanEnd;
|
||||
|
||||
public static bool Scanning { get; private set; }
|
||||
private static object _lock { get; } = new();
|
||||
@@ -100,7 +101,7 @@ namespace ApplicationServices
|
||||
}
|
||||
|
||||
#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(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[]? accounts)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
@@ -232,13 +233,42 @@ namespace ApplicationServices
|
||||
}
|
||||
}
|
||||
|
||||
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(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
|
||||
await using LogArchiver archiver
|
||||
await using LogArchiver? archiver
|
||||
= 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;
|
||||
|
||||
archiver?.DeleteAllButNewestN(20);
|
||||
@@ -266,13 +296,13 @@ namespace ApplicationServices
|
||||
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));
|
||||
|
||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||
{
|
||||
Account = account?.MaskedLogEntry ?? "[null]"
|
||||
Account = account.MaskedLogEntry ?? "[null]"
|
||||
});
|
||||
|
||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||
@@ -294,7 +324,7 @@ namespace ApplicationServices
|
||||
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)
|
||||
{
|
||||
@@ -452,28 +482,28 @@ namespace ApplicationServices
|
||||
}
|
||||
|
||||
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
|
||||
public static event EventHandler<List<LibraryBook>> LibrarySizeChanged;
|
||||
public static event EventHandler<List<LibraryBook>>? LibrarySizeChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||
/// </summary>
|
||||
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
|
||||
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateUserDefinedItem(
|
||||
this LibraryBook lb,
|
||||
string tags = null,
|
||||
string? tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
Rating rating = null)
|
||||
Rating? rating = null)
|
||||
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||
|
||||
public static int UpdateUserDefinedItem(
|
||||
this IEnumerable<LibraryBook> lb,
|
||||
string tags = null,
|
||||
string? tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
Rating rating = null)
|
||||
Rating? rating = null)
|
||||
=> updateUserDefinedItem(
|
||||
lb,
|
||||
udi => {
|
||||
@@ -532,7 +562,8 @@ namespace ApplicationServices
|
||||
var udiEntity = context.Entry(book.Book.UserDefinedItem);
|
||||
|
||||
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
udiEntity.Reference(udi => udi.Rating).TargetEntry.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();
|
||||
@@ -598,7 +629,8 @@ namespace ApplicationServices
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
|
||||
|
||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
|
||||
{
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
|
||||
|
||||
@@ -47,6 +47,22 @@ namespace AudibleUtilities
|
||||
update_no_validate();
|
||||
}
|
||||
}
|
||||
|
||||
private string _cdm;
|
||||
[JsonProperty]
|
||||
public string Cdm
|
||||
{
|
||||
get => _cdm;
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
_cdm = value;
|
||||
update_no_validate();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
||||
#endregion
|
||||
|
||||
@@ -5,19 +5,58 @@ using Newtonsoft.Json;
|
||||
|
||||
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 string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
|
||||
|
||||
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
|
||||
|
||||
public static void EnsureAccountsSettingsFileExists()
|
||||
{
|
||||
// saves. BEWARE: this will overwrite an existing file
|
||||
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>
|
||||
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)
|
||||
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="9.3.0.1" />
|
||||
<PackageReference Include="AudibleApi" Version="9.4.0.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -20,4 +21,9 @@
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Widevine\Cdm.*.cs">
|
||||
<DependentUpon>Cdm.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
189
Source/AudibleUtilities/Widevine/Cdm.Api.cs
Normal file
@@ -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())) }
|
||||
};
|
||||
}
|
||||
}
|
||||
300
Source/AudibleUtilities/Widevine/Cdm.cs
Normal file
@@ -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()).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), 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
|
||||
}
|
||||
73
Source/AudibleUtilities/Widevine/Device.cs
Normal file
@@ -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);
|
||||
}
|
||||
15
Source/AudibleUtilities/Widevine/Extensions.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
14552
Source/AudibleUtilities/Widevine/LicenseProtocol.cs
Normal file
70
Source/AudibleUtilities/Widevine/MpegDash.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -43,5 +43,7 @@ namespace DataLayer
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
public void SetAudibleContributorId(string audibleContributorId)
|
||||
=> AudibleContributorId = audibleContributorId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,19 +61,19 @@ namespace DtoImporterService
|
||||
|
||||
private int upsertPeople(List<Person> people)
|
||||
{
|
||||
var hash = people
|
||||
// new people only
|
||||
.Where(p => !Cache.ContainsKey(p.Name))
|
||||
// remove duplicates by Name. first in wins
|
||||
.ToDictionarySafe(p => p.Name);
|
||||
|
||||
foreach (var kvp in hash)
|
||||
var qtyNew = 0;
|
||||
foreach (var person in people)
|
||||
{
|
||||
var person = kvp.Value;
|
||||
addContributor(person.Name, person.Asin);
|
||||
if (!Cache.TryGetValue(person.Name, out var contributor))
|
||||
{
|
||||
contributor = createContributor(person.Name, person.Asin);
|
||||
qtyNew++;
|
||||
}
|
||||
|
||||
updateContributor(person, contributor);
|
||||
}
|
||||
|
||||
return hash.Count;
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
// only use after loading contributors => local
|
||||
@@ -86,16 +86,22 @@ namespace DtoImporterService
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var pub in hash)
|
||||
addContributor(pub);
|
||||
createContributor(pub);
|
||||
|
||||
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
|
||||
{
|
||||
var newContrib = new Contributor(name);
|
||||
var newContrib = new Contributor(name, id);
|
||||
|
||||
var entityEntry = DbContext.Contributors.Add(newContrib);
|
||||
var entity = entityEntry.Entity;
|
||||
|
||||
@@ -62,7 +62,7 @@ namespace DtoImporterService
|
||||
existing.SetAccount(item.AccountId);
|
||||
}
|
||||
|
||||
existing.AbsentFromLastScan = isPlusTitleUnavailable(item);
|
||||
existing.AbsentFromLastScan = isUnavailable(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -71,7 +71,7 @@ namespace DtoImporterService
|
||||
item.DtoItem.DateAdded,
|
||||
item.AccountId)
|
||||
{
|
||||
AbsentFromLastScan = isPlusTitleUnavailable(item)
|
||||
AbsentFromLastScan = isUnavailable(item)
|
||||
};
|
||||
|
||||
try
|
||||
@@ -113,7 +113,13 @@ namespace DtoImporterService
|
||||
}
|
||||
|
||||
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)
|
||||
=> item.DtoItem.ContentType is null
|
||||
|
||||
@@ -15,34 +15,6 @@ namespace FileLiberator
|
||||
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||
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(object _, string title)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
|
||||
@@ -44,13 +44,17 @@ namespace FileLiberator
|
||||
|
||||
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);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
|
||||
var config = Configuration.Instance;
|
||||
var lameConfig = GetLameOptions(config);
|
||||
var lameConfig = DownloadOptions.GetLameOptions(config);
|
||||
var chapters = m4bBook.GetChaptersFromMetadata();
|
||||
//Finishing configuring lame encoder.
|
||||
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||
|
||||
@@ -122,15 +122,13 @@ namespace FileLiberator
|
||||
|
||||
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);
|
||||
|
||||
using var dlOptions = await DownloadOptions.InitiateDownloadAsync(api, libraryBook, config);
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||
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);
|
||||
else
|
||||
{
|
||||
@@ -140,7 +138,7 @@ namespace FileLiberator
|
||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
|
||||
converter.RetrievedMetadata += Converter_RetrievedMetadata;
|
||||
|
||||
abDownloader = converter;
|
||||
}
|
||||
@@ -158,194 +156,40 @@ namespace FileLiberator
|
||||
|
||||
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);
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
|
||||
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(dlOptions.ContentMetadata.ContentReference));
|
||||
|
||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
||||
OnFileCreated(libraryBook, metadataFile);
|
||||
}
|
||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
||||
OnFileCreated(libraryBook, metadataFile);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
||||
{
|
||||
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
||||
private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags)
|
||||
{
|
||||
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
|
||||
return;
|
||||
|
||||
var outputFormat
|
||||
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
||||
? OutputFormat.Mp3
|
||||
: OutputFormat.M4b;
|
||||
|
||||
long chapterStartMs
|
||||
= config.StripAudibleBrandAudio
|
||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||
: 0;
|
||||
|
||||
//Set the requested AudioFormat for use in file naming templates
|
||||
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
|
||||
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
||||
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,
|
||||
AudibleIV = contentLic?.Voucher?.Iv,
|
||||
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]);
|
||||
tags.Year ??= pubDate.Year.ToString();
|
||||
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
362
Source/FileLiberator/DownloadOptions.Factory.cs
Normal file
@@ -0,0 +1,362 @@
|
||||
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.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, LibraryBook libraryBook, Configuration config)
|
||||
{
|
||||
var license = await ChooseContent(api, libraryBook, config);
|
||||
var options = BuildDownloadOptions(libraryBook, config, license);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static async Task<ContentLicense> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
|
||||
{
|
||||
var cdm = await Cdm.GetCdmAsync();
|
||||
|
||||
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
|
||||
ContentLicense? contentLic = null;
|
||||
ContentLicense? fallback = null;
|
||||
|
||||
if (cdm is null)
|
||||
{
|
||||
//Doesn't matter what the user chose. We can't get a CDM so we must fall back to AAX(C)
|
||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
}
|
||||
else
|
||||
{
|
||||
var spatial = config.FileDownloadQuality is Configuration.DownloadQuality.Spatial;
|
||||
try
|
||||
{
|
||||
var codecChoice = config.SpatialAudioCodec switch
|
||||
{
|
||||
Configuration.SpatialCodec.EC_3 => Ec3Codec,
|
||||
Configuration.SpatialCodec.AC_4 => Ac4Codec,
|
||||
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
|
||||
};
|
||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality, ChapterTitlesType.Tree, DrmType.Widevine, spatial, codecChoice);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
||||
}
|
||||
|
||||
if (contentLic is null)
|
||||
{
|
||||
//We failed to get a widevine license, so fall back to AAX(C)
|
||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
}
|
||||
else if (!contentLic.ContentMetadata.ContentReference.IsSpatial && contentLic.DrmType != DrmType.Adrm)
|
||||
{
|
||||
/*
|
||||
We got a widevine license and we have a Cdm, but we still need to decide if we WANT the file
|
||||
being delivered with widevine. This file is not "spatial", so it may be no better than the
|
||||
audio in the Adrm files. All else being equal, we prefer Adrm files because they have more
|
||||
build-in metadata and always AAC-LC, which is a codec playable by pretty much every device
|
||||
in existence.
|
||||
|
||||
Unfortunately, there appears to be no way to determine which codec/quality combination we'll
|
||||
get until we make the request and see what content gets delivered. For some books,
|
||||
Widevine/High delivers 44.1 kHz / 128 kbps audio and Adrm/High delivers 22.05 kHz / 64 kbps.
|
||||
In those cases, the Widevine content size is much larger. Other books will deliver the same
|
||||
sample rate / bitrate for both Widevine and Adrm, the only difference being codec. Widevine
|
||||
is usually xHE-AAC, but is sometimes AAC-LC. Adrm is always AAC-LC.
|
||||
|
||||
To decide which file we want, use this simple rule: if files are different codecs and
|
||||
Widevine is significantly larger, use Widevine. Otherwise use ADRM.
|
||||
|
||||
*/
|
||||
fallback = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
|
||||
var wvCr = contentLic.ContentMetadata.ContentReference;
|
||||
var adrmCr = fallback.ContentMetadata.ContentReference;
|
||||
|
||||
if (wvCr.Codec == adrmCr.Codec ||
|
||||
adrmCr.ContentSizeInBytes > wvCr.ContentSizeInBytes ||
|
||||
RelativePercentDifference(adrmCr.ContentSizeInBytes, wvCr.ContentSizeInBytes) < 0.05)
|
||||
{
|
||||
contentLic = fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLic.DrmType == DrmType.Widevine && cdm is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
|
||||
var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
|
||||
|
||||
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);
|
||||
contentLic.Voucher = new VoucherDtoV10()
|
||||
{
|
||||
Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()),
|
||||
Iv = Convert.ToHexStringLower(keys[0].Key)
|
||||
};
|
||||
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (fallback != null)
|
||||
return fallback;
|
||||
|
||||
//We won't have a fallback if the requested license is for a spatial audio file.
|
||||
//Throw so that the user is aware that spatial audio exists and that they were not able to download it.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
return contentLic;
|
||||
}
|
||||
|
||||
|
||||
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
||||
{
|
||||
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
|
||||
var outputFormat
|
||||
= contentLic.DrmType is not DrmType.Adrm and not DrmType.Widevine ||
|
||||
(config.AllowLibationFixup && config.DecryptToLossy && contentLic.ContentMetadata.ContentReference.Codec != "ac-4")
|
||||
? OutputFormat.Mp3
|
||||
: OutputFormat.M4b;
|
||||
|
||||
long chapterStartMs
|
||||
= config.StripAudibleBrandAudio
|
||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||
: 0;
|
||||
|
||||
AAXClean.FileType? inputType
|
||||
= contentLic.DrmType is DrmType.Widevine ? AAXClean.FileType.Dash
|
||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 8 && contentLic.Voucher?.Iv == null ? AAXClean.FileType.Aax
|
||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 32 && contentLic.Voucher?.Iv.Length == 32 ? AAXClean.FileType.Aaxc
|
||||
: null;
|
||||
|
||||
//Set the requested AudioFormat for use in file naming templates
|
||||
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
|
||||
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic.ContentMetadata.ContentUrl?.OfflineUrl)
|
||||
{
|
||||
AudibleKey = contentLic.Voucher?.Key,
|
||||
AudibleIV = contentLic.Voucher?.Iv,
|
||||
InputType = inputType,
|
||||
OutputFormat = outputFormat,
|
||||
DrmType = contentLic.DrmType,
|
||||
ContentMetadata = contentLic.ContentMetadata,
|
||||
LameConfig = outputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null,
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
static double RelativePercentDifference(long num1, long num2)
|
||||
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.IO;
|
||||
using ApplicationServices;
|
||||
using LibationFileManager.Templates;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadOptions : IDownloadOptions, IDisposable
|
||||
public partial class DownloadOptions : IDownloadOptions, IDisposable
|
||||
{
|
||||
public event EventHandler<long> DownloadSpeedChanged;
|
||||
public LibraryBook LibraryBook { get; }
|
||||
@@ -26,8 +27,8 @@ namespace FileLiberator
|
||||
public string Publisher => LibraryBook.Book.Publisher;
|
||||
public string Language => LibraryBook.Book.Language;
|
||||
public string AudibleProductId => LibraryBookDto.AudibleProductId;
|
||||
public string SeriesName => LibraryBookDto.SeriesName;
|
||||
public float? SeriesNumber => LibraryBookDto.SeriesNumber;
|
||||
public string SeriesName => LibraryBookDto.FirstSeries?.Name;
|
||||
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
|
||||
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
|
||||
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
|
||||
@@ -40,6 +41,9 @@ namespace FileLiberator
|
||||
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
|
||||
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
|
||||
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
||||
public AAXClean.FileType? InputType { get; init; }
|
||||
public AudibleApi.Common.DrmType DrmType { get; init; }
|
||||
public AudibleApi.Common.ContentMetadata ContentMetadata { get; init; }
|
||||
|
||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||
{
|
||||
@@ -82,9 +86,13 @@ namespace FileLiberator
|
||||
|
||||
private readonly Configuration config;
|
||||
private readonly IDisposable cancellation;
|
||||
public void Dispose() => cancellation?.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
cancellation?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
|
||||
private DownloadOptions(Configuration config, LibraryBook libraryBook, [System.Diagnostics.CodeAnalysis.NotNull] string downloadUrl)
|
||||
{
|
||||
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
|
||||
@@ -19,5 +19,10 @@
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="DownloadOptions.*.cs">
|
||||
<DependentUpon>DownloadOptions.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,8 +5,9 @@ using System.Threading.Tasks;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
|
||||
#nullable enable
|
||||
namespace FileLiberator
|
||||
{
|
||||
public static class UtilityExtensions
|
||||
@@ -47,12 +48,10 @@ namespace FileLiberator
|
||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||
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(),
|
||||
|
||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
||||
Series = getSeries(libraryBook.Book.SeriesLink),
|
||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||
|
||||
@@ -62,5 +61,21 @@ namespace FileLiberator
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
|
||||
<PackageReference Include="Polly" Version="8.5.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ namespace FileManager
|
||||
/// <param name="extension">File extension override to use for <paramref name="destination"/></param>
|
||||
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
|
||||
/// <returns>The actual destination filename</returns>
|
||||
public static string SaferMoveToValidPath(
|
||||
public static LongPath SaferMoveToValidPath(
|
||||
LongPath source,
|
||||
LongPath destination,
|
||||
ReplacementCharacters replacements,
|
||||
|
||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 36 KiB |
@@ -71,13 +71,13 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
<PackageReference Include="Avalonia" Version="11.2.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.4" />
|
||||
<PackageReference Include="Avalonia" Version="11.2.8" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.8" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.4" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.4" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.8" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.8" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
|
||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 36 KiB |
|
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 |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 36 KiB |
@@ -17,6 +17,14 @@
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</ControlTheme>
|
||||
<ControlTheme x:Key="{x:Type DataGridCell}" TargetType="DataGridCell" BasedOn="{StaticResource {x:Type DataGridCell}}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
|
||||
</ControlTheme>
|
||||
<ControlTheme x:Key="{x:Type DataGridColumnHeader}" TargetType="DataGridColumnHeader" BasedOn="{StaticResource {x:Type DataGridColumnHeader}}">
|
||||
<Setter Property="Padding" Value="6,0,0,0" />
|
||||
</ControlTheme>
|
||||
<x:Double x:Key="DataGridSortIconMinWidth">0</x:Double>
|
||||
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#abffab" />
|
||||
@@ -25,17 +33,11 @@
|
||||
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
|
||||
<SolidColorBrush x:Key="HyperlinkNew" Color="Blue" />
|
||||
<SolidColorBrush x:Key="HyperlinkVisited" Color="Purple" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="White" />
|
||||
<SolidColorBrush x:Key="SystemOpaqueBase" Color="White" />
|
||||
|
||||
<SolidColorBrush x:Key="CancelRed" Color="FireBrick" />
|
||||
<SolidColorBrush x:Key="IconFill" Color="#231F20" />
|
||||
<SolidColorBrush x:Key="StoplightRed" Color="#F06060" />
|
||||
<SolidColorBrush x:Key="StoplightYellow" Color="#F0E160" />
|
||||
<SolidColorBrush x:Key="StoplightGreen" Color="#70FA70" />
|
||||
|
||||
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />
|
||||
|
||||
</ResourceDictionary>
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#bed2fa" />
|
||||
@@ -44,35 +46,32 @@
|
||||
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="#4e4b15" />
|
||||
<SolidColorBrush x:Key="HyperlinkNew" Color="CornflowerBlue" />
|
||||
<SolidColorBrush x:Key="HyperlinkVisited" Color="Orchid" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="Black" />
|
||||
<SolidColorBrush x:Key="SystemOpaqueBase" Color="Black" />
|
||||
|
||||
<SolidColorBrush x:Key="CancelRed" Color="#802727" />
|
||||
<SolidColorBrush x:Key="IconFill" Color="#DCE0DF" />
|
||||
<SolidColorBrush x:Key="StoplightRed" Color="#7d1f1f" />
|
||||
<SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" />
|
||||
<SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
|
||||
|
||||
|
||||
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />
|
||||
|
||||
<SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Themes.Fluent/FluentTheme.xaml"/>
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
|
||||
<StyleInclude Source="/Assets/LibationVectorIcons.xaml"/>
|
||||
<StyleInclude Source="/Assets/DataGridColumnHeader.xaml"/>
|
||||
<FluentTheme>
|
||||
<FluentTheme.Palettes>
|
||||
<ColorPaletteResources x:Key="Light" />
|
||||
<ColorPaletteResources x:Key="Dark" />
|
||||
</FluentTheme.Palettes>
|
||||
</FluentTheme>
|
||||
|
||||
<Style Selector="TextBox[IsReadOnly=true]">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
|
||||
<Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
|
||||
<Style Selector="^ /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
|
||||
</Style>
|
||||
</Style>
|
||||
<Style Selector="controls|LinkLabel">
|
||||
@@ -81,6 +80,9 @@
|
||||
</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. -->
|
||||
@@ -88,58 +90,14 @@
|
||||
</Style>
|
||||
|
||||
<Style Selector="dialogs|DialogWindow">
|
||||
<Style Selector="^[UseCustomTitleBar=false]">
|
||||
<Setter Property="SystemDecorations" Value="Full"/>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<ContentPresenter Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Content="{TemplateBinding Content}" />
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="^[UseCustomTitleBar=true]">
|
||||
<Setter Property="SystemDecorations" Value="BorderOnly"/>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Panel Background="{DynamicResource SystemControlBackgroundAltHighBrush}">
|
||||
<Grid RowDefinitions="30,*">
|
||||
<Border Name="DialogWindowTitleBorder" Margin="5,0" Background="{DynamicResource SystemAltMediumColor}">
|
||||
<Border.Styles>
|
||||
<Style Selector="Button#DialogCloseButton">
|
||||
<Style Selector="^:pointerover">
|
||||
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Red" />
|
||||
</Style>
|
||||
<Style Selector="^ Path">
|
||||
<Setter Property="Fill" Value="{DynamicResource IconFill}" />
|
||||
</Style>
|
||||
</Style>
|
||||
<Style Selector="^:not(:pointerover) /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
</Style>
|
||||
</Border.Styles>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<Path Name="DialogWindowTitleIcon" Margin="3,5,0,5" Fill="{DynamicResource IconFill}" Stretch="Uniform" Data="{StaticResource LibationGlassIcon}"/>
|
||||
|
||||
<TextBlock Name="DialogWindowTitleTextBlock" Margin="8,0,0,0" VerticalAlignment="Center" FontWeight="DemiBold" FontSize="12" Grid.Column="1" Text="{TemplateBinding Title}" />
|
||||
|
||||
<Button Name="DialogCloseButton" Grid.Column="2">
|
||||
<Path Fill="{DynamicResource SystemControlBackgroundBaseLowBrush}" VerticalAlignment="Center" Stretch="Uniform" RenderTransform="{StaticResource Rotate45Transform}" Data="{StaticResource CancelButtonIcon}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Path Stroke="{DynamicResource SystemBaseMediumLowColor}" StrokeThickness="1" VerticalAlignment="Bottom" Stretch="Fill" Data="M0,0 L1,0" />
|
||||
<ContentPresenter Grid.Row="1" Content="{TemplateBinding Content}" />
|
||||
</Grid>
|
||||
</Panel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<Setter Property="SystemDecorations" Value="Full"/>
|
||||
<Setter Property="Icon" Value="/Assets/libation.ico"/>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<ContentPresenter Background="{DynamicResource SystemRegionColor}" Content="{TemplateBinding Content}" />
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
<NativeMenu.Menu>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Styling;
|
||||
using LibationAvalonia.Dialogs;
|
||||
@@ -13,19 +12,22 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using Dinah.Core;
|
||||
using LibationAvalonia.Themes;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia
|
||||
{
|
||||
public class App : Application
|
||||
{
|
||||
public static MainWindow MainWindow { get; private set; }
|
||||
public static IBrush ProcessQueueBookFailedBrush { get; private set; }
|
||||
public static IBrush ProcessQueueBookCompletedBrush { get; private set; }
|
||||
public static IBrush ProcessQueueBookCancelledBrush { get; private set; }
|
||||
public static IBrush ProcessQueueBookDefaultBrush { get; private set; }
|
||||
public static IBrush SeriesEntryGridBackgroundBrush { get; private set; }
|
||||
public static Task<List<DataLayer.LibraryBook>>? LibraryTask { get; set; }
|
||||
public static ChardonnayTheme? DefaultThemeColors { get; private set; }
|
||||
public static MainWindow? MainWindow { get; private set; }
|
||||
public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/");
|
||||
public static new Application Current => Application.Current ?? throw new InvalidOperationException("The Avalonia app hasn't started yet.");
|
||||
|
||||
public static readonly Uri AssetUriBase = new("avares://Libation/Assets/");
|
||||
public static Stream OpenAsset(string assetRelativePath)
|
||||
=> AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath));
|
||||
|
||||
@@ -34,12 +36,16 @@ namespace LibationAvalonia
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public static Task<List<DataLayer.LibraryBook>> LibraryTask;
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
DefaultThemeColors = ChardonnayTheme.GetLiveTheme();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
|
||||
var config = Configuration.Instance;
|
||||
|
||||
if (!config.LibationSettingsAreValid)
|
||||
@@ -69,11 +75,23 @@ namespace LibationAvalonia
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private async void Setup_Closing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
var setupDialog = sender as SetupDialog;
|
||||
var desktop = ApplicationLifetime as IClassicDesktopStyleApplicationLifetime;
|
||||
// Get an array of plugins to remove
|
||||
var dataValidationPluginsToRemove =
|
||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||
|
||||
// remove each entry found
|
||||
foreach (var plugin in dataValidationPluginsToRemove)
|
||||
{
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private async void Setup_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
if (sender is not SetupDialog setupDialog || ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -87,7 +105,7 @@ namespace LibationAvalonia
|
||||
|
||||
if (setupDialog.Config.LibationSettingsAreValid)
|
||||
{
|
||||
string theme = setupDialog.SelectedTheme.Content as string;
|
||||
string? theme = setupDialog.SelectedTheme.Content as string;
|
||||
|
||||
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
|
||||
|
||||
@@ -143,7 +161,7 @@ namespace LibationAvalonia
|
||||
desktop.MainWindow = libationFilesDialog;
|
||||
libationFilesDialog.Show();
|
||||
|
||||
void WindowClosing(object sender, System.ComponentModel.CancelEventArgs e)
|
||||
void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||
{
|
||||
libationFilesDialog.Closing -= WindowClosing;
|
||||
e.Cancel = true;
|
||||
@@ -201,16 +219,9 @@ namespace LibationAvalonia
|
||||
|
||||
private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Current.RequestedThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) switch
|
||||
{
|
||||
nameof(ThemeVariant.Dark) => ThemeVariant.Dark,
|
||||
nameof(ThemeVariant.Light) => ThemeVariant.Light,
|
||||
// "System"
|
||||
_ => ThemeVariant.Default
|
||||
};
|
||||
Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged;
|
||||
OpenAndApplyTheme(Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)));
|
||||
|
||||
//Reload colors for current theme
|
||||
LoadStyles();
|
||||
var mainWindow = new MainWindow();
|
||||
desktop.MainWindow = MainWindow = mainWindow;
|
||||
mainWindow.Loaded += MainWindow_Loaded;
|
||||
@@ -218,19 +229,23 @@ namespace LibationAvalonia
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
private static async void MainWindow_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
[PropertyChangeFilter(nameof(ThemeVariant))]
|
||||
private static void ThemeVariant_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
|
||||
=> OpenAndApplyTheme(e.NewValue as string);
|
||||
|
||||
private static void OpenAndApplyTheme(string? themeVariant)
|
||||
{
|
||||
var library = await LibraryTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
|
||||
using var themePersister = ChardonnayThemePersister.Create();
|
||||
themePersister?.Target.ApplyTheme(themeVariant);
|
||||
}
|
||||
|
||||
private static void LoadStyles()
|
||||
private static async void MainWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookFailedBrush));
|
||||
ProcessQueueBookCompletedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCompletedBrush));
|
||||
ProcessQueueBookCancelledBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCancelledBrush));
|
||||
SeriesEntryGridBackgroundBrush = AvaloniaUtils.GetBrushFromResources(nameof(SeriesEntryGridBackgroundBrush));
|
||||
ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookDefaultBrush));
|
||||
if (LibraryTask is not null && MainWindow is not null)
|
||||
{
|
||||
var library = await LibraryTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:collections="using:Avalonia.Collections">
|
||||
<Styles.Resources>
|
||||
<!--
|
||||
Based on Fluent template from v11.0.0-preview8
|
||||
Modified sort arrow positioning to make more room for header text
|
||||
-->
|
||||
<ControlTheme x:Key="{x:Type DataGridColumnHeader}" TargetType="DataGridColumnHeader">
|
||||
<Setter Property="Foreground" Value="{DynamicResource DataGridColumnHeaderForegroundBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderBackgroundBrush}" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="SeparatorBrush" Value="{DynamicResource DataGridGridLinesBrush}" />
|
||||
<Setter Property="Padding" Value="8,0,0,0" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="MinHeight" Value="32" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border x:Name="HeaderBorder"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}">
|
||||
<Grid Name="PART_ColumnHeaderRoot" ColumnDefinitions="*,Auto">
|
||||
|
||||
<Grid Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="16" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}" />
|
||||
|
||||
<Path Name="SortIcon"
|
||||
IsVisible="False"
|
||||
Grid.Column="1"
|
||||
Height="12"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Stretch="Uniform" />
|
||||
</Grid>
|
||||
|
||||
<Rectangle Name="VerticalSeparator"
|
||||
Grid.Column="1"
|
||||
Width="1"
|
||||
VerticalAlignment="Stretch"
|
||||
Fill="{TemplateBinding SeparatorBrush}"
|
||||
IsVisible="{TemplateBinding AreSeparatorsVisible}" />
|
||||
|
||||
<Grid x:Name="FocusVisual" IsHitTestVisible="False"
|
||||
IsVisible="False">
|
||||
<Rectangle x:Name="FocusVisualPrimary"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Fill="Transparent"
|
||||
IsHitTestVisible="False"
|
||||
Stroke="{DynamicResource DataGridCellFocusVisualPrimaryBrush}"
|
||||
StrokeThickness="2" />
|
||||
<Rectangle x:Name="FocusVisualSecondary"
|
||||
Margin="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Fill="Transparent"
|
||||
IsHitTestVisible="False"
|
||||
Stroke="{DynamicResource DataGridCellFocusVisualSecondaryBrush}"
|
||||
StrokeThickness="1" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
|
||||
<Style Selector="^:focus-visible /template/ Grid#FocusVisual">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^:pointerover /template/ Grid#PART_ColumnHeaderRoot">
|
||||
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderHoveredBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^:pressed /template/ Grid#PART_ColumnHeaderRoot">
|
||||
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderPressedBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^:dragIndicator">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^:sortascending /template/ Path#SortIcon">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
<Setter Property="Data" Value="{StaticResource DataGridSortIconAscendingPath}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^:sortdescending /template/ Path#SortIcon">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
<Setter Property="Data" Value="{StaticResource DataGridSortIconDescendingPath}" />
|
||||
</Style>
|
||||
</ControlTheme>
|
||||
</Styles.Resources>
|
||||
</Styles>
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 35 KiB |
@@ -1,31 +1,36 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml.MarkupExtensions;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.VisualTree;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationFileManager;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia
|
||||
{
|
||||
internal static class AvaloniaUtils
|
||||
{
|
||||
public static IBrush GetBrushFromResources(string name)
|
||||
=> GetBrushFromResources(name, Brushes.Transparent);
|
||||
public static IBrush GetBrushFromResources(string name, IBrush defaultBrush)
|
||||
public static T DynamicResource<T>(this T control, AvaloniaProperty prop, object resourceKey) where T : Control
|
||||
{
|
||||
if (App.Current.TryGetResource(name, App.Current.ActualThemeVariant, out var value) && value is IBrush brush)
|
||||
return brush;
|
||||
return defaultBrush;
|
||||
control[!prop] = new DynamicResourceExtension(resourceKey);
|
||||
return control;
|
||||
}
|
||||
|
||||
public static Task<DialogResult> ShowDialogAsync(this DialogWindow dialogWindow, Window owner = null)
|
||||
=> dialogWindow.ShowDialog<DialogResult>(owner ?? App.MainWindow);
|
||||
public static Task<DialogResult> ShowDialogAsync(this Dialogs.DialogWindow dialogWindow, Window? owner = null)
|
||||
=> ((owner ?? App.MainWindow) is Window window)
|
||||
? dialogWindow.ShowDialog<DialogResult>(window)
|
||||
: Task.FromResult(DialogResult.None);
|
||||
|
||||
public static Window GetParentWindow(this Control control) => control.GetVisualRoot() as Window;
|
||||
public static Task<DialogResult> ShowDialogAsync(this Dialogs.Login.WebLoginDialog dialogWindow, Window? owner = null)
|
||||
=> ((owner ?? App.MainWindow) is Window window)
|
||||
? dialogWindow.ShowDialog<DialogResult>(window)
|
||||
: Task.FromResult(DialogResult.None);
|
||||
|
||||
public static Window? GetParentWindow(this Control control) => control.GetVisualRoot() as Window;
|
||||
|
||||
|
||||
private static Bitmap defaultImage;
|
||||
private static Bitmap? defaultImage;
|
||||
public static Bitmap TryLoadImageOrDefault(byte[] picture, PictureSize defaultSize = PictureSize.Native)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using Avalonia.Controls;
|
||||
using LibationUiBase.GridView;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LibationAvalonia.Controls
|
||||
@@ -12,11 +13,13 @@ namespace LibationAvalonia.Controls
|
||||
private static readonly ContextMenu ContextMenu = new();
|
||||
private static readonly AvaloniaList<Control> MenuItems = new();
|
||||
private static readonly PropertyInfo OwningColumnProperty;
|
||||
private static readonly PropertyInfo OwningGridProperty;
|
||||
|
||||
static DataGridContextMenus()
|
||||
{
|
||||
ContextMenu.ItemsSource = MenuItems;
|
||||
OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
}
|
||||
|
||||
public static void AttachContextMenu(this DataGridCell cell)
|
||||
@@ -30,19 +33,35 @@ namespace LibationAvalonia.Controls
|
||||
|
||||
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (sender is DataGridCell cell && cell.DataContext is IGridEntry entry)
|
||||
if (sender is DataGridCell cell &&
|
||||
cell.DataContext is IGridEntry clickedEntry &&
|
||||
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
|
||||
OwningGridProperty.GetValue(column) is DataGrid grid)
|
||||
{
|
||||
var allSelected = grid.SelectedItems.OfType<IGridEntry>().ToArray();
|
||||
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
|
||||
if (clickedIndex == -1)
|
||||
{
|
||||
//User didn't right-click on a selected cell
|
||||
grid.SelectedItem = clickedEntry;
|
||||
allSelected = [clickedEntry];
|
||||
}
|
||||
else if (clickedIndex > 0)
|
||||
{
|
||||
//Ensure the clicked entry is first in the list
|
||||
(allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]);
|
||||
}
|
||||
|
||||
var args = new DataGridCellContextMenuStripNeededEventArgs
|
||||
{
|
||||
Column = OwningColumnProperty.GetValue(cell) as DataGridColumn,
|
||||
GridEntry = entry,
|
||||
Column = column,
|
||||
Grid = grid,
|
||||
GridEntries = allSelected,
|
||||
ContextMenu = ContextMenu
|
||||
};
|
||||
|
||||
args.ContextMenuItems.Clear();
|
||||
|
||||
CellContextMenuStripNeeded?.Invoke(sender, args);
|
||||
|
||||
e.Handled = args.ContextMenuItems.Count == 0;
|
||||
}
|
||||
else
|
||||
@@ -61,10 +80,37 @@ namespace LibationAvalonia.Controls
|
||||
private static string GetCellValue(DataGridColumn column, object item)
|
||||
=> GetCellValueMethod.Invoke(column, new object[] { item, column.ClipboardContentBinding })?.ToString() ?? "";
|
||||
|
||||
public string CellClipboardContents => GetCellValue(Column, GridEntry);
|
||||
public DataGridColumn Column { get; init; }
|
||||
public IGridEntry GridEntry { get; init; }
|
||||
public ContextMenu ContextMenu { get; init; }
|
||||
public string CellClipboardContents => GetCellValue(Column, GridEntries[0]);
|
||||
public string GetRowClipboardContents()
|
||||
{
|
||||
if (GridEntries is null || GridEntries.Length == 0)
|
||||
return string.Empty;
|
||||
else if (GridEntries.Length == 1)
|
||||
return HeaderNames + Environment.NewLine + GetRowClipboardContents(GridEntries[0]);
|
||||
else
|
||||
return string.Join(Environment.NewLine, GridEntries.Select(GetRowClipboardContents).Prepend(HeaderNames));
|
||||
}
|
||||
|
||||
private string HeaderNames
|
||||
=> string.Join("\t",
|
||||
Grid.Columns
|
||||
.Where(c => c.IsVisible)
|
||||
.OrderBy(c => c.DisplayIndex)
|
||||
.Select(c => RemoveLineBreaks(c.Header.ToString())));
|
||||
|
||||
private static string RemoveLineBreaks(string text)
|
||||
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
|
||||
|
||||
private string GetRowClipboardContents(IGridEntry gridEntry)
|
||||
{
|
||||
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
|
||||
return string.Join("\t", contents);
|
||||
}
|
||||
|
||||
public required DataGrid Grid { get; init; }
|
||||
public required DataGridColumn Column { get; init; }
|
||||
public required IGridEntry[] GridEntries { get; init; }
|
||||
public required ContextMenu ContextMenu { get; init; }
|
||||
public AvaloniaList<Control> ContextMenuItems
|
||||
=> ContextMenu.ItemsSource as AvaloniaList<Control>;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ContentControl.Styles>
|
||||
<Style Selector="controls|GroupBox">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemBaseMediumLowColor}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource SystemBaseMediumColor}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
<Setter Property="Template">
|
||||
@@ -28,7 +28,7 @@
|
||||
<TextBlock
|
||||
Name="PART_Label"
|
||||
Padding="4,0"
|
||||
Background="{DynamicResource SystemAltHighColor}"
|
||||
Background="{DynamicResource SystemRegionColor}"
|
||||
Text="{TemplateBinding Label}"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="650"
|
||||
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="700"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
|
||||
x:DataType="vm:AudioSettingsVM"
|
||||
x:Class="LibationAvalonia.Controls.Settings.Audio">
|
||||
|
||||
|
||||
<Grid
|
||||
Margin="5"
|
||||
RowDefinitions="Auto,*,Auto"
|
||||
RowDefinitions="Auto,Auto"
|
||||
ColumnDefinitions="*,*">
|
||||
|
||||
<Grid.Styles>
|
||||
@@ -28,9 +28,12 @@
|
||||
</Style>
|
||||
</Grid.Styles>
|
||||
|
||||
<!--Left Column-->
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Grid.Column="0">
|
||||
Grid.Column="0"
|
||||
Margin="0,0,10,0"
|
||||
>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock
|
||||
@@ -40,10 +43,26 @@
|
||||
<controls:WheelComboBox
|
||||
Margin="5,0,0,0"
|
||||
Grid.Column="1"
|
||||
SelectionChanged="Quality_SelectionChanged"
|
||||
ItemsSource="{CompiledBinding DownloadQualities}"
|
||||
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,5,0,0"
|
||||
IsEnabled="{CompiledBinding SpatialSelected}"
|
||||
ToolTip.Tip="{CompiledBinding SpatialAudioCodecTip}">
|
||||
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{CompiledBinding SpatialAudioCodecText}" />
|
||||
|
||||
<controls:WheelComboBox
|
||||
Margin="5,0,0,0"
|
||||
Grid.Column="1"
|
||||
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
|
||||
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
|
||||
</Grid>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />
|
||||
</CheckBox>
|
||||
@@ -65,11 +84,15 @@
|
||||
SelectedItem="{CompiledBinding ClipBookmarkFormat}"/>
|
||||
</Grid>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}">
|
||||
<CheckBox
|
||||
IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding RetainAaxFileTip}">
|
||||
<TextBlock Text="{CompiledBinding RetainAaxFileText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}">
|
||||
<CheckBox
|
||||
IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding MergeOpeningAndEndCreditsTip}">
|
||||
<TextBlock Text="{CompiledBinding MergeOpeningEndCreditsText}" />
|
||||
</CheckBox>
|
||||
|
||||
@@ -84,137 +107,214 @@
|
||||
IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding AllowLibationFixupText}" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="1"
|
||||
Label="Audiobook Fix-ups"
|
||||
IsEnabled="{CompiledBinding AllowLibationFixup}">
|
||||
<controls:GroupBox
|
||||
Grid.Row="1"
|
||||
Label="Audiobook Fix-ups"
|
||||
IsEnabled="{CompiledBinding AllowLibationFixup}">
|
||||
|
||||
<StackPanel Orientation="Vertical">
|
||||
<StackPanel Orientation="Vertical">
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
|
||||
</CheckBox>
|
||||
|
||||
<RadioButton IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}">
|
||||
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
|
||||
<TextBlock
|
||||
Text="Download my books in the original audio format (Lossless)" />
|
||||
<CheckBox
|
||||
IsEnabled="{CompiledBinding !DecryptToLossy}"
|
||||
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}">
|
||||
|
||||
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
|
||||
|
||||
</CheckBox>
|
||||
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Download my books as .MP3 files (transcode if necessary)" />
|
||||
|
||||
</RadioButton>
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Column="1"
|
||||
Grid.RowSpan="2"
|
||||
Margin="10,0,0,0"
|
||||
Label="Mp3 Encoding Options">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
|
||||
<Grid
|
||||
Margin="0,5"
|
||||
ColumnDefinitions="Auto,*">
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Column="0"
|
||||
Label="Target">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,Auto">
|
||||
<RadioButton
|
||||
Margin="5"
|
||||
Content="Bitrate"
|
||||
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
|
||||
|
||||
<RadioButton
|
||||
Grid.Column="1"
|
||||
Margin="5"
|
||||
Content="Quality"
|
||||
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
</controls:GroupBox>
|
||||
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox
|
||||
HorizontalAlignment="Right"
|
||||
Grid.Column="1"
|
||||
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Downsample to mono? (Recommended)" />
|
||||
|
||||
IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding StripAudibleBrandAudioTip}">
|
||||
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
|
||||
</CheckBox>
|
||||
</Grid>
|
||||
|
||||
<CheckBox
|
||||
IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding StripUnabridgedTip}">
|
||||
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--Right Column-->
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Margin="10,0,0,0">
|
||||
|
||||
<RadioButton
|
||||
IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
|
||||
|
||||
<Grid Grid.Row="1" Margin="0,5" RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
|
||||
<StackPanel VerticalAlignment="Center">
|
||||
<TextBlock
|
||||
Text="Download my books in the original audio format (Lossless)" />
|
||||
<CheckBox
|
||||
IsEnabled="{CompiledBinding !DecryptToLossy}"
|
||||
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding MoveMoovToBeginningTip}">
|
||||
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
|
||||
<TextBlock Margin="0,0,0,5" Text="Max audio sample rate:" />
|
||||
<controls:WheelComboBox
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
ItemsSource="{CompiledBinding SampleRates}"
|
||||
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
|
||||
<RadioButton
|
||||
IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Download my books as .MP3 files (transcode if necessary)" />
|
||||
</RadioButton>
|
||||
|
||||
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
|
||||
<controls:GroupBox
|
||||
Grid.Column="1"
|
||||
IsEnabled="{CompiledBinding DecryptToLossy}"
|
||||
Label="Mp3 Encoding Options">
|
||||
|
||||
<controls:WheelComboBox
|
||||
Grid.Column="2"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
ItemsSource="{CompiledBinding EncoderQualities}"
|
||||
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="2"
|
||||
Margin="0,5"
|
||||
Label="Bitrate"
|
||||
IsEnabled="{CompiledBinding LameTargetBitrate}" >
|
||||
<Grid
|
||||
Margin="0,5"
|
||||
ColumnDefinitions="Auto,*">
|
||||
|
||||
<StackPanel>
|
||||
<Grid ColumnDefinitions="*,25,Auto">
|
||||
<controls:GroupBox
|
||||
Grid.Column="0"
|
||||
Label="Target">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,Auto">
|
||||
<RadioButton
|
||||
Margin="5"
|
||||
Content="Bitrate"
|
||||
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
|
||||
|
||||
<RadioButton
|
||||
Grid.Column="1"
|
||||
Margin="5"
|
||||
Content="Quality"
|
||||
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
</controls:GroupBox>
|
||||
|
||||
<CheckBox
|
||||
HorizontalAlignment="Right"
|
||||
Grid.Column="1"
|
||||
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding LameDownsampleMonoTip}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Downsample to mono? (Recommended)" />
|
||||
|
||||
</CheckBox>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Margin="0,5" RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<TextBlock Margin="0,0,0,5" Text="Max audio sample rate:" />
|
||||
<controls:WheelComboBox
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
ItemsSource="{CompiledBinding SampleRates}"
|
||||
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
|
||||
|
||||
<controls:WheelComboBox
|
||||
Grid.Column="2"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
ItemsSource="{CompiledBinding EncoderQualities}"
|
||||
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="2"
|
||||
Margin="0,5"
|
||||
Label="Bitrate"
|
||||
IsEnabled="{CompiledBinding LameTargetBitrate}" >
|
||||
|
||||
<StackPanel>
|
||||
<Grid ColumnDefinitions="*,25,Auto">
|
||||
|
||||
<Slider
|
||||
Grid.Column="0"
|
||||
IsEnabled="{CompiledBinding !LameMatchSource}"
|
||||
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
|
||||
Minimum="16"
|
||||
Maximum="320"
|
||||
IsSnapToTickEnabled="True" TickFrequency="16"
|
||||
Ticks="16,32,48,64,80,96,112,128,144,160,176,192,208,224,240,256,272,288,304,320"
|
||||
TickPlacement="Outside">
|
||||
|
||||
<Slider.Styles>
|
||||
<Style Selector="Slider /template/ Thumb">
|
||||
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
|
||||
<Setter Property="ToolTip.Placement" Value="Top" />
|
||||
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
|
||||
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
|
||||
</Style>
|
||||
</Slider.Styles>
|
||||
</Slider>
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
Text="{CompiledBinding LameBitrate}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
Text=" Kbps" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,*">
|
||||
|
||||
<CheckBox
|
||||
Grid.Column="0"
|
||||
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Restrict Encoder to Constant Bitrate?" />
|
||||
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Match Source Bitrate?" />
|
||||
|
||||
</CheckBox>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="3"
|
||||
Margin="0,5"
|
||||
Label="Quality"
|
||||
IsEnabled="{CompiledBinding !LameTargetBitrate}">
|
||||
|
||||
<Grid
|
||||
ColumnDefinitions="*,Auto,25"
|
||||
RowDefinitions="*,Auto">
|
||||
|
||||
<Slider
|
||||
Grid.Column="0"
|
||||
IsEnabled="{CompiledBinding !LameMatchSource}"
|
||||
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
|
||||
Minimum="16"
|
||||
Maximum="320"
|
||||
IsSnapToTickEnabled="True" TickFrequency="16"
|
||||
Ticks="16,32,48,64,80,96,112,128,144,160,176,192,208,224,240,256,272,288,304,320"
|
||||
Grid.ColumnSpan="2"
|
||||
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
|
||||
Minimum="0"
|
||||
Maximum="9"
|
||||
IsSnapToTickEnabled="True" TickFrequency="1"
|
||||
Ticks="0,1,2,3,4,5,6,7,8,9"
|
||||
TickPlacement="Outside">
|
||||
|
||||
<Slider.Styles>
|
||||
<Style Selector="Slider /template/ Thumb">
|
||||
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
|
||||
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
|
||||
<Setter Property="ToolTip.Placement" Value="Top" />
|
||||
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
|
||||
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
|
||||
@@ -222,105 +322,42 @@
|
||||
</Slider.Styles>
|
||||
</Slider>
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
Text="{CompiledBinding LameBitrate}" />
|
||||
|
||||
<TextBlock
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
Text=" Kbps" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,*">
|
||||
|
||||
<CheckBox
|
||||
Grid.Column="0"
|
||||
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Restrict Encoder to Constant Bitrate?" />
|
||||
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
|
||||
Orientation="Horizontal">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Match Source Bitrate?" />
|
||||
<TextBlock Text="V" />
|
||||
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
Text="Higher" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Right"
|
||||
Text="Lower" />
|
||||
|
||||
</CheckBox>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="3"
|
||||
Margin="0,5"
|
||||
Label="Quality"
|
||||
IsEnabled="{CompiledBinding !LameTargetBitrate}">
|
||||
</controls:GroupBox>
|
||||
|
||||
<Grid
|
||||
ColumnDefinitions="*,Auto,25"
|
||||
RowDefinitions="*,Auto">
|
||||
<TextBlock
|
||||
Grid.Row="4"
|
||||
Margin="0,5"
|
||||
VerticalAlignment="Bottom"
|
||||
Text="Using L.A.M.E encoding engine"
|
||||
FontStyle="Oblique" />
|
||||
</Grid>
|
||||
</controls:GroupBox>
|
||||
|
||||
<Slider
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
|
||||
Minimum="0"
|
||||
Maximum="9"
|
||||
IsSnapToTickEnabled="True" TickFrequency="1"
|
||||
Ticks="0,1,2,3,4,5,6,7,8,9"
|
||||
TickPlacement="Outside">
|
||||
<Slider.Styles>
|
||||
<Style Selector="Slider /template/ Thumb">
|
||||
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
|
||||
<Setter Property="ToolTip.Placement" Value="Top" />
|
||||
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
|
||||
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
|
||||
</Style>
|
||||
</Slider.Styles>
|
||||
</Slider>
|
||||
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
Orientation="Horizontal">
|
||||
|
||||
<TextBlock Text="V" />
|
||||
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
Text="Higher" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
HorizontalAlignment="Right"
|
||||
Text="Lower" />
|
||||
|
||||
</Grid>
|
||||
</controls:GroupBox>
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="4"
|
||||
Margin="0,5"
|
||||
VerticalAlignment="Bottom"
|
||||
Text="Using L.A.M.E encoding engine"
|
||||
FontStyle="Oblique" />
|
||||
</Grid>
|
||||
</controls:GroupBox>
|
||||
</StackPanel>
|
||||
|
||||
<!--Bottom Row-->
|
||||
<controls:GroupBox
|
||||
Grid.Row="2"
|
||||
Grid.ColumnSpan="2"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Controls;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Controls.Settings
|
||||
@@ -19,6 +22,25 @@ namespace LibationAvalonia.Controls.Settings
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async void Quality_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SpatialSelected)
|
||||
{
|
||||
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
|
||||
{
|
||||
await MessageBox.Show(VisualRoot as Window,
|
||||
"Your must remove account(s) from Libation and then re-add them to enable spatial audiobook downloads.",
|
||||
"Spatial Audio Unavailable",
|
||||
MessageBoxButtons.OK);
|
||||
|
||||
_viewModel.FileDownloadQuality = _viewModel.DownloadQualities[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel is null) return;
|
||||
|
||||
@@ -2,6 +2,7 @@ using Avalonia.Controls;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Controls.Settings
|
||||
|
||||
@@ -158,15 +158,24 @@
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
FontSize="16"
|
||||
Margin="0,0,15,0"
|
||||
VerticalAlignment="Center"
|
||||
Text="Theme: "/>
|
||||
Text="Theme:"/>
|
||||
|
||||
<controls:WheelComboBox
|
||||
Name="ThemeComboBox"
|
||||
Grid.Column="1"
|
||||
MinWidth="80"
|
||||
SelectedItem="{CompiledBinding ThemeVariant, Mode=TwoWay}"
|
||||
ItemsSource="{CompiledBinding Themes}"/>
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
Padding="20,0"
|
||||
VerticalAlignment="Stretch"
|
||||
Content="Edit Theme Colors"
|
||||
Click="EditThemeColors_Click"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Controls.Settings
|
||||
{
|
||||
public partial class Important : UserControl
|
||||
{
|
||||
private ImportantSettingsVM? ViewModel => DataContext as ImportantSettingsVM;
|
||||
public Important()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -16,6 +21,42 @@ namespace LibationAvalonia.Controls.Settings
|
||||
_ = Configuration.Instance.LibationFiles;
|
||||
DataContext = new ImportantSettingsVM(Configuration.Instance);
|
||||
}
|
||||
|
||||
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
|
||||
}
|
||||
|
||||
private void EditThemeColors_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
|
||||
{
|
||||
//Only allow a single instance of the theme picker
|
||||
//Show it as a window, not a dialog, so users can preview
|
||||
//their changes throughout the entire app.
|
||||
if (lifetime.Windows.OfType<ThemePickerDialog>().FirstOrDefault() is ThemePickerDialog dialog)
|
||||
{
|
||||
dialog.BringIntoView();
|
||||
}
|
||||
else
|
||||
{
|
||||
var themePicker = new ThemePickerDialog();
|
||||
themePicker.Show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ThemeComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
//Remove the combo box before changing the theme, then re-add it.
|
||||
//This is a workaround to a crash that will happen if the theme
|
||||
//is changed while the combo box is open
|
||||
ThemeComboBox.SelectionChanged -= ThemeComboBox_SelectionChanged;
|
||||
var parent = ThemeComboBox.Parent as Panel;
|
||||
if (parent?.Children.Remove(ThemeComboBox) ?? false)
|
||||
{
|
||||
Configuration.Instance.SetString(ViewModel?.ThemeVariant, nameof(ViewModel.ThemeVariant));
|
||||
parent.Children.Add(ThemeComboBox);
|
||||
}
|
||||
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
|
||||
}
|
||||
|
||||
public void OpenLogFolderButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
|
||||
52
Source/LibationAvalonia/Controls/ThemePreviewControl.axaml
Normal file
@@ -0,0 +1,52 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="650"
|
||||
xmlns:views="clr-namespace:LibationAvalonia.Views"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
x:Class="LibationAvalonia.Controls.ThemePreviewControl">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<controls:GroupBox>
|
||||
<WrapPanel>
|
||||
<RadioButton Margin="5,0" Content="This is an option" IsChecked="True" />
|
||||
<RadioButton Margin="5,0" Content="This is another option" />
|
||||
<CheckBox Margin="5,0" Content="This is a check box" />
|
||||
<controls:WheelComboBox
|
||||
Margin="5,0"
|
||||
ItemsSource="{Binding ComboBoxItems}"
|
||||
SelectedIndex="{Binding ComboBoxSelectedIndex}" />
|
||||
<TextBox Margin="5,0" Text="This is an editable text box" />
|
||||
<TextBox Margin="5,0" Text="This is a read-only text box" IsReadOnly="True" />
|
||||
<NumericUpDown Margin="5,0" Value="100" />
|
||||
<controls:LinkLabel VerticalAlignment="Center" Margin="5,5" Text="This is an unvisited link" />
|
||||
<controls:LinkLabel VerticalAlignment="Center" Margin="5,5" Text="This is a visited link" Foreground="{DynamicResource HyperlinkVisited}" />
|
||||
<StackPanel Margin="5,0" Height="25" Orientation="Horizontal">
|
||||
<StackPanel.Styles>
|
||||
<Style Selector="Path">
|
||||
<Setter Property="Stretch" Value="Uniform" />
|
||||
<Setter Property="Margin" Value="3,5" />
|
||||
<Setter Property="Fill" Value="{DynamicResource IconFill}" />
|
||||
</Style>
|
||||
</StackPanel.Styles>
|
||||
<Path Data="{StaticResource QueuedIcon}" />
|
||||
<Path Data="{StaticResource QueueCompletedIcon}" />
|
||||
<Path Data="{StaticResource QueueErrorIcon}"/>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</controls:GroupBox>
|
||||
<WrapPanel Orientation="Horizontal" Grid.Row="1">
|
||||
<views:ProcessBookControl DataContext="{Binding QueuedBook}" ProcessBookStatus="{Binding Status}" />
|
||||
<views:ProcessBookControl DataContext="{Binding WorkingBook}" ProcessBookStatus="{Binding Status}" />
|
||||
<views:ProcessBookControl DataContext="{Binding CompletedBook}" ProcessBookStatus="{Binding Status}" />
|
||||
<views:ProcessBookControl DataContext="{Binding CancelledBook}" ProcessBookStatus="{Binding Status}" />
|
||||
<views:ProcessBookControl DataContext="{Binding FailedBook}" ProcessBookStatus="{Binding Status}" />
|
||||
|
||||
</WrapPanel>
|
||||
|
||||
<views:ProductsDisplay
|
||||
Grid.Row="2"
|
||||
DataContext="{Binding ProductsDisplay}" />
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,94 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media.Imaging;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationFileManager;
|
||||
using NPOI.Util.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Controls;
|
||||
|
||||
public partial class ThemePreviewControl : UserControl
|
||||
{
|
||||
public ProductsDisplayViewModel ProductsDisplay { get; set; }
|
||||
public string[] ComboBoxItems { get; } = Enumerable.Range(1, 9).Select(n => $"Combo box item {n}").ToArray();
|
||||
public int ComboBoxSelectedIndex { get; set; }
|
||||
|
||||
public ProcessBookViewModel QueuedBook { get; }
|
||||
public ProcessBookViewModel WorkingBook { get; }
|
||||
public ProcessBookViewModel CompletedBook { get; }
|
||||
public ProcessBookViewModel CancelledBook { get; }
|
||||
public ProcessBookViewModel FailedBook { get; }
|
||||
public ThemePreviewControl()
|
||||
{
|
||||
InitializeComponent();
|
||||
List<LibraryBook> sampleEntries;
|
||||
sampleEntries = CreateMockBooks().ToList();
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
using var ms1 = new MemoryStream();
|
||||
App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1);
|
||||
PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray());
|
||||
}
|
||||
|
||||
QueuedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Queued };
|
||||
WorkingBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Working };
|
||||
CompletedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Completed };
|
||||
CancelledBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Cancelled };
|
||||
FailedBook = new ProcessBookViewModel(sampleEntries[0], null) { Status = ProcessBookStatus.Failed };
|
||||
|
||||
//Set the current processable so that the empty queue doesn't try to advance.
|
||||
QueuedBook.AddDownloadPdf();
|
||||
WorkingBook.AddDownloadPdf();
|
||||
|
||||
typeof(ProcessBookViewModel).GetProperty(nameof(ProcessBookViewModel.Progress)).SetValue(WorkingBook, 50);
|
||||
|
||||
ProductsDisplay = new ProductsDisplayViewModel();
|
||||
_ = ProductsDisplay.BindToGridAsync(sampleEntries);
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
private IEnumerable<LibraryBook> CreateMockBooks()
|
||||
{
|
||||
var author = new Contributor("Some Author", "asin_contributor");
|
||||
var narrator = new Contributor("Some Narrator", "asin_narrator");
|
||||
|
||||
var book1 = new Book(new AudibleProductId("asin_book1"), "Some Book 1", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
|
||||
var book2 = new Book(new AudibleProductId("asin_book2"), "Some Book 2", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
|
||||
var book3 = new Book(new AudibleProductId("asin_book3"), "Some Book 3", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
|
||||
var book4 = new Book(new AudibleProductId("asin_book4"), "Some Book 4", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us");
|
||||
var seriesParent = new Book(new AudibleProductId("asin_series"), "Some Series", "", "Demo Series Entry", 0, ContentType.Parent, [author], [narrator], "us");
|
||||
var episode = new Book(new AudibleProductId("asin_episode"), "Some Episode", "Episode 1", "Demo Episode Entry", 56, ContentType.Episode, [author], [narrator], "us");
|
||||
|
||||
var series = new Series(new AudibleSeriesId(seriesParent.AudibleProductId), seriesParent.Title);
|
||||
|
||||
seriesParent.UpsertSeries(series, "");
|
||||
episode.UpsertSeries(series, "1");
|
||||
|
||||
book1.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
|
||||
book4.UserDefinedItem.BookStatus = LiberatedStatus.Error;
|
||||
//Set the backing field directly to preserve LiberatedStatus.PartialDownload
|
||||
typeof(UserDefinedItem).GetField("_bookStatus", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(book2.UserDefinedItem, LiberatedStatus.PartialDownload);
|
||||
|
||||
yield return new LibraryBook(book1, System.DateTime.Now.AddDays(4), "someone@email.co");
|
||||
yield return new LibraryBook(book2, System.DateTime.Now.AddDays(3), "someone@email.co");
|
||||
yield return new LibraryBook(book3, System.DateTime.Now.AddDays(2), "someone@email.co") { AbsentFromLastScan = true };
|
||||
yield return new LibraryBook(book4, System.DateTime.Now.AddDays(1), "someone@email.co");
|
||||
yield return new LibraryBook(seriesParent, System.DateTime.Now, "someone@email.co");
|
||||
yield return new LibraryBook(episode, System.DateTime.Now, "someone@email.co");
|
||||
}
|
||||
|
||||
private class MockProcessable : FileLiberator.Processable
|
||||
{
|
||||
public override string Name => nameof(MockProcessable);
|
||||
public override Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) => Task.FromResult(new StatusHandler());
|
||||
public override bool Validate(LibraryBook libraryBook) => false;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,12 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="520"
|
||||
MinWidth="400" MinHeight="520"
|
||||
Width="400" Height="520"
|
||||
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="540"
|
||||
MinWidth="450" MinHeight="540"
|
||||
Width="450" Height="540"
|
||||
x:Class="LibationAvalonia.Dialogs.AboutDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
Title="About Libation"
|
||||
Icon="/Assets/libation.ico">
|
||||
Title="About Libation">
|
||||
|
||||
<Grid Margin="10" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
@@ -42,30 +41,38 @@
|
||||
|
||||
<controls:GroupBox Grid.Row="3" Label="Acknowledgements" Grid.ColumnSpan="2">
|
||||
<StackPanel>
|
||||
<StackPanel.Styles>
|
||||
<Style Selector="controls|LinkLabel">
|
||||
<Setter Property="Margin" Value="5,0" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
</StackPanel.Styles>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto">
|
||||
<controls:LinkLabel FontWeight="Bold" Text="rmcrackan" Tapped="Link_GithubUser" />
|
||||
<TextBlock Grid.Column="1" Margin="10,0" Text="Creator" />
|
||||
<controls:LinkLabel Grid.Row="1" FontWeight="Bold" Text="Mbucari" Tapped="Link_GithubUser" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Margin="10,0" Text="Developer" />
|
||||
</Grid>
|
||||
<ItemsControl ItemsSource="{Binding PrimaryContributors}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:LinkLabel FontWeight="Bold" Text="{Binding Name}" Tapped="ContributorLink_Tapped" />
|
||||
<TextBlock Grid.Column="1" Margin="10,0" Text="{Binding Type}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock Margin="0,10" FontSize="12" Text="Additional Contributions by:" TextDecorations="Underline"/>
|
||||
|
||||
<WrapPanel>
|
||||
<WrapPanel.Styles>
|
||||
<Style Selector="controls|LinkLabel">
|
||||
<Setter Property="Margin" Value="5,0" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
</Style>
|
||||
</WrapPanel.Styles>
|
||||
<controls:LinkLabel Text="pixil98" Tapped="Link_GithubUser" />
|
||||
<controls:LinkLabel Text="hutattedonmyarm" Tapped="Link_GithubUser" />
|
||||
<controls:LinkLabel Text="seanke" Tapped="Link_GithubUser" />
|
||||
<controls:LinkLabel Text="wtanksleyjr" Tapped="Link_GithubUser" />
|
||||
<controls:LinkLabel Text="Dr.Blank" Tapped="Link_GithubUser" />
|
||||
<controls:LinkLabel Text="CharlieRussel" Tapped="Link_GithubUser" />
|
||||
</WrapPanel>
|
||||
<ItemsControl ItemsSource="{Binding AdditionalContributors}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<controls:LinkLabel Text="{Binding Name}" Tapped="ContributorLink_Tapped" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
@@ -48,11 +49,11 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
}
|
||||
|
||||
private void Link_GithubUser(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
private void ContributorLink_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
if (sender is LinkLabel lbl)
|
||||
if (sender is LinkLabel lbl && lbl.DataContext is LibationContributor contributor)
|
||||
{
|
||||
Dinah.Core.Go.To.Url($"ht" + $"tps://github.com/{lbl.Text.Replace('.','-')}");
|
||||
Dinah.Core.Go.To.Url(contributor.Link.AbsoluteUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +73,9 @@ namespace LibationAvalonia.Dialogs
|
||||
private bool canCheckForUpgrade = true;
|
||||
private string upgradeButtonText = "Check for Upgrade";
|
||||
|
||||
public IEnumerable<LibationContributor> PrimaryContributors => LibationContributor.PrimaryContributors;
|
||||
public IEnumerable<LibationContributor> AdditionalContributors => LibationContributor.AdditionalContributors;
|
||||
|
||||
public AboutVM()
|
||||
{
|
||||
Version = $"Libation {AppScaffolding.LibationScaffolding.Variety} v{AppScaffolding.LibationScaffolding.BuildVersion}";
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
Width="800" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.AccountsDialog"
|
||||
Title="Audible Accounts"
|
||||
Icon="/Assets/libation.ico">
|
||||
Title="Audible Accounts">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
|
||||
<Grid.Styles>
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
Width="650" Height="500"
|
||||
x:Class="LibationAvalonia.Dialogs.BookDetailsDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
Title="Book Details" Name="BookDetails"
|
||||
Icon="/Assets/libation.ico">
|
||||
Title="Book Details" Name="BookDetails">
|
||||
|
||||
<Grid RowDefinitions="*,Auto,Auto,40">
|
||||
<Grid.Styles>
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="450"
|
||||
Width="700" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.BookRecordsDialog"
|
||||
Title="BookRecordsDialog"
|
||||
Icon="/Assets/libation.ico">
|
||||
Title="BookRecordsDialog">
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Styling;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
@@ -9,100 +10,43 @@ namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public abstract class DialogWindow : Window
|
||||
{
|
||||
public bool SaveAndRestorePosition { get; set; } = true;
|
||||
protected bool CancelOnEscape { get; set; } = true;
|
||||
protected bool SaveOnEnter { get; set; } = true;
|
||||
public bool SaveAndRestorePosition { get; set; }
|
||||
public Control ControlToFocusOnShow { get; set; }
|
||||
protected override Type StyleKeyOverride => typeof(DialogWindow);
|
||||
|
||||
public static readonly StyledProperty<bool> UseCustomTitleBarProperty =
|
||||
AvaloniaProperty.Register<DialogWindow, bool>(nameof(UseCustomTitleBar));
|
||||
|
||||
public bool UseCustomTitleBar
|
||||
{
|
||||
get { return GetValue(UseCustomTitleBarProperty); }
|
||||
set { SetValue(UseCustomTitleBarProperty, value); }
|
||||
}
|
||||
|
||||
public DialogWindow()
|
||||
public DialogWindow(bool saveAndRestorePosition = true)
|
||||
{
|
||||
SaveAndRestorePosition = saveAndRestorePosition;
|
||||
KeyDown += DialogWindow_KeyDown;
|
||||
Initialized += DialogWindow_Initialized;
|
||||
Opened += DialogWindow_Opened;
|
||||
Loaded += DialogWindow_Loaded;
|
||||
Closing += DialogWindow_Closing;
|
||||
|
||||
UseCustomTitleBar = Configuration.IsWindows;
|
||||
if (Design.IsDesignMode)
|
||||
RequestedThemeVariant = ThemeVariant.Dark;
|
||||
}
|
||||
|
||||
private bool fixedMinHeight = false;
|
||||
private bool fixedMaxHeight = false;
|
||||
private bool fixedHeight = false;
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
private void DialogWindow_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
const int customTitleBarHeight = 30;
|
||||
if (UseCustomTitleBar)
|
||||
{
|
||||
if (change.Property == MinHeightProperty && !fixedMinHeight)
|
||||
{
|
||||
fixedMinHeight = true;
|
||||
MinHeight += customTitleBarHeight;
|
||||
fixedMinHeight = false;
|
||||
}
|
||||
if (change.Property == MaxHeightProperty && !fixedMaxHeight)
|
||||
{
|
||||
fixedMaxHeight = true;
|
||||
MaxHeight += customTitleBarHeight;
|
||||
fixedMaxHeight = false;
|
||||
}
|
||||
if (change.Property == HeightProperty && !fixedHeight)
|
||||
{
|
||||
fixedHeight = true;
|
||||
Height += customTitleBarHeight;
|
||||
fixedHeight = false;
|
||||
}
|
||||
}
|
||||
base.OnPropertyChanged(change);
|
||||
}
|
||||
|
||||
public DialogWindow(bool saveAndRestorePosition) : this()
|
||||
{
|
||||
SaveAndRestorePosition = saveAndRestorePosition;
|
||||
if (!CanResize)
|
||||
this.HideMinMaxBtns();
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
|
||||
if (!UseCustomTitleBar)
|
||||
return;
|
||||
|
||||
var closeButton = e.NameScope.Find<Button>("DialogCloseButton");
|
||||
var border = e.NameScope.Get<Border>("DialogWindowTitleBorder");
|
||||
var titleBlock = e.NameScope.Get<TextBlock>("DialogWindowTitleTextBlock");
|
||||
var icon = e.NameScope.Get<Avalonia.Controls.Shapes.Path>("DialogWindowTitleIcon");
|
||||
|
||||
closeButton.Click += CloseButton_Click;
|
||||
border.PointerPressed += Border_PointerPressed;
|
||||
icon.IsVisible = Icon != null;
|
||||
|
||||
if (MinHeight == MaxHeight && MinWidth == MaxWidth)
|
||||
{
|
||||
CanResize = false;
|
||||
border.Margin = new Thickness(0);
|
||||
icon.Margin = new Thickness(8, 5, 0, 5);
|
||||
Height = MinHeight;
|
||||
Width = MinWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void Border_PointerPressed(object sender, Avalonia.Input.PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(null).Properties.IsLeftButtonPressed)
|
||||
BeginMoveDrag(e);
|
||||
}
|
||||
|
||||
private void CloseButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
CancelAndClose();
|
||||
}
|
||||
|
||||
private void DialogWindow_Initialized(object sender, EventArgs e)
|
||||
{
|
||||
this.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
@@ -128,9 +72,9 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
private async void DialogWindow_KeyDown(object sender, Avalonia.Input.KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Avalonia.Input.Key.Escape)
|
||||
if (CancelOnEscape && e.Key == Avalonia.Input.Key.Escape)
|
||||
await CancelAndCloseAsync();
|
||||
else if (e.Key == Avalonia.Input.Key.Return)
|
||||
else if (SaveOnEnter && e.Key == Avalonia.Input.Key.Return)
|
||||
await SaveAndCloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="350"
|
||||
Width="800" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.EditQuickFilters"
|
||||
Title="Audible Accounts"
|
||||
Icon="/Assets/libation.ico"
|
||||
Title="Edit Quick Filters"
|
||||
x:DataType="dialogs:EditQuickFilters">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
|
||||
|
||||
@@ -42,6 +42,17 @@ namespace LibationAvalonia.Dialogs
|
||||
public EditQuickFilters()
|
||||
{
|
||||
InitializeComponent();
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
Filters = new ObservableCollection<Filter>([
|
||||
new Filter { Name = "Filter 1", FilterString = "[filter1 string]" },
|
||||
new Filter { Name = "Filter 2", FilterString = "[filter2 string]" },
|
||||
new Filter { Name = "Filter 3", FilterString = "[filter3 string]" },
|
||||
new Filter { Name = "Filter 4", FilterString = "[filter4 string]" }
|
||||
]);
|
||||
DataContext = this;
|
||||
return;
|
||||
}
|
||||
|
||||
// WARNING: accounts persister will write ANY EDIT to object immediately to file
|
||||
// here: copy strings and dispose of persister
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
MinWidth="500" MinHeight="450"
|
||||
Width="500" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.EditReplacementChars"
|
||||
Title="Illegal Character Replacement"
|
||||
Icon="/Assets/libation.ico">
|
||||
Title="Illegal Character Replacement">
|
||||
|
||||
<Grid
|
||||
RowDefinitions="*,Auto"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
Width="800" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.EditTemplateDialog"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
Icon="/Assets/libation.ico"
|
||||
Title="EditTemplateDialog">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
@@ -51,7 +50,7 @@
|
||||
<DataGridTemplateColumn Width="Auto" Header="Tag">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
|
||||
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
@@ -59,7 +58,7 @@
|
||||
<DataGridTemplateColumn Width="Auto" Header="Description">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextPresenter
|
||||
<TextBlock
|
||||
Height="18"
|
||||
Margin="10,0,10,0"
|
||||
VerticalAlignment="Center" Text="{Binding Item2}" />
|
||||
|
||||
@@ -1,185 +1,186 @@
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
namespace LibationAvalonia.Dialogs;
|
||||
|
||||
public partial class EditTemplateDialog : DialogWindow
|
||||
{
|
||||
public partial class EditTemplateDialog : DialogWindow
|
||||
private EditTemplateViewModel _viewModel;
|
||||
|
||||
public EditTemplateDialog()
|
||||
{
|
||||
private EditTemplateViewModel _viewModel;
|
||||
InitializeComponent();
|
||||
|
||||
public EditTemplateDialog()
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
_ = Configuration.Instance.LibationFiles;
|
||||
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
|
||||
_viewModel = new(Configuration.Instance, editor);
|
||||
_viewModel.ResetTextBox(editor.EditingTemplate.TemplateText);
|
||||
Title = $"Edit {editor.TemplateName}";
|
||||
DataContext = _viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
|
||||
|
||||
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
|
||||
_viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText);
|
||||
Title = $"Edit {templateEditor.TemplateName}";
|
||||
_ = Configuration.Instance.LibationFiles;
|
||||
RequestedThemeVariant = ThemeVariant.Dark;
|
||||
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
|
||||
_viewModel = new(Configuration.Instance, editor);
|
||||
_viewModel.ResetTextBox(editor.EditingTemplate.TemplateText);
|
||||
Title = $"Edit {editor.TemplateName}";
|
||||
DataContext = _viewModel;
|
||||
}
|
||||
}
|
||||
|
||||
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
|
||||
|
||||
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
|
||||
_viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText);
|
||||
Title = $"Edit {templateEditor.TemplateName}";
|
||||
DataContext = _viewModel;
|
||||
}
|
||||
|
||||
|
||||
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
var dataGrid = sender as DataGrid;
|
||||
|
||||
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
|
||||
if (string.IsNullOrWhiteSpace(item)) return;
|
||||
|
||||
var text = userEditTbox.Text;
|
||||
|
||||
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
|
||||
userEditTbox.CaretIndex += item.Length;
|
||||
}
|
||||
|
||||
protected override async Task SaveAndCloseAsync()
|
||||
{
|
||||
if (!await _viewModel.Validate())
|
||||
return;
|
||||
|
||||
await base.SaveAndCloseAsync();
|
||||
}
|
||||
|
||||
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await SaveAndCloseAsync();
|
||||
|
||||
private class EditTemplateViewModel : ViewModels.ViewModelBase
|
||||
{
|
||||
private readonly Configuration config;
|
||||
public InlineCollection Inlines { get; } = new();
|
||||
public ITemplateEditor TemplateEditor { get; }
|
||||
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
|
||||
{
|
||||
var dataGrid = sender as DataGrid;
|
||||
config = configuration;
|
||||
TemplateEditor = templates;
|
||||
Description = templates.TemplateDescription;
|
||||
ListItems
|
||||
= new AvaloniaList<Tuple<string, string, string>>(
|
||||
TemplateEditor
|
||||
.EditingTemplate
|
||||
.TagsRegistered
|
||||
.Cast<TemplateTags>()
|
||||
.Select(
|
||||
t => new Tuple<string, string, string>(
|
||||
$"<{t.TagName}>",
|
||||
t.Description,
|
||||
t.DefaultValue)
|
||||
)
|
||||
);
|
||||
|
||||
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
|
||||
if (string.IsNullOrWhiteSpace(item)) return;
|
||||
|
||||
var text = userEditTbox.Text;
|
||||
|
||||
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
|
||||
userEditTbox.CaretIndex += item.Length;
|
||||
}
|
||||
|
||||
protected override async Task SaveAndCloseAsync()
|
||||
// hold the work-in-progress value. not guaranteed to be valid
|
||||
private string _userTemplateText;
|
||||
public string UserTemplateText
|
||||
{
|
||||
if (!await _viewModel.Validate())
|
||||
return;
|
||||
|
||||
await base.SaveAndCloseAsync();
|
||||
}
|
||||
|
||||
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await SaveAndCloseAsync();
|
||||
|
||||
private class EditTemplateViewModel : ViewModels.ViewModelBase
|
||||
{
|
||||
private readonly Configuration config;
|
||||
public InlineCollection Inlines { get; } = new();
|
||||
public ITemplateEditor TemplateEditor { get; }
|
||||
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
|
||||
get => _userTemplateText;
|
||||
set
|
||||
{
|
||||
config = configuration;
|
||||
TemplateEditor = templates;
|
||||
Description = templates.TemplateDescription;
|
||||
ListItems
|
||||
= new AvaloniaList<Tuple<string, string, string>>(
|
||||
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
|
||||
templateTb_TextChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private string _warningText;
|
||||
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
|
||||
|
||||
public void ResetTextBox(string value) => UserTemplateText = value;
|
||||
public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate);
|
||||
|
||||
public async Task<bool> Validate()
|
||||
{
|
||||
if (TemplateEditor.EditingTemplate.IsValid)
|
||||
return true;
|
||||
|
||||
var errors
|
||||
= TemplateEditor
|
||||
.EditingTemplate
|
||||
.Errors
|
||||
.Select(err => $"- {err}")
|
||||
.Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void templateTb_TextChanged()
|
||||
{
|
||||
TemplateEditor.SetTemplateText(UserTemplateText);
|
||||
|
||||
const char ZERO_WIDTH_SPACE = '\u200B';
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
|
||||
// result: can wrap long paths. eg:
|
||||
// |-- LINE WRAP BOUNDARIES --|
|
||||
// \books\author with a very <= normal line break on space between words
|
||||
// long name\narrator narrator
|
||||
// \title <= line break on the zero-with space we added before slashes
|
||||
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
|
||||
|
||||
WarningText
|
||||
= !TemplateEditor.EditingTemplate.HasWarnings
|
||||
? ""
|
||||
: "Warning:\r\n" +
|
||||
TemplateEditor
|
||||
.EditingTemplate
|
||||
.TagsRegistered
|
||||
.Cast<TemplateTags>()
|
||||
.Select(
|
||||
t => new Tuple<string, string, string>(
|
||||
$"<{t.TagName}>",
|
||||
t.Description,
|
||||
t.DefaultValue)
|
||||
)
|
||||
);
|
||||
.Warnings
|
||||
.Select(err => $"- {err}")
|
||||
.Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
|
||||
}
|
||||
var bold = FontWeight.Bold;
|
||||
var reg = FontWeight.Normal;
|
||||
|
||||
// hold the work-in-progress value. not guaranteed to be valid
|
||||
private string _userTemplateText;
|
||||
public string UserTemplateText
|
||||
Inlines.Clear();
|
||||
|
||||
if (!TemplateEditor.IsFilePath)
|
||||
{
|
||||
get => _userTemplateText;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
|
||||
templateTb_TextChanged();
|
||||
}
|
||||
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
|
||||
return;
|
||||
}
|
||||
|
||||
private string _warningText;
|
||||
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
|
||||
var folder = TemplateEditor.GetFolderName();
|
||||
var file = TemplateEditor.GetFileName();
|
||||
var ext = config.DecryptToLossy ? "mp3" : "m4b";
|
||||
|
||||
public string Description { get; }
|
||||
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
|
||||
Inlines.Add(new Run(sing) { FontWeight = reg });
|
||||
|
||||
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
|
||||
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
|
||||
|
||||
public void ResetTextBox(string value) => UserTemplateText = value;
|
||||
public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate);
|
||||
Inlines.Add(new Run(sing));
|
||||
|
||||
public async Task<bool> Validate()
|
||||
{
|
||||
if (TemplateEditor.EditingTemplate.IsValid)
|
||||
return true;
|
||||
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
|
||||
|
||||
var errors
|
||||
= TemplateEditor
|
||||
.EditingTemplate
|
||||
.Errors
|
||||
.Select(err => $"- {err}")
|
||||
.Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void templateTb_TextChanged()
|
||||
{
|
||||
TemplateEditor.SetTemplateText(UserTemplateText);
|
||||
|
||||
const char ZERO_WIDTH_SPACE = '\u200B';
|
||||
var sing = $"{Path.DirectorySeparatorChar}";
|
||||
|
||||
// result: can wrap long paths. eg:
|
||||
// |-- LINE WRAP BOUNDARIES --|
|
||||
// \books\author with a very <= normal line break on space between words
|
||||
// long name\narrator narrator
|
||||
// \title <= line break on the zero-with space we added before slashes
|
||||
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
|
||||
|
||||
WarningText
|
||||
= !TemplateEditor.EditingTemplate.HasWarnings
|
||||
? ""
|
||||
: "Warning:\r\n" +
|
||||
TemplateEditor
|
||||
.EditingTemplate
|
||||
.Warnings
|
||||
.Select(err => $"- {err}")
|
||||
.Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
|
||||
var bold = FontWeight.Bold;
|
||||
var reg = FontWeight.Normal;
|
||||
|
||||
Inlines.Clear();
|
||||
|
||||
if (!TemplateEditor.IsFilePath)
|
||||
{
|
||||
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
|
||||
return;
|
||||
}
|
||||
|
||||
var folder = TemplateEditor.GetFolderName();
|
||||
var file = TemplateEditor.GetFileName();
|
||||
var ext = config.DecryptToLossy ? "mp3" : "m4b";
|
||||
|
||||
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
|
||||
Inlines.Add(new Run(sing) { FontWeight = reg });
|
||||
|
||||
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
|
||||
|
||||
Inlines.Add(new Run(sing));
|
||||
|
||||
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
|
||||
|
||||
Inlines.Add(new Run($".{ext}"));
|
||||
}
|
||||
Inlines.Add(new Run($".{ext}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
MinWidth="500" MinHeight="500"
|
||||
Width="500" Height="520"
|
||||
Title="Cover"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/libation.ico">
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Image Stretch="Uniform" Source="{Binding CoverImage}">
|
||||
<Image.ContextMenu>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="165"
|
||||
MinHeight="165" MaxHeight="165"
|
||||
MinWidth="800" MaxWidth="800"
|
||||
Width="800" Height="165"
|
||||
x:Class="LibationAvalonia.Dialogs.LibationFilesDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Title="Book Details"
|
||||
Icon="/Assets/libation.ico">
|
||||
Title="Book Details">
|
||||
|
||||
<Grid
|
||||
RowDefinitions="Auto,Auto">
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
MinHeight="135" MaxHeight="135"
|
||||
MinWidth="550" MaxWidth="550"
|
||||
Width="550" Height="135"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/libation.ico">
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid Margin="10" RowDefinitions="Auto,Auto">
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
MinWidth="400" MinHeight="100"
|
||||
MaxWidth="400" MaxHeight="100"
|
||||
Width="400" Height="100"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/libation.ico">
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
Width="600" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.LocateAudiobooksDialog"
|
||||
Title="Locate Audiobooks"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/libation.ico">
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid Margin="5" ColumnDefinitions="*,Auto" RowDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Text="Found Audiobooks" />
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
Width="240" Height="140"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
x:Class="LibationAvalonia.Dialogs.Login.ApprovalNeededDialog"
|
||||
Title="Approval Alert Detected"
|
||||
Icon="/Assets/libation.ico">
|
||||
Title="Approval Alert Detected">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Threading;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Dialogs.Login
|
||||
{
|
||||
public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
@@ -23,14 +23,14 @@ namespace LibationAvalonia.Dialogs.Login
|
||||
LoginCallback = new AvaloniaLoginCallback(_account);
|
||||
}
|
||||
|
||||
public async Task<ChoiceOut> StartAsync(ChoiceIn choiceIn)
|
||||
public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
|
||||
{
|
||||
if (Configuration.IsWindows && Environment.OSVersion.Version.Major >= 10)
|
||||
{
|
||||
try
|
||||
{
|
||||
var weblogin = new WebLoginDialog(_account.AccountId, choiceIn.LoginUrl);
|
||||
if (await weblogin.ShowDialog<DialogResult>(App.MainWindow) is DialogResult.OK)
|
||||
if (await weblogin.ShowDialogAsync(App.MainWindow) is DialogResult.OK)
|
||||
return ChoiceOut.External(weblogin.ResponseUrl);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||