mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-09 12:17:22 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa829df265 | ||
|
|
e84a0a121a | ||
|
|
087c107685 | ||
|
|
af7a1e8f69 | ||
|
|
cbbd8b7955 | ||
|
|
edc737df4a | ||
|
|
8050e11a41 | ||
|
|
bdf55009b5 | ||
|
|
a5a28c9d62 | ||
|
|
70909a40dc | ||
|
|
a922cfdf40 | ||
|
|
bc806bada0 | ||
|
|
36207bbab3 | ||
|
|
f4dee94785 | ||
|
|
3d563a2dcc | ||
|
|
67b476eaa0 | ||
|
|
a0adf75b53 | ||
|
|
014beaf71f | ||
|
|
74a028acaa | ||
|
|
66bf8b9de9 | ||
|
|
635780a4cf | ||
|
|
82fb235de9 | ||
|
|
336a01db01 | ||
|
|
a9df4add33 | ||
|
|
129486ce89 | ||
|
|
3e7191adc7 | ||
|
|
4cd784787e | ||
|
|
5503a9d768 | ||
|
|
094e207c0b | ||
|
|
09be09bd1b | ||
|
|
19a867488b | ||
|
|
9bfdaa9632 | ||
|
|
eca013e780 | ||
|
|
5f814a55eb | ||
|
|
f980600a2c | ||
|
|
75c25fefe4 | ||
|
|
7538491f32 | ||
|
|
5268e0c375 | ||
|
|
e1449c9b1e | ||
|
|
c7f6815014 | ||
|
|
5b2c620bf5 | ||
|
|
72927943c2 | ||
|
|
b02aedb579 | ||
|
|
3ab22a9796 | ||
|
|
47980e1f86 | ||
|
|
673f8f3b06 | ||
|
|
52325e7ae8 | ||
|
|
ebb1d9ff4c | ||
|
|
3fbd0e1286 | ||
|
|
0c0b72cd87 | ||
|
|
c22efa2c0f | ||
|
|
bfaab98eff | ||
|
|
bc72aad30c | ||
|
|
31ad21f69d | ||
|
|
1ab18f5f60 | ||
|
|
77cb6bf78e | ||
|
|
1c32149c2b | ||
|
|
06356f249a | ||
|
|
c4b4fa6971 | ||
|
|
5c5d1bd946 | ||
|
|
23ecdf56ee | ||
|
|
1729f84dbb | ||
|
|
178b7ee1d8 | ||
|
|
5ff1a4f813 | ||
|
|
272241bed6 | ||
|
|
f39751da17 | ||
|
|
612ec05b19 | ||
|
|
e4dde1e6c8 | ||
|
|
c2f9392fa8 | ||
|
|
e965cc6c59 | ||
|
|
e0c780c998 | ||
|
|
f870612f92 | ||
|
|
2497e3a03e | ||
|
|
1431fd73a9 | ||
|
|
c01e83f844 | ||
|
|
04a7103ae8 | ||
|
|
df04be9dec | ||
|
|
1520647659 | ||
|
|
5956c83041 | ||
|
|
b1452dabc3 | ||
|
|
bf017ac28c | ||
|
|
798602c56f | ||
|
|
a0045156c1 | ||
|
|
8402010a2a | ||
|
|
59ab316358 | ||
|
|
5c61ebd741 | ||
|
|
b56e55bee2 | ||
|
|
cad813e28b | ||
|
|
0e6523e0ae | ||
|
|
0489be0350 | ||
|
|
bf78563746 | ||
|
|
7aecda4259 | ||
|
|
a0ef5c3d95 | ||
|
|
d4248200d1 | ||
|
|
aecffc4584 | ||
|
|
bb593fe8d1 | ||
|
|
819193fe03 | ||
|
|
76e036fb42 | ||
|
|
5e1ac3f9cc | ||
|
|
326e48c663 | ||
|
|
c376af9a95 | ||
|
|
c6e5e37f7f | ||
|
|
df9702997d | ||
|
|
edf0d32508 | ||
|
|
5f7df75c85 | ||
|
|
1839eebb20 | ||
|
|
75f1d753b1 | ||
|
|
cc40d5e494 | ||
|
|
5ef3f3e11e | ||
|
|
03dab18223 | ||
|
|
492d2e9ae3 | ||
|
|
b6875591f0 | ||
|
|
29a9cb3873 | ||
|
|
1f38ba8abf | ||
|
|
8b166312c6 | ||
|
|
f816ccf3e4 | ||
|
|
1eddccbe63 | ||
|
|
84c6f6fed0 | ||
|
|
d36369b321 | ||
|
|
5d0ed3af68 | ||
|
|
059b817b03 | ||
|
|
0c66a2ede9 | ||
|
|
b38e298ddc | ||
|
|
e9e56182b8 | ||
|
|
819a0960be | ||
|
|
0b03be5d02 | ||
|
|
7cb58c7912 | ||
|
|
10c1cc2a60 | ||
|
|
49e8164b6f | ||
|
|
a030853d6b | ||
|
|
1d9b41c2a4 | ||
|
|
1e81c86f65 | ||
|
|
5ce147ef83 | ||
|
|
7e89a96ffc | ||
|
|
8c1b754180 | ||
|
|
0cecaad686 | ||
|
|
63dc8fc988 | ||
|
|
6a166b23fb | ||
|
|
ed20356af9 | ||
|
|
316099b42f | ||
|
|
71e8d8eea1 | ||
|
|
cb8c1b3a3e | ||
|
|
d4b5a48b79 | ||
|
|
f0d344099a | ||
|
|
a60299efe6 | ||
|
|
e97c68257d | ||
|
|
afd970c672 | ||
|
|
4a138bb42f | ||
|
|
3b33ee2c43 | ||
|
|
88243825eb | ||
|
|
0ca01c338d | ||
|
|
d23b87d2ad | ||
|
|
955c5ca9b2 | ||
|
|
0934701d75 | ||
|
|
cb01b9b3b1 | ||
|
|
fc973d4f5f | ||
|
|
2aa6d8c078 | ||
|
|
bc297330ad | ||
|
|
b959d878aa | ||
|
|
cc50f183c0 | ||
|
|
bf9b85331b | ||
|
|
6288bc543e | ||
|
|
d000a8ee19 | ||
|
|
12cbc7e07d | ||
|
|
4df08d383a | ||
|
|
a42c332cf4 | ||
|
|
240454efa8 | ||
|
|
099b01af14 | ||
|
|
af163df6ca | ||
|
|
0efe50b57a | ||
|
|
a0ca574974 | ||
|
|
d240ab12ec | ||
|
|
cc90740de4 | ||
|
|
6fa65238db | ||
|
|
c827b9bb8d | ||
|
|
63778135c6 | ||
|
|
383d768f46 | ||
|
|
381cf307f7 | ||
|
|
d7a794e557 | ||
|
|
524e1ced50 | ||
|
|
2b7214e3dd | ||
|
|
325af3a491 | ||
|
|
ac19e9e80b | ||
|
|
e49f410544 | ||
|
|
9383a0456e | ||
|
|
5ca43895f2 | ||
|
|
986dda5eaa | ||
|
|
2db63a8edd | ||
|
|
ad59032693 | ||
|
|
e11186da99 | ||
|
|
e90850f16d | ||
|
|
d93d325913 |
No files matched your search
@@ -66,10 +66,20 @@ jobs:
|
||||
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
|
||||
"-p:SelfContained=true")
|
||||
|
||||
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
|
||||
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
|
||||
# PowerShell does not stop at a native command that fails, and the step is judged by the exit code
|
||||
# of the last one, so a publish that fails anywhere but the end passes as a green build. That is not
|
||||
# hypothetical: LibationWinForms failed to compile for a while and every run stayed green, uploading
|
||||
# a Libation-Classic zip with no Libation.exe in it. Check each one.
|
||||
$projects = @(
|
||||
"Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj",
|
||||
"LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj",
|
||||
"LibationCli/LibationCli.csproj",
|
||||
"Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj")
|
||||
|
||||
foreach ($project in $projects) {
|
||||
dotnet publish "$project" $PUBLISH_ARGS
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed for $project" }
|
||||
}
|
||||
|
||||
- name: Zip artifact
|
||||
id: zip
|
||||
@@ -81,6 +91,11 @@ jobs:
|
||||
"WindowsConfigApp.deps.json")
|
||||
|
||||
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
|
||||
|
||||
# Both UIs publish as Libation.exe. upload-artifact's if-no-files-found only proves the zip exists,
|
||||
# which it does whether or not the app is inside it, so say outright that it has to be.
|
||||
if (-not (Test-Path "Libation.exe")) { throw "Libation.exe is missing from the publish output" }
|
||||
|
||||
$artifact="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||
Compress-Archive -Path * -DestinationPath "$artifact"
|
||||
|
||||
@@ -99,6 +99,10 @@ export default defineConfig({
|
||||
link: "/docs/features/daily-download-limit",
|
||||
},
|
||||
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
|
||||
{
|
||||
text: "Parallel Downloads",
|
||||
link: "/docs/features/parallel-downloads",
|
||||
},
|
||||
{
|
||||
text: "Retrying Refused Downloads",
|
||||
link: "/docs/features/retrying-refused-downloads",
|
||||
@@ -111,6 +115,7 @@ export default defineConfig({
|
||||
text: "Easy guide to searching",
|
||||
link: "/docs/features/lucene",
|
||||
},
|
||||
{ text: "Trash Bin", link: "/docs/features/trash-bin" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -119,6 +124,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
|
||||
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
|
||||
{ text: "Device registration", link: "/docs/advanced/device-registration" },
|
||||
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
|
||||
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
|
||||
],
|
||||
|
||||
@@ -15,10 +15,16 @@ plus a headless CLI (`LibationCli`) that shares the same config and SQLite libra
|
||||
`dotnet restore Source/Libation.slnx`. No other setup is needed.
|
||||
- The solution file is the new XML format: `Source/Libation.slnx` (there is no classic `.sln`).
|
||||
|
||||
### Building — Linux only builds the cross-platform projects
|
||||
- Do **not** build the whole solution on Linux. Several projects are Windows/OS-specific and will
|
||||
not build here: `LibationWinForms`, `HangoverWinForms` (target `net10.0-windows7.0`) and the
|
||||
`LoadByOS/{Windows,MacOS}ConfigApp` helpers.
|
||||
### Building — everything compiles on Linux, only some of it runs
|
||||
- `dotnet build Source/Libation.slnx` succeeds on Linux with 0 errors. So do
|
||||
`LibationWinForms`, `HangoverWinForms` and the `LoadByOS/{Windows,MacOS}ConfigApp` helpers,
|
||||
individually: they target `net10.0-windows7.0` but set `EnableWindowsTargeting`, which is what makes
|
||||
a non-Windows SDK build them. **Always compile-check a change to the WinForms projects** — an earlier
|
||||
version of this file said they could not be built here, and a whole PR's worth of WinForms edits went
|
||||
out unverified on the strength of that.
|
||||
- Compiling is not running. WinForms has no Linux runtime, so behaviour and layout in
|
||||
`LibationWinForms` / `HangoverWinForms` still need a human on Windows. Say so explicitly rather than
|
||||
implying a WinForms change was tested.
|
||||
- Build the runnable cross-platform apps directly:
|
||||
- `dotnet build Source/LibationAvalonia/LibationAvalonia.csproj`
|
||||
- `dotnet build Source/LibationCli/LibationCli.csproj`
|
||||
|
||||
@@ -2,4 +2,12 @@
|
||||
<PropertyGroup>
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The SDK supplies Microsoft.SourceLink.Common and the git tasks, which is enough to find the
|
||||
repository but not to turn its URL into fetchable source URLs. Without a host provider every
|
||||
project warns that the generated source link is empty and ships PDBs that cannot locate their
|
||||
sources. -->
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.301" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -47,7 +47,7 @@ We welcome contributions!
|
||||
## Community & Support
|
||||
|
||||
- **[Issues](https://github.com/rmcrackan/Libation/issues)**: Report bugs or request features.
|
||||
- **[PayPal](https://paypal.me/mcrackan?locale.x=en_us)**: Support the project if you find it useful.
|
||||
- **[Donate](https://getlibation.com/donate)**: Support the project if you find it useful.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ Developer utilities. None of these ship in a Libation install - they exist only
|
||||
|
||||
| Script | Purpose | Documented in |
|
||||
|--------|---------|---------------|
|
||||
| `seed-demo-accounts.cs` | Seed fake Audible accounts, to reach what is gated on having one without signing in | [Testing Changes](https://getlibation.com/docs/development/testing) |
|
||||
| `seed-demo-library.cs` | Seed a library covering every Liberate-column icon, for manual UI testing | [Testing Changes](https://getlibation.com/docs/development/testing) |
|
||||
| `seed-download-history.cs` | Seed completed downloads so the daily download limit can be tested without downloading | [Testing Changes](https://getlibation.com/docs/development/testing) |
|
||||
| `Bundle_Debian.sh` | Build the Linux `.deb` package | Used by `.github/workflows/build-linux.yml` |
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#:package AudibleApi@11.0.3.1
|
||||
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleApi.Cryptography;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
// Adds fake Audible accounts to AccountsSettings.json, so the parts of Libation that only appear once an
|
||||
// account exists can be tested without signing in to Audible.
|
||||
//
|
||||
// dotnet run Scripts/seed-demo-accounts.cs [--count 1] [--clean] [path-to-Libation-files-folder]
|
||||
//
|
||||
// Run Libation at least once first so AccountsSettings.json exists, and close it before seeding: the file is
|
||||
// read at startup and written back on save, so a running app would overwrite whatever this wrote.
|
||||
//
|
||||
// The accounts carry structurally valid but meaningless tokens. Libation will count them, list them and
|
||||
// enable everything that is gated on having an account, but any scan or download attempt fails at Audible.
|
||||
//
|
||||
// Real accounts in the file are left alone. --clean removes only the accounts this script added, which are
|
||||
// the ones whose AccountId matches demo*@example.com.
|
||||
//
|
||||
// Keep the package version above in step with AudibleApi in Source/AudibleUtilities/AudibleUtilities.csproj,
|
||||
// so the JSON this writes is the shape the app expects to read.
|
||||
|
||||
// Fixture material. A syntactically valid RSA key and ADP token so the identity parses; neither authenticates.
|
||||
const string SamplePrivateKey = @"
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpgIBAAKCAQEA5nPbGSVDmlEH2tJa6kz/P2HI8IeirhfPHdmi+X/nsb9i3WNf
|
||||
tmEdZxfK26IValQDXvBH17a1gr0HD6pYse1XsV2w0HxiW1RW+ZnjL8/fzPdkSOb+
|
||||
4xKlqRopCueBSdDGgAF06spZ3IeHLfEFOJX4dO1Y73pFBUkA0k53LT12L2Tjay/r
|
||||
buZHJqIzxmwja7/nkiWL0Xo7UySHtQACYsKEatu6yHBS+cPTlGR/qeUpeJTHwDLP
|
||||
7ZQ7kWzJGY1mfInYekjlZLsMsWswso3pg1vPyHgxzM2BWhY8m6mlXQ9G/USxBTib
|
||||
MNuMtpR73XsgamneFCc+Uv1cxw7ofZ41YOOAbQIDAQABAoIBAQDIre8HkKm0Aggj
|
||||
B7df/TjxCsgenR6PF/Cmf9UqC7XJ1W3UeCrq+NrP4aonZJfdhdeBnyAQuuyJMu6p
|
||||
N6ARISuSKpJEm2xTN7idluJ9yjmLlYtg6LbhKmXUQhGniz3M999DrQERTLDAF80h
|
||||
tpbjVcWMnPsrX4AnQBFVEjs5zCHU1hD+X463EmUHBWyT975jbZ8Fy7/fTzkdzLnn
|
||||
qE5lROALr2MCAAwQRFbRE6dd52vnXaBrVcAtRzjATts3WG3+SNi2Fm/OrYqQcY9e
|
||||
lBexNviT8VcldOAMrO10E2u0d+tvxFzwB3ABMvaVamrEZky4XSfB6aLzpD0JJj1s
|
||||
UHnIiVwJAoGBAPl8nLll/J9rud/N2HiAX2YkP0MC0HW4yM3KxLtXKyXrP5qBpaci
|
||||
wTDUmSWEEE3GUJMM1Z4d9tl9Lz2MhU2KqkEvLI3kQ7aUu33PYUBGMVcUzhFQ49lU
|
||||
Nzz8YB183iqo31o/DKk2Cr5gI7SykQZ0gn/urZkEJeErLzlhPXcyeY5jAoGBAOx4
|
||||
CGucVdv5MbdXZP8jVzxuvUlSp7BIQJ2phQXDFBNApFKnZn7yBYBx7dqzleymGm+R
|
||||
INZAurg3SNw4nvbQc3Z2dJ8I+n5ErjFCKp1IedVxx1eMEfecTwrQZuUwLISIyjqF
|
||||
czSJNwcNqzCx67z397/Cg5K/0pu6uIe0r7xozcbvAoGBAOOvZ9CDVPOg+rdXQvFm
|
||||
Jqou9lUPonNtOkUlgjl+qfAnK5q0KxvHSgxoWYO1bLOuAybQlbuBmSCPcKd5MMa9
|
||||
f/eRN9YetfVQ83Mz6YshBDJ22EFRUz+p7eeIY6dFp/PCvmO8Gq/qlA996dglBtmf
|
||||
RuG+T0vQT0mZgbWaGuBHfkwFAoGBAMOLg1MRxgKRMKavk6pU3EfyP3+J5XemWCDI
|
||||
1WLtbgV5uClNmzmxBBGypQHs7jbzKPtHpULn5kB+HzdVb0clG8ZDsK7u6s5OF0pO
|
||||
sBS+oVl7rF/eSeFcFhUYP26ZhsbWo3z/bERuj926VO2AxDPRTsP5o3pQPGZhY0V9
|
||||
irGgbUJrAoGBAOseS3J4BqYM4R3Hr7cRAhvzSjIkeTcDF1zTOa4FZDHBxZ6g2PNq
|
||||
8ekhtfn1zPczsPTF1vNuqEISKLxaPkVPiw0mtaZQjVwpF/IOxMNjWVLp6oJf8Mm2
|
||||
BxlXqPnQ4mG66oqSFQgDEmFdMhRb2of6xL1gYYL62C80G2T7QtmPfSab
|
||||
-----END RSA PRIVATE KEY-----
|
||||
";
|
||||
|
||||
const string SampleAdpToken = "{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}";
|
||||
|
||||
const string DemoAccountSuffix = "@example.com";
|
||||
const string DemoAccountPrefix = "demo";
|
||||
|
||||
var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase);
|
||||
var count = IntArg("--count") ?? 1;
|
||||
var pathArg = args.FirstOrDefault(a => !a.StartsWith("--") && !int.TryParse(a, out _));
|
||||
|
||||
if (count < 1)
|
||||
{
|
||||
Console.Error.WriteLine("--count must be at least 1.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (FindLibationFiles(pathArg) is not string libationFiles)
|
||||
{
|
||||
Console.Error.WriteLine("Could not find a Libation accounts file.");
|
||||
Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:");
|
||||
Console.Error.WriteLine(@" dotnet run Scripts/seed-demo-accounts.cs C:\Users\me\AppData\Local\Libation");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var accountsPath = Path.Combine(libationFiles, "AccountsSettings.json");
|
||||
Console.WriteLine($"Accounts file: {accountsPath}");
|
||||
|
||||
var root = JObject.Parse(File.ReadAllText(accountsPath));
|
||||
if (root["Accounts"] is not JArray accounts)
|
||||
{
|
||||
accounts = [];
|
||||
root["Accounts"] = accounts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this script wrote the account, decided from its id rather than a marker property.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A property would be tidier but does not survive: AudibleUtilities.Account serializes exactly five members
|
||||
/// and has no [JsonExtensionData], so Newtonsoft ignores an extra property on the way in and never writes it
|
||||
/// back out. Verified by adding one and renaming the account in the app - the save dropped it. Anything that
|
||||
/// dirties an account does the same, including a token refresh during a scan, and --clean would then leave
|
||||
/// demo accounts behind with no way to find them. Giving the shipping Account class a demo flag to serve a
|
||||
/// dev script is not a trade worth making.
|
||||
///
|
||||
/// The id is the right anchor instead: it is immutable, always persisted, and example.com is reserved by
|
||||
/// RFC 2606, so no real Audible login can collide with it.
|
||||
/// </remarks>
|
||||
static bool IsDemo(JToken account)
|
||||
=> account["AccountId"]?.Value<string>() is string id
|
||||
&& id.StartsWith(DemoAccountPrefix, StringComparison.OrdinalIgnoreCase)
|
||||
&& id.EndsWith(DemoAccountSuffix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (clean)
|
||||
{
|
||||
var removed = accounts.Where(IsDemo).ToList();
|
||||
foreach (var account in removed)
|
||||
account.Remove();
|
||||
|
||||
File.WriteAllText(accountsPath, root.ToString(Formatting.Indented));
|
||||
Console.WriteLine($"Removed {removed.Count} demo account(s). {accounts.Count} account(s) remain.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var realAccounts = accounts.Count(a => !IsDemo(a));
|
||||
if (realAccounts > 0)
|
||||
Console.WriteLine($"Leaving {realAccounts} real account(s) untouched.");
|
||||
|
||||
var added = 0;
|
||||
foreach (var accountId in DemoAccountIds(count))
|
||||
{
|
||||
if (accounts.Any(a => string.Equals(a["AccountId"]?.Value<string>(), accountId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Console.WriteLine($" {accountId} already present, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
accounts.Add(BuildAccount(accountId));
|
||||
added++;
|
||||
}
|
||||
|
||||
File.WriteAllText(accountsPath, root.ToString(Formatting.Indented));
|
||||
|
||||
Console.WriteLine($"Added {added} demo account(s). Libation will now see {accounts.Count} account(s).");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Start Libation. What the account count changes:");
|
||||
Console.WriteLine(" 1 account Import > Scan Library, Import > Remove Library Books");
|
||||
Console.WriteLine(" 2 or more Import > Scan Library of All / Some Accounts, and the matching Remove items");
|
||||
Console.WriteLine(" any an empty library offers 'Scan Library' rather than 'Add Account'");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Do not scan or download: these accounts have meaningless tokens and Audible will refuse them.");
|
||||
return 0;
|
||||
|
||||
/// <summary>
|
||||
/// The first account matches the one seed-demo-library.cs assigns its books to, so seeded books and a
|
||||
/// seeded account describe the same library rather than two unrelated ones.
|
||||
/// </summary>
|
||||
static IEnumerable<string> DemoAccountIds(int count)
|
||||
{
|
||||
yield return $"{DemoAccountPrefix}{DemoAccountSuffix}";
|
||||
|
||||
for (var n = 2; n <= count; n++)
|
||||
yield return $"{DemoAccountPrefix}{n}{DemoAccountSuffix}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An account entry carrying a registered-looking identity. The values are fixture material, the same shape
|
||||
/// AudibleApi writes after a real registration, so the file parses and the account loads.
|
||||
/// </summary>
|
||||
static JObject BuildAccount(string accountId)
|
||||
{
|
||||
var identity = new Identity(Localization.Get("us"));
|
||||
identity.Update(
|
||||
new PrivateKey(SamplePrivateKey),
|
||||
new AdpToken(SampleAdpToken),
|
||||
new AccessToken($"Atna|_DEMO_ACCESS_{accountId}", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)),
|
||||
new RefreshToken($"Atnr|_DEMO_REFRESH_{accountId}"),
|
||||
[new KeyValuePair<string, Dinah.Core.Security.SecretString>("session-id", "demo-cookie-value")],
|
||||
deviceSerialNumber: "demo-device-serial",
|
||||
deviceType: "demo-device-type",
|
||||
amazonAccountId: "demo-amazon-account",
|
||||
deviceName: "Demo Device",
|
||||
storeAuthenticationCookie: "demo-store-auth-cookie");
|
||||
|
||||
// Write the tokens as plaintext rather than reaching for the OS secret store, which this script has no
|
||||
// business touching. Libation reads either, whatever its own Token storage preference is set to.
|
||||
IdentityTokenStorage.Configure(TokenStorageMethod.Plaintext, protector: null);
|
||||
|
||||
return new JObject
|
||||
{
|
||||
["AccountId"] = accountId,
|
||||
["AccountName"] = $"Demo ({accountId})",
|
||||
["LibraryScan"] = true,
|
||||
["DecryptKey"] = "",
|
||||
["IdentityTokens"] = JObject.Parse(JsonConvert.SerializeObject(identity, Identity.GetJsonSerializerSettings()))
|
||||
};
|
||||
}
|
||||
|
||||
int? IntArg(string name)
|
||||
{
|
||||
var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
return i >= 0 && i + 1 < args.Length && int.TryParse(args[i + 1], out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>Locate the Libation files folder the same way Libation itself does.</summary>
|
||||
static string? FindLibationFiles(string? explicitPath)
|
||||
{
|
||||
var candidates = new List<string>();
|
||||
|
||||
if (explicitPath is not null)
|
||||
candidates.Add(explicitPath);
|
||||
|
||||
foreach (var folder in KnownLibationFolders())
|
||||
{
|
||||
candidates.Add(folder);
|
||||
|
||||
// appsettings.json may redirect the files folder somewhere else entirely.
|
||||
var appSettings = Path.Combine(folder, "appsettings.json");
|
||||
if (!File.Exists(appSettings))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
if (System.Text.Json.JsonDocument.Parse(File.ReadAllText(appSettings)).RootElement
|
||||
.TryGetProperty("LibationFiles", out var redirect) && redirect.GetString() is string path)
|
||||
candidates.Add(path);
|
||||
}
|
||||
catch (System.Text.Json.JsonException) { }
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "AccountsSettings.json")));
|
||||
}
|
||||
|
||||
static IEnumerable<string> KnownLibationFolders()
|
||||
{
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation");
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation");
|
||||
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Libation");
|
||||
yield return Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}");
|
||||
yield return AppContext.BaseDirectory;
|
||||
}
|
||||
@@ -14,6 +14,9 @@ using System.Text.Json;
|
||||
// Reproducing every icon by hand is tedious, and two of them are not database state at all:
|
||||
// the yellow lamp means "a partial download is sitting on disk", so this writes placeholder
|
||||
// .aaxc files as well.
|
||||
//
|
||||
// Writing rows directly also leaves Libation's search index stale, which makes every filter on the
|
||||
// seeded books come back empty, so this deletes the index and lets Libation rebuild it.
|
||||
|
||||
const int NotLiberated = 0, Liberated = 1, Error = 2;
|
||||
const int Product = 1, Episode = 2, Parent = 4;
|
||||
@@ -52,6 +55,7 @@ if (clean)
|
||||
File.Delete(partial);
|
||||
|
||||
Console.WriteLine($"Removed {removed} demo book(s) and their placeholder downloads.");
|
||||
InvalidateSearchIndex();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -97,12 +101,13 @@ foreach (var book in books)
|
||||
Execute(
|
||||
"""
|
||||
insert into LibraryBooks (BookId, AbsentFromLastScan, Account, DateAdded, IncludedUntil, IsAudiblePlus, IsDeleted)
|
||||
values ($bookId, $isAbsent, 'demo@example.com', '2026-08-13 00:00:00', $includedUntil, $isPlus, 0)
|
||||
values ($bookId, $isAbsent, 'demo@example.com', '2026-08-13 00:00:00', $includedUntil, $isPlus, $isDeleted)
|
||||
""",
|
||||
("$bookId", bookId),
|
||||
("$isAbsent", book.IsAbsent ? 1 : 0),
|
||||
("$includedUntil", book.IsPlus ? "2027-01-31 00:00:00" : (object)DBNull.Value),
|
||||
("$isPlus", book.IsPlus ? 1 : 0));
|
||||
("$isPlus", book.IsPlus ? 1 : 0),
|
||||
("$isDeleted", book.IsDeleted ? 1 : 0));
|
||||
|
||||
Execute(
|
||||
"insert into BookContributor (BookId, ContributorId, Role, [Order]) values ($bookId, $contributorId, 1, 0)",
|
||||
@@ -135,6 +140,7 @@ foreach (var book in books)
|
||||
|
||||
transaction.Commit();
|
||||
Console.WriteLine($"Added {added} book(s).");
|
||||
InvalidateSearchIndex();
|
||||
|
||||
if (partials.Count > 0)
|
||||
{
|
||||
@@ -146,10 +152,26 @@ if (partials.Count > 0)
|
||||
Console.WriteLine($"Wrote {partials.Count} placeholder download(s) to {inProgress}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Filtering covers these books. Try HasSubtitle , TitleHasColon , or Trashed for no matches.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Start Libation and sort by Title. Expected Liberate column, top to bottom:");
|
||||
foreach (var book in books)
|
||||
foreach (var book in books.Where(b => !b.IsDeleted))
|
||||
Console.WriteLine($" {book.Title,-46} {book.Expectation}");
|
||||
|
||||
var trashed = books.Where(b => b.IsDeleted).ToList();
|
||||
if (trashed.Count > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"{trashed.Count} seeded book(s) are in the trash, so they are NOT in the grid:");
|
||||
foreach (var book in trashed)
|
||||
Console.WriteLine($" {book.Title,-46} {book.Expectation}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" - the status bar should end with a clickable \"N in trash\"");
|
||||
Console.WriteLine(" - Settings > Trash Bin should carry the same count");
|
||||
Console.WriteLine(" - filtering for Trashed should find nothing and offer to open the trash bin");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Do not click the stoplights: these books are not real and cannot be downloaded.");
|
||||
return 0;
|
||||
@@ -264,6 +286,42 @@ static List<DemoBook> BuildDemoBooks()
|
||||
Expectation: expectation,
|
||||
IsAbsent: true));
|
||||
|
||||
// Books in the trash. Removal is a soft delete, and a trashed book is filtered out of the grid, the
|
||||
// search index and every status count at once, so nothing on screen distinguishes one from a book that
|
||||
// was never imported. These rows are how that is checked: they must stay out of the grid while the
|
||||
// status bar, the Settings > Trash Bin menu item and the empty-search hint all account for them.
|
||||
foreach (var (label, bookStatus, isPlus, expectation) in new (string, int, bool, string)[]
|
||||
{
|
||||
("purchased", NotLiberated, false, "in the trash bin only, red lamp there"),
|
||||
("PLUS", Liberated, true, "in the trash bin only, green lamp with badge there"),
|
||||
})
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Trashed | {label}",
|
||||
ContentType: Product,
|
||||
BookStatus: bookStatus,
|
||||
PdfStatus: null,
|
||||
IsPlus: isPlus,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: expectation,
|
||||
IsDeleted: true));
|
||||
|
||||
// A trashed episode whose parent is still in the library. The trash bin asks for every parent, not just
|
||||
// deleted ones, so this should appear nested under the series there while the series keeps its other
|
||||
// episodes in the main grid.
|
||||
books.Add(new DemoBook(
|
||||
Asin: $"{AsinPrefix}{++n:000}",
|
||||
Title: $"{n:00} Demo Series - episode 3 (trashed)",
|
||||
ContentType: Episode,
|
||||
BookStatus: NotLiberated,
|
||||
PdfStatus: null,
|
||||
IsPlus: false,
|
||||
NeedsPartialDownload: false,
|
||||
Expectation: "nested under Demo Series in the trash bin, absent from the grid",
|
||||
SeriesAsin: seriesAsin,
|
||||
SeriesOrder: "3",
|
||||
IsDeleted: true));
|
||||
|
||||
// Titles that subtitle removal changes. <title short> stops at the first colon, so it cuts a colon in
|
||||
// Audible's own title just as readily as it drops Audible's subtitle field, and the grid's Title column
|
||||
// shows both the same way. The HasSubtitle and TitleHasColon filters are the only way to tell them apart.
|
||||
@@ -347,6 +405,31 @@ string DownloadsInProgress()
|
||||
return Path.Combine(inProgress, "DownloadsInProgress");
|
||||
}
|
||||
|
||||
/// <summary>Delete the Lucene index so Libation rebuilds it from the database.</summary>
|
||||
/// <remarks>
|
||||
/// This script writes rows straight to SQLite, which leaves the index untouched, so every filter on the
|
||||
/// main grid came back empty no matter what had been seeded. Libation treats a missing index as a full
|
||||
/// re-index rather than an error, so deleting the folder is enough: the first search rebuilds it from the
|
||||
/// library, and the rebuild excludes trashed books the same way the grid does.
|
||||
/// </remarks>
|
||||
void InvalidateSearchIndex()
|
||||
{
|
||||
var searchEngine = Path.Combine(libationFiles, "SearchEngine");
|
||||
if (!Directory.Exists(searchEngine))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(searchEngine, recursive: true);
|
||||
Console.WriteLine("Deleted the search index. Libation rebuilds it on the first search or filter.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
Console.Error.WriteLine($"Could not delete the search index at {searchEngine}: {ex.Message}");
|
||||
Console.Error.WriteLine("Close Libation and delete that folder by hand, or filtering will not find the seeded books.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Look up a row's id by its Audible id, inserting it first if it isn't there yet.</summary>
|
||||
/// <remarks>
|
||||
/// Neither Contributors.AudibleContributorId nor Series.AudibleSeriesId is unique, so an upsert
|
||||
@@ -396,4 +479,5 @@ record DemoBook(
|
||||
string? SeriesAsin = null,
|
||||
string? SeriesOrder = null,
|
||||
bool IsAbsent = false,
|
||||
string Subtitle = "");
|
||||
string Subtitle = "",
|
||||
bool IsDeleted = false);
|
||||
@@ -21,4 +21,8 @@
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -179,13 +179,17 @@ public class NetworkFileStream : Stream, IUpdatable
|
||||
DownloadTask = Task.Run(() => DownloadLoopInternal(client, response), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
// avoid infinite retry loops if the server consistently fails for a particular title
|
||||
private const int MAX_TLS_RETRIES = 5;
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
{
|
||||
try
|
||||
{
|
||||
long startPosition = WritePosition;
|
||||
var startPosition = WritePosition;
|
||||
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
var tlsRetryCount = 0;
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -206,8 +210,22 @@ public class NetworkFileStream : Stream, IUpdatable
|
||||
blockResponse = await RequestNextByteRangeAsync(client);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException e)
|
||||
when (e.InnerException is System.ComponentModel.Win32Exception { NativeErrorCode: -2146893008 }
|
||||
&& WritePosition != startPosition
|
||||
&& WritePosition < ContentLength && !IsCancelled
|
||||
&& ++tlsRetryCount <= MAX_TLS_RETRIES)
|
||||
{
|
||||
Serilog.Log.Logger.Warning($"TLS decryption failure at position 0x{WritePosition:X10}. Reconnecting and resuming download.");
|
||||
|
||||
_writeFile.Position = startPosition = WritePosition;
|
||||
blockResponse.Dispose();
|
||||
blockResponse = await RequestNextByteRangeAsync(client);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.7.10</Version>
|
||||
<Version>14.2.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
@@ -16,6 +16,9 @@
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
|
||||
@@ -208,12 +208,34 @@ public static class LibationScaffolding
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
=> config.EnsureSerilogConfig();
|
||||
|
||||
/// <summary>
|
||||
/// Hands everything <see cref="StartupLog"/> collected before this point to Serilog, and points it at
|
||||
/// Serilog from here on. Startup runs long before logging exists, so without this those diagnostics are
|
||||
/// simply dropped. This is also the only place in Libation that maps a startup entry onto a Serilog level.
|
||||
/// </summary>
|
||||
private static void replayStartupLog()
|
||||
=> StartupLog.ReplayTo(entry =>
|
||||
{
|
||||
var level = entry.Level switch
|
||||
{
|
||||
StartupLogLevel.Debug => Serilog.Events.LogEventLevel.Debug,
|
||||
StartupLogLevel.Warning => Serilog.Events.LogEventLevel.Warning,
|
||||
StartupLogLevel.Error => Serilog.Events.LogEventLevel.Error,
|
||||
_ => Serilog.Events.LogEventLevel.Information,
|
||||
};
|
||||
|
||||
// The message is already rendered: startup cannot build a Serilog template without
|
||||
// referencing Serilog, which is the whole point of StartupLog. See issue #2001.
|
||||
Log.Logger.Write(level, entry.Exception, "[startup {StartupTimestamp:HH:mm:ss.fff}] {StartupMessage}", entry.Timestamp, entry.Message);
|
||||
});
|
||||
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
|
||||
private static void configureLogging(Configuration config)
|
||||
{
|
||||
config.ConfigureLogging();
|
||||
replayStartupLog();
|
||||
Log.Information(
|
||||
"Paths: LibationFiles={LibationFiles} AppsettingsJson={AppsettingsJson} SQLiteDb={SqliteDb}",
|
||||
config.LibationFiles.Location,
|
||||
@@ -289,11 +311,20 @@ public static class LibationScaffolding
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
|
||||
// -1 means the count could not be taken. Listing a directory no longer throws when it stops being
|
||||
// readable partway through, so an incomplete walk has to be asked about rather than caught: a count
|
||||
// that silently means "as many as I managed to read" is worse than no count in a bug report.
|
||||
static int fileCount(FileManager.LongPath? longPath)
|
||||
{
|
||||
if (longPath is null)
|
||||
return -1;
|
||||
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||
|
||||
var complete = true;
|
||||
try
|
||||
{
|
||||
var count = FileManager.FileUtility.SaferEnumerateFiles(longPath, onIncomplete: _ => complete = false).Count();
|
||||
return complete ? count : -1;
|
||||
}
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
@@ -305,6 +336,11 @@ public static class LibationScaffolding
|
||||
Configuration.OS,
|
||||
Environment.OSVersion,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
// A file Windows refused to load, or a half-applied upgrade, is usually explained by one
|
||||
// of these three, and none of them is visible anywhere else in a bug report.
|
||||
InstallFolder = Configuration.ProcessDirectory,
|
||||
ApplicationControl = ApplicationControlPolicy.GetState(),
|
||||
InstallFolderCloudSync = CloudSyncedFolders.GetSyncStatus(Configuration.ProcessDirectory),
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
@@ -318,7 +354,8 @@ public static class LibationScaffolding
|
||||
config.ImportEpisodes,
|
||||
config.ImportPlusTitles,
|
||||
config.DownloadEpisodes,
|
||||
config.BetaOptIn,
|
||||
// Off means no startup upgrade prompt, which is otherwise indistinguishable from a broken check
|
||||
config.CheckForUpgradesAtStartup,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Dinah.Core;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// What to tell the user about titles a run left alone because the last library scan did not find them.
|
||||
/// <para>
|
||||
/// Audible will not license a title it no longer lists for the account - a returned title, or one that has
|
||||
/// left the Plus catalog - so attempting one collects a refusal every run and downloads nothing. Both hosts
|
||||
/// say it in these words so that the same skip does not read as two different things.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AbsentFromLastScanUserMessage
|
||||
{
|
||||
/// <summary>The reason, short enough to head a list of skip reasons.</summary>
|
||||
public const string Label = "Absent from your last library scan";
|
||||
|
||||
/// <summary>What to do about it.</summary>
|
||||
public const string Advice = "run Scan, or `libationcli scan`, then try again";
|
||||
|
||||
/// <summary>The line a CLI run prints in place of attempting them.</summary>
|
||||
public static string BuildCliSkippedSummary(int count)
|
||||
=> $"Skipped {"title".PluralizeWithCount(count)} absent from your last library scan. "
|
||||
+ $"Audible will not license a title it no longer lists, so {Advice}. To attempt them anyway: libationcli liberate --force.";
|
||||
}
|
||||
@@ -6,10 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0">
|
||||
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="ClosedXML" Version="0.105.0">
|
||||
<PackageReference Include="ClosedXML" Version="0.105.1">
|
||||
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
@@ -21,6 +18,10 @@
|
||||
<ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -60,7 +60,7 @@ public class BulkSetDownloadStatus
|
||||
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Download Pending'",
|
||||
LiberatedStatus.NotLiberated,
|
||||
books2change));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>Column header for csv and xlsx export. Mirrors the CsvHelper attribute it replaced.</summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
internal sealed class NameAttribute(params string[] names) : Attribute
|
||||
{
|
||||
public string[] Names { get; } = names;
|
||||
}
|
||||
|
||||
/// <summary>Excludes a property from csv export. Mirrors the CsvHelper attribute it replaced.</summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
internal sealed class IgnoreAttribute : Attribute { }
|
||||
|
||||
/// <summary>
|
||||
/// Minimal write-only CSV serializer, output-compatible with the way this project used
|
||||
/// CsvHelper: the delimiter is the culture's list separator, records end with CRLF,
|
||||
/// fields containing the delimiter, quotes, newlines, or leading/trailing spaces are
|
||||
/// quoted per RFC 4180, and values are formatted with the culture. Columns are the
|
||||
/// public instance properties of each record's runtime type, in declaration order,
|
||||
/// honoring <see cref="NameAttribute"/> and <see cref="IgnoreAttribute"/>.
|
||||
/// </summary>
|
||||
internal sealed class CsvWriter(TextWriter writer, CultureInfo culture) : IDisposable
|
||||
{
|
||||
private readonly string delimiter = culture.TextInfo.ListSeparator;
|
||||
private readonly Dictionary<Type, List<(string Header, PropertyInfo Property)>> columnCache = new();
|
||||
private bool rowHasFields;
|
||||
|
||||
public void WriteHeader(Type type)
|
||||
{
|
||||
foreach (var (header, _) in getColumns(type))
|
||||
writeField(header);
|
||||
}
|
||||
|
||||
public void NextRecord()
|
||||
{
|
||||
writer.Write("\r\n");
|
||||
rowHasFields = false;
|
||||
}
|
||||
|
||||
public void WriteRecords<T>(IEnumerable<T> records) where T : notnull
|
||||
{
|
||||
foreach (var record in records)
|
||||
{
|
||||
foreach (var (_, property) in getColumns(record.GetType()))
|
||||
writeField(toString(property.GetValue(record)));
|
||||
NextRecord();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => writer.Dispose();
|
||||
|
||||
private void writeField(string field)
|
||||
{
|
||||
if (rowHasFields)
|
||||
writer.Write(delimiter);
|
||||
writer.Write(escape(field));
|
||||
rowHasFields = true;
|
||||
}
|
||||
|
||||
private string escape(string field)
|
||||
{
|
||||
if (field.Length == 0)
|
||||
return field;
|
||||
|
||||
var needsQuoting
|
||||
= field.Contains('"')
|
||||
|| field[0] == ' '
|
||||
|| field[^1] == ' '
|
||||
|| field.Contains(delimiter)
|
||||
|| field.Contains('\r')
|
||||
|| field.Contains('\n');
|
||||
|
||||
return needsQuoting ? $"\"{field.Replace("\"", "\"\"")}\"" : field;
|
||||
}
|
||||
|
||||
private string toString(object? value)
|
||||
=> value switch
|
||||
{
|
||||
null => "",
|
||||
string s => s,
|
||||
IFormattable formattable => formattable.ToString(null, culture),
|
||||
_ => value.ToString() ?? ""
|
||||
};
|
||||
|
||||
private List<(string Header, PropertyInfo Property)> getColumns(Type type)
|
||||
{
|
||||
if (!columnCache.TryGetValue(type, out var columns))
|
||||
columnCache[type] = columns = type
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.GetMethod is not null
|
||||
&& p.GetIndexParameters().Length == 0
|
||||
&& p.GetCustomAttribute<IgnoreAttribute>() is null)
|
||||
.Select(p => (p.GetCustomAttribute<NameAttribute>()?.Names.FirstOrDefault() ?? p.Name, p))
|
||||
.ToList();
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,23 @@ public static class DbContexts
|
||||
return context.GetDeletedLibraryBooks();
|
||||
}
|
||||
|
||||
/// <summary>How many books belong in the search index. A row count only; no entities are loaded.</summary>
|
||||
public static int GetIndexableBookCount()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetIndexableBookCount();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many <see cref="LibraryBook"/> rows are in the trash. A row count only; no entities are loaded,
|
||||
/// so this is cheap enough to refresh whenever the library changes.
|
||||
/// </summary>
|
||||
public static int GetTrashedBookCount()
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibraryBookCountsByTrashFlag().InTrash;
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
|
||||
{
|
||||
using var context = GetContext();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
@@ -11,6 +11,7 @@ using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -62,33 +63,31 @@ public static class LibraryCommands
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
|
||||
var scan = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var libraryItems = scan.Items;
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
var existing = existingLibrary?.ToList() ?? [];
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
EnsureScanCanIdentifyInactiveBooks(totalCount, existing.Count, scan.FailedAccounts);
|
||||
|
||||
var inactive = existing.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
|
||||
Log.Logger.Information(
|
||||
"Books absent from the library scan. {@DebugInfo}",
|
||||
new { InactiveCount = inactive.Count, CandidateCount = existing.Count, ScannedCount = totalCount });
|
||||
|
||||
return inactive;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
|
||||
{
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
@@ -107,6 +106,22 @@ public static class LibraryCommands
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuse to call anything "no longer in your library" from a scan that could not see the whole library.
|
||||
/// Importing tolerates a partial scan because it only adds and updates; removal cannot, because a book
|
||||
/// missing from one bad scan is indistinguishable from a book the user no longer owns.
|
||||
/// </summary>
|
||||
/// <exception cref="LibraryScanIncompleteException">The scan cannot support that decision.</exception>
|
||||
public static void EnsureScanCanIdentifyInactiveBooks(int scannedItemCount, int existingBookCount, IReadOnlyCollection<string>? failedAccounts)
|
||||
{
|
||||
if (failedAccounts?.Count > 0)
|
||||
throw LibraryScanIncompleteException.ForFailedAccounts(failedAccounts);
|
||||
|
||||
// An account whose every book vanished at once is a broken scan, not an emptied library.
|
||||
if (scannedItemCount == 0 && existingBookCount > 0)
|
||||
throw LibraryScanIncompleteException.ForEmptyScan(existingBookCount);
|
||||
}
|
||||
|
||||
#region FULL LIBRARY scan and import
|
||||
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
|
||||
@@ -136,10 +151,14 @@ public static class LibraryCommands
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished
|
||||
//product_details is the only group that returns the copyright line, which Libation
|
||||
//needs for Widevine downloads: those files arrive without an embedded cprt tag.
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDetails,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin);
|
||||
//Importing only adds and updates, so a partially scanned library is still worth importing.
|
||||
var importItems = (await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin)).Items;
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
@@ -156,25 +175,12 @@ public static class LibraryCommands
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx))
|
||||
{
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Authentication failed.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
@@ -275,9 +281,18 @@ public static class LibraryCommands
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
|
||||
/// <summary>Outcome of scanning one or more accounts.</summary>
|
||||
/// <param name="Items">Everything the accounts that did scan returned.</param>
|
||||
/// <param name="FailedAccounts">
|
||||
/// Accounts that could not be scanned. Their books are absent from <paramref name="Items"/> for that reason
|
||||
/// alone, so a caller deciding what to remove must not treat the result as the whole library.
|
||||
/// </param>
|
||||
private sealed record ScanResult(List<ImportItem> Items, IReadOnlyCollection<string> FailedAccounts);
|
||||
|
||||
private static async Task<ScanResult> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
var failedAccounts = new List<string>();
|
||||
|
||||
await using LogArchiver? archiver
|
||||
= Log.Logger.IsDebugEnabled()
|
||||
@@ -290,11 +305,22 @@ public static class LibraryCommands
|
||||
{
|
||||
try
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin);
|
||||
// An account is scanned as a unit: all of its marketplaces or none of them. A partial scan would
|
||||
// leave the marketplaces that did not run looking empty, and AbsentFromLastScan - which is keyed
|
||||
// by account id, not by marketplace - would mark their titles absent.
|
||||
var accountTasks = new List<Task<List<ImportItem>>>();
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||
// ScanLocales leads with the account's own marketplace, so any login happens once, up front
|
||||
foreach (var locale in account.ScanLocales)
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin, storeLocale: locale);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
accountTasks.Add(scanAccountAsync(apiExtended, account, locale, libraryOptions, archiver));
|
||||
}
|
||||
|
||||
tasks.AddRange(accountTasks);
|
||||
}
|
||||
catch (Exception ex) when (!allowInteractiveLogin && AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
|
||||
{
|
||||
@@ -304,32 +330,39 @@ public static class LibraryCommands
|
||||
{
|
||||
//Catch to allow other accounts to continue scanning.
|
||||
Log.Logger.Error(ex, "Failed to scan account");
|
||||
failedAccounts.Add(account.MaskedLogEntry ?? account.AccountId ?? "[unknown account]");
|
||||
}
|
||||
}
|
||||
|
||||
// import library in parallel
|
||||
var arrayOfLists = await Task.WhenAll(tasks);
|
||||
var importItems = arrayOfLists.SelectMany(a => a).ToList();
|
||||
return importItems;
|
||||
return new ScanResult(importItems, failedAccounts);
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
|
||||
/// <param name="locale">
|
||||
/// The marketplace being read. Usually the account's own; for an account holding titles under a second
|
||||
/// storefront, each of those in turn. Books are tagged with it, which is how a download later finds its way
|
||||
/// back to the right store.
|
||||
/// </param>
|
||||
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, Locale locale, LibraryOptions libraryOptions, LogArchiver? archiver)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
|
||||
ArgumentValidator.EnsureNotNull(locale, nameof(locale));
|
||||
|
||||
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
|
||||
{
|
||||
Account = account.MaskedLogEntry ?? "[null]"
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
|
||||
logTime($"pre scanAccountAsync {account.AccountName}");
|
||||
logTime($"pre scanAccountAsync {account.AccountName} {locale.Name}");
|
||||
|
||||
try
|
||||
{
|
||||
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions);
|
||||
|
||||
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
|
||||
logTime($"post scanAccountAsync {account.AccountName} {locale.Name} qty: {dtoItems.Count}");
|
||||
|
||||
await logDtoItemsAsync(dtoItems);
|
||||
|
||||
@@ -346,12 +379,13 @@ public static class LibraryCommands
|
||||
{
|
||||
if (archiver is not null)
|
||||
{
|
||||
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json";
|
||||
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry} {locale.Name}.json";
|
||||
var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson)));
|
||||
|
||||
var scanFile = new JObject
|
||||
{
|
||||
{ "Account", account.MaskedLogEntry },
|
||||
{ "Locale", locale.Name },
|
||||
{ "ScannedDateTime", DateTime.Now.ToString("u") },
|
||||
};
|
||||
|
||||
@@ -404,13 +438,39 @@ public static class LibraryCommands
|
||||
#endregion
|
||||
|
||||
#region remove/restore books
|
||||
|
||||
/// <summary>
|
||||
/// Record every change to the trash. Removal is a soft delete, so a book can leave the library and sit
|
||||
/// out of sight indefinitely; without this the log cannot say when that happened or how much is in there.
|
||||
/// </summary>
|
||||
private static void logTrashChange(string action, int qtyChanges)
|
||||
{
|
||||
if (qtyChanges < 1)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Log.Logger.Information("Trash bin changed. {@DebugInfo}", new
|
||||
{
|
||||
Action = action,
|
||||
Books = qtyChanges,
|
||||
BooksInTrash = DbContexts.GetTrashedBookCount()
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//The change itself already succeeded. Never fail it over a log line.
|
||||
Log.Logger.Warning(ex, "Trash bin changed ({action}, {qtyChanges}) but the trash count could not be read", action, qtyChanges);
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook?>? idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
private static int removeBooks(IEnumerable<LibraryBook?>? removeLibraryBooks)
|
||||
{
|
||||
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
var qtyChanges = DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
// Entry() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in removeLibraryBooks.OfType<LibraryBook>())
|
||||
@@ -419,6 +479,9 @@ public static class LibraryCommands
|
||||
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
});
|
||||
|
||||
logTrashChange("Moved to trash", qtyChanges);
|
||||
return qtyChanges;
|
||||
}
|
||||
|
||||
public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
|
||||
@@ -428,7 +491,7 @@ public static class LibraryCommands
|
||||
return 0;
|
||||
try
|
||||
{
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
var qtyChanges = DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
// Entry() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in libraryBooks)
|
||||
@@ -437,6 +500,9 @@ public static class LibraryCommands
|
||||
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
});
|
||||
|
||||
logTrashChange("Restored from trash", qtyChanges);
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -452,11 +518,14 @@ public static class LibraryCommands
|
||||
return 0;
|
||||
try
|
||||
{
|
||||
return DoDbSizeChangeOperation(ctx =>
|
||||
var qtyChanges = DoDbSizeChangeOperation(ctx =>
|
||||
{
|
||||
ctx.LibraryBooks.RemoveRange(libraryBooks.OfType<LibraryBook>());
|
||||
ctx.Books.RemoveRange(libraryBooks.OfType<LibraryBook>().Select(lb => lb.Book));
|
||||
});
|
||||
|
||||
logTrashChange("Permanently deleted", qtyChanges);
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -673,7 +742,12 @@ public static class LibraryCommands
|
||||
}
|
||||
}
|
||||
|
||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
|
||||
/// <param name="requestedBy">
|
||||
/// Names the caller in the log. Both UIs count the whole library and the visible subset as the
|
||||
/// library loads, and the two produce identical lines whenever no filter is applied, so without
|
||||
/// this there is no way to tell which set a given count describes.
|
||||
/// </param>
|
||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null, [CallerMemberName] string requestedBy = "")
|
||||
{
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
|
||||
@@ -689,7 +763,7 @@ public static class LibraryCommands
|
||||
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
|
||||
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
|
||||
|
||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||
Log.Logger.Information("Book counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||
|
||||
var pdfResults = libraryBooks
|
||||
.AsParallel()
|
||||
@@ -699,9 +773,11 @@ public static class LibraryCommands
|
||||
|
||||
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
|
||||
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||
// Error means Audible has a supplement listed for the title but will not deliver one, so the count it
|
||||
// belongs in is the one for PDFs Libation cannot get. Without this such a title is in no count at all.
|
||||
var pdfsUnavailable = pdfResults.Count(r => r.status == LiberatedStatus.Error || (r.absent && r.status == LiberatedStatus.NotLiberated));
|
||||
|
||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||
Log.Logger.Information("PDF counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// A library scan finished without throwing but cannot be trusted to say what is no longer in the account.
|
||||
/// Deciding "inactive" from such a scan would offer to remove books the user still owns.
|
||||
/// </summary>
|
||||
public class LibraryScanIncompleteException : Exception
|
||||
{
|
||||
/// <summary>Accounts that could not be scanned, if that is why the scan is untrustworthy.</summary>
|
||||
public IReadOnlyCollection<string> FailedAccounts { get; }
|
||||
|
||||
public LibraryScanIncompleteException(string message, IReadOnlyCollection<string>? failedAccounts = null)
|
||||
: base(message)
|
||||
=> FailedAccounts = failedAccounts ?? [];
|
||||
|
||||
internal static LibraryScanIncompleteException ForFailedAccounts(IReadOnlyCollection<string> failedAccounts)
|
||||
=> new(
|
||||
$"{"account".PluralizeWithCount(failedAccounts.Count)} could not be scanned, so Libation cannot tell which books are no longer in your library: "
|
||||
+ string.Join(", ", failedAccounts),
|
||||
failedAccounts);
|
||||
|
||||
internal static LibraryScanIncompleteException ForEmptyScan(int existingBookCount)
|
||||
=> new(
|
||||
$"The library scan returned no books while Libation still holds {"book".PluralizeWithCount(existingBookCount)}. "
|
||||
+ "Treating that as an empty Audible library would offer to remove books you still own.");
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using AudibleApi.Common;
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
|
||||
@@ -23,6 +23,7 @@ public static class SearchEngineCommands
|
||||
lock (IndexLock)
|
||||
{
|
||||
var engine = new SearchEngine();
|
||||
repairShortIndex(engine);
|
||||
try
|
||||
{
|
||||
return func(engine);
|
||||
@@ -40,6 +41,59 @@ public static class SearchEngineCommands
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set once the index has been measured against the library, so the check costs one query per run.</summary>
|
||||
private static bool indexCounted;
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds an index that holds fewer books than the library does.
|
||||
/// <para>
|
||||
/// A book the index never received is not merely unsearchable. Filtering intersects the grid with the
|
||||
/// query's hits, so a positive term such as <c>Absent</c> cannot return it, while the negated <c>-Absent</c>
|
||||
/// resolves to "every document in the index" and therefore drops it from the grid - which looks exactly
|
||||
/// like the negated filter working correctly. Reported as issue #1989.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing else notices: the index is only ever written as a whole, and <see cref="tryUpdate"/>
|
||||
/// deliberately swallows a rebuild that fails so a bad index cannot fail a good scan. A short index
|
||||
/// therefore stays short until something happens to change the library again.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void repairShortIndex(SearchEngine engine)
|
||||
{
|
||||
if (indexCounted)
|
||||
return;
|
||||
|
||||
// before the work, not after: a check that throws must not run on every query for the rest of the session
|
||||
indexCounted = true;
|
||||
|
||||
try
|
||||
{
|
||||
var indexed = engine.GetIndexedBookCount();
|
||||
|
||||
// no index yet, or one too damaged to read. Both already have their own recovery.
|
||||
if (indexed < 0)
|
||||
return;
|
||||
|
||||
var expected = DbContexts.GetIndexableBookCount();
|
||||
|
||||
// more documents than books is stale rather than harmful: an extra document matches no grid row.
|
||||
if (indexed >= expected)
|
||||
return;
|
||||
|
||||
Log.Warning("The search index holds {Indexed} of {Expected} books. Rebuilding it: search cannot find the rest, and a negated filter hides them.", indexed, expected);
|
||||
|
||||
var failures = fullReIndex(engine);
|
||||
|
||||
if (failures > 0)
|
||||
Log.Error("{Failures} book(s) could not be added to the search index. Search cannot find them, and a negated filter hides them.", failures);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Searching with a short index still beats not searching at all.
|
||||
Log.Error(ex, "Could not check the search index against the library.");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
public static event EventHandler? SearchEngineUpdated;
|
||||
@@ -92,7 +146,7 @@ public static class SearchEngineCommands
|
||||
}
|
||||
}
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||
public static void FullReIndex() => performSafeCommand(e => fullReIndex(e));
|
||||
public static void FullReIndex(List<LibraryBook> libraryBooks)
|
||||
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
|
||||
|
||||
@@ -147,13 +201,15 @@ public static class SearchEngineCommands
|
||||
}
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine)
|
||||
/// <returns>How many books could not be indexed.</returns>
|
||||
private static int fullReIndex(SearchEngine engine)
|
||||
{
|
||||
var library = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
fullReIndex(engine, library);
|
||||
return fullReIndex(engine, library);
|
||||
}
|
||||
|
||||
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
|
||||
/// <returns>How many books could not be indexed.</returns>
|
||||
private static int fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
|
||||
=> engine.CreateNewIndex(libraryBooks);
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using DataLayer;
|
||||
using Serilog;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Answers "is the book you searched for in the trash?".
|
||||
/// A trashed book is filtered out of the library and out of the search index, so searching for one returns
|
||||
/// nothing at all and looks exactly like a book that was never imported (issue #1925).
|
||||
/// </summary>
|
||||
public static class TrashBinSearch
|
||||
{
|
||||
/// <summary>
|
||||
/// Books in the trash matching <paramref name="searchString"/>, using the same query syntax as the
|
||||
/// library search so a fielded query means the same thing in both places.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Indexes the trash on the spot rather than keeping an index in step with it. Call only once a library
|
||||
/// search has already come back empty: that happens on Enter, at human speed, so the work is rare.
|
||||
/// Returns nothing rather than throwing; this only ever powers a hint.
|
||||
/// </remarks>
|
||||
public static List<LibraryBook> Search(string? searchString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
return [];
|
||||
|
||||
try
|
||||
{
|
||||
//GetDeletedLibraryBooks also returns parents so the trash grid can nest episodes; only the
|
||||
//genuinely deleted are candidates here.
|
||||
var trashed = DbContexts.GetDeletedLibraryBooks().Where(lb => lb.IsDeleted).ToList();
|
||||
if (trashed.Count == 0)
|
||||
return [];
|
||||
|
||||
var searchEngine = new TempSearchEngine();
|
||||
if (!searchEngine.ReindexSearchEngine(trashed))
|
||||
return [];
|
||||
|
||||
if (searchEngine.GetSearchResultSet(searchString) is not { } results)
|
||||
return [];
|
||||
|
||||
var matchedIds = results.Docs.Select(d => d.ProductId).ToHashSet();
|
||||
return trashed.Where(lb => matchedIds.Contains(lb.Book.AudibleProductId)).ToList();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Failed to search the trash bin for {searchString}", searchString);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ using Dinah.Core.Security;
|
||||
using LibationFileManager;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
@@ -86,9 +88,121 @@ public class Account : IUpdatable, ILogMasked
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The marketplace this account is registered with: where it logs in, and the only place its tokens can be
|
||||
/// refreshed. Every account has exactly one.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public Locale? Locale => IdentityTokens?.Locale;
|
||||
|
||||
private readonly List<string> _additionalLocaleNames = new();
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Further marketplaces this same login holds a library in, beyond <see cref="Locale"/>. A title bought while
|
||||
/// an Amazon address was temporarily set to another country stays in that country's library forever, and only
|
||||
/// a scan of that marketplace will ever see it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only marketplace names live here - no credentials. Audible honors one device registration across every
|
||||
/// marketplace, so these are read with the very tokens <see cref="Locale"/> registered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written as null rather than <c>[]</c> when empty, so that the settings file of an account with one
|
||||
/// marketplace - which is nearly all of them - is exactly what it was before this property existed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonProperty(PropertyName = "AdditionalLocaleNames", NullValueHandling = NullValueHandling.Ignore)]
|
||||
private List<string>? _additionalLocaleNames_json
|
||||
{
|
||||
get => _additionalLocaleNames.Count == 0 ? null : _additionalLocaleNames;
|
||||
// 'set' is only used by json deser
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
_additionalLocaleNames.Clear();
|
||||
foreach (var name in value.Select(canonicalize).OfType<string>())
|
||||
if (!_additionalLocaleNames.Contains(name) && name != Locale?.Name)
|
||||
_additionalLocaleNames.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The additional marketplaces, resolved. Names that no longer match a known locale are dropped, as is the
|
||||
/// registered marketplace: json is applied in document order, so a file listing these before its
|
||||
/// IdentityTokens would slip a duplicate past the check on the way in, and this marketplace would then be
|
||||
/// scanned twice.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Locale> AdditionalLocales
|
||||
=> _additionalLocaleNames
|
||||
.Select(Localization.Get)
|
||||
.Where(l => !string.IsNullOrEmpty(l.CountryCode) && l.Name != Locale?.Name)
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Every marketplace a scan of this account should read: its own, then any extras. One account is scanned as
|
||||
/// a unit, so that a title found under one marketplace is never counted absent because another was scanned.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Locale> ScanLocales
|
||||
=> Locale is null ? AdditionalLocales : new[] { Locale }.Concat(AdditionalLocales).ToList();
|
||||
|
||||
/// <summary>True if <paramref name="localeName"/> is this account's own marketplace or one of its extras.</summary>
|
||||
public bool HasMarketplace(string? localeName)
|
||||
=> canonicalize(localeName) is string name
|
||||
&& (name == Locale?.Name || _additionalLocaleNames.Contains(name));
|
||||
|
||||
/// <summary>Adds an extra marketplace. No-op if it is already this account's own, or already added.</summary>
|
||||
public bool AddMarketplace(string? localeName)
|
||||
{
|
||||
if (canonicalize(localeName) is not string name || HasMarketplace(name))
|
||||
return false;
|
||||
|
||||
_additionalLocaleNames.Add(name);
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool RemoveMarketplace(string? localeName)
|
||||
{
|
||||
if (canonicalize(localeName) is not string name || !_additionalLocaleNames.Remove(name))
|
||||
return false;
|
||||
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Replaces the extra marketplaces wholesale. Used by the accounts dialog, which edits a copy.</summary>
|
||||
public void SetAdditionalMarketplaces(IEnumerable<string?> localeNames)
|
||||
{
|
||||
var replacement = (localeNames ?? [])
|
||||
.Select(canonicalize)
|
||||
.OfType<string>()
|
||||
.Where(n => n != Locale?.Name)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (replacement.SequenceEqual(_additionalLocaleNames))
|
||||
return;
|
||||
|
||||
_additionalLocaleNames.Clear();
|
||||
_additionalLocaleNames.AddRange(replacement);
|
||||
update();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store the locale's canonical name, so that a country code ('de') and an internal name ('germany') cannot
|
||||
/// end up in the list as two separate marketplaces.
|
||||
/// </summary>
|
||||
private static string? canonicalize(string? localeName)
|
||||
{
|
||||
var locale = Localization.Get(localeName);
|
||||
return string.IsNullOrEmpty(locale.CountryCode) ? null : locale.Name;
|
||||
}
|
||||
|
||||
public Account(string accountId)
|
||||
{
|
||||
AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim();
|
||||
@@ -101,6 +215,8 @@ public class Account : IUpdatable, ILogMasked
|
||||
/// </summary>
|
||||
public override string ToString() => MaskedLogEntry;
|
||||
|
||||
/// <summary>Derived from the fields above, so persisting it would only add a stale copy of them.</summary>
|
||||
[JsonIgnore]
|
||||
public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}";
|
||||
private static string mask(string? str)
|
||||
=> str is null ? "[null]"
|
||||
|
||||
@@ -97,6 +97,7 @@ public class AccountsSettings : IUpdatable
|
||||
public void Add(Account account)
|
||||
{
|
||||
_add(account);
|
||||
Serilog.Log.Logger.Information("Added Audible account {Account}", account.MaskedLogEntry);
|
||||
update_no_validate();
|
||||
}
|
||||
|
||||
@@ -108,6 +109,12 @@ public class AccountsSettings : IUpdatable
|
||||
account.Updated += update;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account that can speak to <paramref name="locale"/> for this login: the one registered with that
|
||||
/// marketplace, or failing that the one carrying it as an extra marketplace. Callers hand this a book's
|
||||
/// marketplace and get back the credentials that can license it, which is why extras have to resolve here
|
||||
/// as well as the registered marketplace does.
|
||||
/// </summary>
|
||||
public Account? GetAccount(string accountId, string? locale)
|
||||
{
|
||||
if (locale is null)
|
||||
@@ -116,9 +123,32 @@ public class AccountsSettings : IUpdatable
|
||||
// AccountId is compared case-insensitively: Audible/library data has been observed to differ
|
||||
// only by letter case (e.g. a stored id capitalized differently than settings), which caused
|
||||
// spurious "No account found" failures that blocked every affected book. See issue #1931.
|
||||
return Accounts.SingleOrDefault(a =>
|
||||
var registered = Accounts.SingleOrDefault(a =>
|
||||
a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.Locale?.Name == locale);
|
||||
|
||||
if (registered is not null)
|
||||
return registered;
|
||||
|
||||
return Accounts.FirstOrDefault(a =>
|
||||
a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.HasMarketplace(locale));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The account already scanning <paramref name="localeName"/> for this login, if any. Adding a marketplace,
|
||||
/// importing an audible-cli file, and probing all need to know whether a marketplace is spoken for - including
|
||||
/// by a second row for the same login, which is how multiple marketplaces were handled before one account
|
||||
/// could hold several.
|
||||
/// </summary>
|
||||
public Account? GetAccountClaimingMarketplace(string accountId, string? localeName, Account? excluding = null)
|
||||
{
|
||||
var name = Localization.Get(localeName).Name;
|
||||
|
||||
return Accounts.FirstOrDefault(a =>
|
||||
!ReferenceEquals(a, excluding)
|
||||
&& a.AccountId.EqualsInsensitive(accountId)
|
||||
&& a.HasMarketplace(name));
|
||||
}
|
||||
|
||||
public bool Delete(string accountId, string locale)
|
||||
@@ -136,28 +166,23 @@ public class AccountsSettings : IUpdatable
|
||||
|
||||
account.Updated -= update;
|
||||
var result = _accounts_backing.Remove(account);
|
||||
if (result)
|
||||
Serilog.Log.Logger.Information("Removed Audible account {Account}", account.MaskedLogEntry);
|
||||
update_no_validate();
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No two rows for one login may scan the same marketplace, or a scan would import it twice and a download
|
||||
/// would have two accounts to choose from. Extra marketplaces count: claiming 'us' as an extra collides with
|
||||
/// another row registered with 'us' just as surely as two 'us' registrations would.
|
||||
/// </summary>
|
||||
private void validate(Account account)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
|
||||
var accountId = account.AccountId;
|
||||
var locale = account?.IdentityTokens?.Locale?.Name;
|
||||
|
||||
var acct = GetAccount(accountId, locale);
|
||||
|
||||
// new: ok
|
||||
if (acct is null)
|
||||
return;
|
||||
|
||||
// same account instance: ok
|
||||
if (acct == account)
|
||||
return;
|
||||
|
||||
// same account id + locale, different instance: bad
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
foreach (var locale in account.ScanLocales)
|
||||
if (GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is not null)
|
||||
throw new InvalidOperationException("Cannot add an account with the same account Id and Locale");
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,9 @@ using LibationFileManager;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
@@ -36,7 +32,11 @@ public class ApiExtended
|
||||
public static Task<ApiExtended> CreateAsync(Account account)
|
||||
=> CreateAsync(account, allowInteractiveLogin: true);
|
||||
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin)
|
||||
/// <param name="storeLocale">
|
||||
/// The marketplace to read, when it is not the one the account is registered with. The account's own tokens
|
||||
/// are used either way; only the store host changes. Null means the account's own marketplace.
|
||||
/// </param>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin, Locale? storeLocale = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||
@@ -46,13 +46,15 @@ public class ApiExtended
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry
|
||||
AccountMaskedLogEntry = account.MaskedLogEntry,
|
||||
StoreLocaleName = (storeLocale ?? locale).Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
storeLocale);
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -102,7 +104,17 @@ public class ApiExtended
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
Configuration.Instance.GetDeviceRegistrationProfile());
|
||||
|
||||
// login happens against the account's own marketplace, so the api it hands back reads that one.
|
||||
// re-create once the tokens exist if some other marketplace is the one actually wanted.
|
||||
if (storeLocale is not null && storeLocale.Name != locale.Name)
|
||||
api = await EzApiCreator.GetApiAsync(
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
storeLocale);
|
||||
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
@@ -429,21 +441,21 @@ public class ApiExtended
|
||||
}
|
||||
|
||||
int lastEpNum = -1, dupeCount = 0;
|
||||
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
|
||||
foreach (var child in children.OrderBy(i => UsableEpisodeNumber(i)).ThenBy(i => i.PublicationDateTime))
|
||||
{
|
||||
string sequence;
|
||||
if (child.EpisodeNumber is null)
|
||||
var episodeNumber = UsableEpisodeNumber(child);
|
||||
if (episodeNumber is null)
|
||||
{
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
|
||||
sequence = FallbackSeriesSequence(parent, child);
|
||||
}
|
||||
else
|
||||
{
|
||||
//multipart episodes may have the same episode number
|
||||
if (child.EpisodeNumber == lastEpNum)
|
||||
if (episodeNumber == lastEpNum)
|
||||
dupeCount++;
|
||||
else
|
||||
lastEpNum = child.EpisodeNumber.Value;
|
||||
lastEpNum = episodeNumber.Value;
|
||||
|
||||
sequence = (lastEpNum + dupeCount).ToString();
|
||||
}
|
||||
@@ -462,5 +474,69 @@ public class ApiExtended
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nine digits. Big enough for YYYYMMDD-style numbering; too small for unix timestamps,
|
||||
/// Integer.MAX_VALUE, and the other sentinel integers Audible has sent as episode order (issue #2024).
|
||||
/// </summary>
|
||||
private const long MaxPlausibleSeriesOrder = 1_000_000_000;
|
||||
|
||||
private static bool IsPlausibleSeriesOrder(long n)
|
||||
=> n >= 0 && n < MaxPlausibleSeriesOrder;
|
||||
|
||||
/// <summary>
|
||||
/// Audible sometimes serializes a missing episode_number as a sentinel integer (Integer.MAX_VALUE
|
||||
/// was the one in #2024) instead of omitting the field. Treat any implausibly large value the same way.
|
||||
/// </summary>
|
||||
private static int? UsableEpisodeNumber(Item child)
|
||||
=> child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null;
|
||||
|
||||
/// <summary>
|
||||
/// When episode_number is missing or implausibly large, use relationship sort/sequence
|
||||
/// or the catalog series sequence. Prefer the parent's child relationship (the historical source),
|
||||
/// then the child's parent relationship, then any series sequence Audible already attached.
|
||||
/// </summary>
|
||||
private static string FallbackSeriesSequence(Item parent, Item child)
|
||||
{
|
||||
var fromParent = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin);
|
||||
if (UsableRelationshipOrder(fromParent) is string parentOrder)
|
||||
return parentOrder;
|
||||
|
||||
var fromChild = child.Relationships?.FirstOrDefault(r => r.Asin == parent.Asin);
|
||||
if (UsableRelationshipOrder(fromChild) is string childOrder)
|
||||
return childOrder;
|
||||
|
||||
var catalogSequence = child.Series?.FirstOrDefault(s => s.Asin == parent.Asin)?.Sequence;
|
||||
if (IsUsableOrderString(catalogSequence))
|
||||
return catalogSequence!;
|
||||
|
||||
return "0";
|
||||
}
|
||||
|
||||
private static string? UsableRelationshipOrder(Relationship? relationship)
|
||||
{
|
||||
if (relationship is null)
|
||||
return null;
|
||||
if (IsUsableSort(relationship.Sort))
|
||||
return relationship.Sort!.Value.ToString();
|
||||
if (IsUsableOrderString(relationship.Sequence))
|
||||
return relationship.Sequence;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsUsableSort(long? sort)
|
||||
=> sort is long n && IsPlausibleSeriesOrder(n);
|
||||
|
||||
/// <summary>
|
||||
/// Bare integers that are too large are sentinels or timestamps. Mixed forms like "1-6" or "2.1"
|
||||
/// are real series orders and are left alone.
|
||||
/// </summary>
|
||||
private static bool IsUsableOrderString(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "-1")
|
||||
return false;
|
||||
return !long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|
||||
|| IsPlausibleSeriesOrder(n);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -7,8 +7,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="11.0.3.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.36.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<PublicAssets>runtime</PublicAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -28,4 +30,8 @@
|
||||
<DependentUpon>Cdm.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,5 +1,3 @@
|
||||
using AudibleApi.Authentication;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public static class AuthenticationExceptionHelper
|
||||
@@ -14,7 +12,7 @@ public static class AuthenticationExceptionHelper
|
||||
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AuthenticationRequiredException or LoginFailedException)
|
||||
if (current is AuthenticationRequiredException)
|
||||
return true;
|
||||
|
||||
if (current is InvalidOperationException { Message: var message }
|
||||
|
||||
@@ -20,5 +20,25 @@ public static class Extensions
|
||||
.Select(p => p.EndDate)
|
||||
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
|
||||
?.DateTime;
|
||||
|
||||
/// <summary>
|
||||
/// The book's summary with Audible's HTML markup flattened away, ready to be stored and used
|
||||
/// as-is. See <see cref="HtmlText.ToPlainText"/>.
|
||||
/// </summary>
|
||||
public string PlainTextDescription()
|
||||
=> HtmlText.ToPlainText(item.Description);
|
||||
|
||||
/// <summary>
|
||||
/// The publisher's copyright line, e.g. "©2024 Bentley Little (P)2025 Journalstone".
|
||||
/// Only present when the product_details response group was requested, and often null anyway.
|
||||
/// </summary>
|
||||
/// <remarks><see cref="Item.Copyright"/> is typed as <see cref="object"/> because Audible has
|
||||
/// never been observed returning anything but a string or null there. Anything else is
|
||||
/// discarded rather than stringified, so a shape we do not understand cannot reach a file tag.
|
||||
/// </remarks>
|
||||
public string? CopyrightString()
|
||||
=> item.Copyright is string copyright && !string.IsNullOrWhiteSpace(copyright)
|
||||
? copyright.Trim()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Audible returns a book's summary as HTML. Nothing downstream of the API renders markup - not the
|
||||
/// library grid, not the exports, and least of all the metadata tags written into an audio file - so
|
||||
/// the markup is flattened here, on the way in, rather than carried around and stripped at each use.
|
||||
/// </summary>
|
||||
public static partial class HtmlText
|
||||
{
|
||||
/// <param name="paragraphSeparator">Joins the block-level runs. A single newline matches what
|
||||
/// Audible itself embeds in the description tags of its .aaxc files.</param>
|
||||
public static string ToPlainText(string? html, string paragraphSeparator = "\n")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return "";
|
||||
|
||||
// Not every summary is marked up. Running the unmarked ones through the parser risks a stray
|
||||
// '<' swallowing the rest of the sentence, and buys nothing.
|
||||
if (!html.Contains('<'))
|
||||
return (WebUtility.HtmlDecode(html) ?? html).Trim();
|
||||
|
||||
// Replace block-level boundaries with newlines, then strip all remaining tags.
|
||||
var stripped = StripTags().Replace(BlockBoundary().Replace(html, "\n"), "");
|
||||
|
||||
var text = WebUtility.HtmlDecode(stripped) ?? stripped;
|
||||
|
||||
var paragraphs = text
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace('\r', '\n')
|
||||
.Split('\n')
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => line.Length > 0);
|
||||
|
||||
return string.Join(paragraphSeparator, paragraphs);
|
||||
}
|
||||
|
||||
/// <summary>Every tag that ends a run of text. A line break counts as a paragraph break because
|
||||
/// Audible's summaries mark up paragraphs and nothing finer.</summary>
|
||||
[GeneratedRegex(@"<\s*br\s*/?\s*>|<\s*/\s*(?:p|div|li|tr|blockquote|h[1-6])\s*>", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex BlockBoundary();
|
||||
|
||||
/// <summary>Matches any HTML/XML tag.</summary>
|
||||
[GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial Regex StripTags();
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using AudibleApi;
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public enum MarketplaceProbeOutcome
|
||||
{
|
||||
/// <summary>Already scanned: this account's own marketplace, or one it has already been given.</summary>
|
||||
AlreadyScanned,
|
||||
/// <summary>Scanned by a different account row for the same login - the pre-existing way to hold two marketplaces.</summary>
|
||||
ScannedByAnotherAccount,
|
||||
/// <summary>The marketplace answered, and holds titles.</summary>
|
||||
TitlesFound,
|
||||
/// <summary>The marketplace answered, and holds nothing.</summary>
|
||||
Empty,
|
||||
/// <summary>The marketplace could not be asked. Says nothing either way about what is there.</summary>
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <param name="TitleCount">Titles the marketplace reports, or null when it was not asked or did not answer.</param>
|
||||
/// <param name="ClaimedBy">For <see cref="MarketplaceProbeOutcome.ScannedByAnotherAccount"/>, the account already scanning it.</param>
|
||||
public record MarketplaceProbeResult(
|
||||
Locale Locale,
|
||||
MarketplaceProbeOutcome Outcome,
|
||||
int? TitleCount = null,
|
||||
string? ClaimedBy = null,
|
||||
string? Error = null)
|
||||
{
|
||||
/// <summary>Whether adding this marketplace to the account is something the user can choose to do.</summary>
|
||||
public bool CanAdd => Outcome is MarketplaceProbeOutcome.TitlesFound or MarketplaceProbeOutcome.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Asks each Audible marketplace whether this login holds anything there.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A title bought while an Amazon address was briefly set to another country stays in that country's library for
|
||||
/// good, and a scan of the account's own marketplace will never see it - no error, no warning, the titles are
|
||||
/// simply absent. One device registration is honored by every marketplace, so the only thing standing between
|
||||
/// those titles and a scan is knowing which marketplace to look in. That is what this answers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One request per marketplace, in sequence: enough to get a count, and no more traffic or concurrency than a
|
||||
/// user who clicked a button should generate. Nothing here runs on its own - the accounts dialog asks for it.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MarketplaceProbe
|
||||
{
|
||||
/// <summary>Space between requests. A deliberate trickle rather than a fan-out.</summary>
|
||||
public static TimeSpan RequestSpacing { get; set; } = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
/// <summary>
|
||||
/// The marketplaces worth asking about for this account. Pre-Amazon locales and modern ones are separate
|
||||
/// worlds with their own logins, so only the account's own kind is probed; asking about the rest would double
|
||||
/// the traffic to learn nothing.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<Locale> CandidateLocales(Account account)
|
||||
{
|
||||
var withUsername = account?.Locale?.WithUsername ?? false;
|
||||
|
||||
return Localization.Locales
|
||||
.Where(l => l.WithUsername == withUsername)
|
||||
.OrderBy(l => l.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Probe every candidate marketplace, yielding each result as it arrives so a dialog can fill in a row at a
|
||||
/// time. Never throws for a single marketplace: one that cannot be reached is reported as such and the rest
|
||||
/// carry on.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<MarketplaceProbeResult> ProbeAsync(
|
||||
Account account,
|
||||
AccountsSettings accountsSettings,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(accountsSettings, nameof(accountsSettings));
|
||||
|
||||
var first = true;
|
||||
|
||||
foreach (var locale in CandidateLocales(account))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (account.HasMarketplace(locale.Name))
|
||||
{
|
||||
yield return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.AlreadyScanned);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (accountsSettings.GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is { } other)
|
||||
{
|
||||
yield return new MarketplaceProbeResult(
|
||||
locale,
|
||||
MarketplaceProbeOutcome.ScannedByAnotherAccount,
|
||||
ClaimedBy: AccountCredentialStatus.FormatAccountLabel(other));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!first)
|
||||
await Task.Delay(RequestSpacing, cancellationToken);
|
||||
first = false;
|
||||
|
||||
yield return await probeOneAsync(account, locale);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<MarketplaceProbeResult> probeOneAsync(Account account, Locale locale)
|
||||
{
|
||||
try
|
||||
{
|
||||
// no interactive login: the whole premise is that this account's existing tokens already work here
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: false, storeLocale: locale);
|
||||
|
||||
var count = await apiExtended.Api.GetItemsCountAsync(
|
||||
new LibraryOptions { PurchasedAfter = new DateTime(1970, 1, 1) });
|
||||
|
||||
Serilog.Log.Logger.Information(
|
||||
"Marketplace probe: {LocaleName} reported {TitleCount} titles. {@DebugInfo}",
|
||||
locale.Name,
|
||||
count,
|
||||
new { Account = account.MaskedLogEntry });
|
||||
|
||||
// -1 means Audible answered without the count header. it answered, so the marketplace is reachable;
|
||||
// treat it as reachable-but-unknown rather than claiming there is nothing there
|
||||
return count switch
|
||||
{
|
||||
> 0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.TitlesFound, count),
|
||||
0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty, 0),
|
||||
_ => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Information(
|
||||
ex,
|
||||
"Marketplace probe: {LocaleName} could not be checked. {@DebugInfo}",
|
||||
locale.Name,
|
||||
new { Account = account.MaskedLogEntry });
|
||||
|
||||
return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Failed, Error: ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,12 @@ public partial class Mkb79Auth
|
||||
return account;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exported file names one marketplace - the one this account is registered with - because that is all
|
||||
/// the format holds: a single <c>locale_code</c> alongside a single device registration. audible-cli switches
|
||||
/// marketplaces on its own from those same tokens, so nothing is lost to it. Any additional marketplaces
|
||||
/// Libation reads for this account are its own bookkeeping and have no slot here.
|
||||
/// </summary>
|
||||
public static Mkb79Auth FromAccount(Account account)
|
||||
=> new()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
@@ -10,10 +10,37 @@ public enum Mkb79ImportOutcome
|
||||
InvalidFile,
|
||||
}
|
||||
|
||||
public sealed record Mkb79ImportResult(Mkb79ImportOutcome Outcome, Account? Account = null, string? Message = null);
|
||||
/// <param name="ClaimedBy">
|
||||
/// For <see cref="Mkb79ImportOutcome.DuplicateAccount"/>, the account already scanning that marketplace. It may
|
||||
/// be one registered with it, or one carrying it as an additional marketplace.
|
||||
/// </param>
|
||||
public sealed record Mkb79ImportResult(
|
||||
Mkb79ImportOutcome Outcome,
|
||||
Account? Account = null,
|
||||
string? Message = null,
|
||||
Account? ClaimedBy = null);
|
||||
|
||||
public static class Mkb79AuthImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Why a duplicate import was refused, in the same words everywhere it is refused. Naming the account that
|
||||
/// already reads the marketplace matters now that it need not be a row registered with it - it may be one
|
||||
/// reading it as an additional marketplace, which is not obvious from the accounts grid.
|
||||
/// </summary>
|
||||
public static string DuplicateMessage(Mkb79ImportResult result)
|
||||
{
|
||||
var locale = result.Account?.Locale?.Name ?? "[unknown]";
|
||||
|
||||
if (result.ClaimedBy is { } claimedBy && claimedBy.Locale?.Name != locale)
|
||||
return $"The '{locale}' marketplace is already scanned by the account "
|
||||
+ $"{AccountCredentialStatus.FormatAccountLabel(claimedBy)}, as an additional marketplace. "
|
||||
+ "Nothing was imported.";
|
||||
|
||||
return "An account with that account id and country already exists."
|
||||
+ $"{Environment.NewLine}Account ID: {result.Account?.AccountId}"
|
||||
+ $"{Environment.NewLine}Country: {locale}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present.
|
||||
/// </summary>
|
||||
@@ -32,11 +59,12 @@ public static class Mkb79AuthImporter
|
||||
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (persister.AccountsSettings.Accounts.Any(a =>
|
||||
a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
|
||||
{
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account);
|
||||
}
|
||||
// An mkb79 file names one marketplace, and a marketplace can only be scanned by one account. Ask about
|
||||
// every claim on it, not just registrations: an existing account may already be reading this marketplace
|
||||
// as an additional one, in which case importing would scan it twice.
|
||||
var claimedBy = persister.AccountsSettings.GetAccountClaimingMarketplace(account.AccountId, account.Locale?.Name);
|
||||
if (claimedBy is not null)
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account, ClaimedBy: claimedBy);
|
||||
|
||||
persister.AccountsSettings.Add(account);
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.Success, account);
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -35,4 +35,8 @@
|
||||
<Folder Include="Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+581
@@ -0,0 +1,581 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260826123718_AddBookCopyright")]
|
||||
partial class AddBookCopyright
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("smallint");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId"));
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("real");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBookCopyright : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Copyright",
|
||||
table: "Books",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Copyright",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("ProductVersion", "10.0.11")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -52,6 +52,9 @@ namespace DataLayer.Postgres.Migrations
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -31,4 +31,8 @@
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+560
@@ -0,0 +1,560 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260826123725_AddBookCopyright")]
|
||||
partial class AddBookCopyright
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b =>
|
||||
{
|
||||
b.Property<int>("DownloadAttemptFailureId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("LastFailedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("RetryAfterUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadAttemptFailureId");
|
||||
|
||||
b.HasIndex("Account", "AudibleProductId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("DownloadAttemptFailures");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.DownloadHistory", b =>
|
||||
{
|
||||
b.Property<int>("DownloadHistoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("Bytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CompletedAtUtcTicks")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("DownloadHistoryId");
|
||||
|
||||
b.HasIndex("CompletedAtUtcTicks");
|
||||
|
||||
b.ToTable("DownloadHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<bool>("IsFinished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBookCopyright : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Copyright",
|
||||
table: "Books",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Copyright",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.11");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
@@ -45,6 +45,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Copyright")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.4.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.4.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="11.0.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -35,6 +35,10 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -52,6 +52,9 @@ public class Book
|
||||
public bool IsSpatial { get; private set; }
|
||||
public DateTime? DatePublished { get; private set; }
|
||||
public string? Language { get; private set; }
|
||||
/// <summary>The publisher's copyright line. Only returned by the API's product_details response
|
||||
/// group, and null for plenty of titles even then.</summary>
|
||||
public string? Copyright { get; private set; }
|
||||
|
||||
// is owned, not optional 1:1
|
||||
public UserDefinedItem UserDefinedItem { get; private set; }
|
||||
@@ -119,6 +122,13 @@ public class Book
|
||||
public void UpdateLengthInMinutes(int lengthInMinutes)
|
||||
=> LengthInMinutes = lengthInMinutes;
|
||||
|
||||
public void UpdateDescription(string? description)
|
||||
{
|
||||
// don't overwrite with default values
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
Description = description.Trim();
|
||||
}
|
||||
|
||||
#region contributors, authors, narrators
|
||||
internal HashSet<BookContributor> ContributorsLink { get; private set; }
|
||||
|
||||
@@ -232,31 +242,67 @@ public class Book
|
||||
private readonly HashSet<Supplement>? _supplements;
|
||||
public IEnumerable<Supplement> Supplements => _supplements?.ToList() ?? [];
|
||||
|
||||
public void AddSupplementDownloadUrl(string url)
|
||||
/// <summary>
|
||||
/// Records where Audible says this book's supplement can be found, replacing anything recorded before.
|
||||
/// <para>
|
||||
/// Audible reports one supplement per title, so this replaces rather than accumulates. It used to add, and
|
||||
/// guarded against duplicates by comparing the incoming url to itself - which came to the same thing only
|
||||
/// because the always-true comparison meant "this book already has a supplement", so a url that changed was
|
||||
/// silently ignored.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public void SetSupplementDownloadUrl(string url)
|
||||
{
|
||||
// supplements are owned by Book, so no need to Load():
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
|
||||
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
|
||||
|
||||
if (_supplements?.Any(s => url.EqualsInsensitive(url)) is true)
|
||||
if (_supplements is null)
|
||||
return;
|
||||
|
||||
_supplements?.Add(new Supplement(this, url));
|
||||
if (_supplements.Count == 1 && _supplements.First().Url.EqualsInsensitive(url))
|
||||
return;
|
||||
|
||||
_supplements.Clear();
|
||||
_supplements.Add(new Supplement(this, url));
|
||||
|
||||
// Only when the book had no supplement at all. A url Audible has changed says nothing about whether the
|
||||
// file behind it is already downloaded.
|
||||
UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets that this book has a supplement, for when Audible says it has none after all.
|
||||
/// <para>
|
||||
/// The status goes back to null rather than to NotLiberated, because null is what "this title has no PDF"
|
||||
/// is recorded as everywhere else: it is the state of a book that never had one, and it is what stops the
|
||||
/// grid from showing a PDF marker and the counts from expecting one.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>Whether anything was removed.</returns>
|
||||
public bool RemoveSupplements()
|
||||
{
|
||||
if (_supplements is null || _supplements.Count == 0)
|
||||
return false;
|
||||
|
||||
_supplements.Clear();
|
||||
UserDefinedItem.PdfStatus = null;
|
||||
return true;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||
|
||||
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language)
|
||||
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language, string? copyright = null)
|
||||
{
|
||||
// don't overwrite with default values
|
||||
IsAbridged |= isAbridged;
|
||||
IsSpatial = isSpatial ?? IsSpatial;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
Language = language?.Trim().FirstCharToUpper() ?? Language;
|
||||
Copyright = copyright?.Trim() ?? Copyright;
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
||||
|
||||
@@ -16,11 +16,12 @@ public class Contributor
|
||||
|
||||
// id.s
|
||||
// ----
|
||||
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
|
||||
// the name in the path is decorative; the id is what resolves it. Both of these reach the same page:
|
||||
// https://www.audible.com/author/Robert-Bevan/B0EXAMPLE1 == https://www.audible.com/author/B0EXAMPLE1
|
||||
// goes to summary page
|
||||
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
|
||||
// at bottom "See all titles by Robert Bevan" goes to https://www.audible.com/search?searchAuthor=Robert+Bevan
|
||||
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
|
||||
// all narrators have no id: https://www.audible.com/search?searchNarrator=Robert+Bevan
|
||||
|
||||
internal int ContributorId { get; private set; }
|
||||
public string Name { get; private set; }
|
||||
|
||||
@@ -89,6 +89,7 @@ public class MockLibraryBook : LibraryBook
|
||||
bool isAbridged = false,
|
||||
bool isSpatial = false,
|
||||
string language = "English",
|
||||
string? copyright = null,
|
||||
LiberatedStatus bookStatus = LiberatedStatus.Liberated,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
AudioFormat? lastDlFormat = null,
|
||||
@@ -113,7 +114,7 @@ public class MockLibraryBook : LibraryBook
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus;
|
||||
book.UserDefinedItem.BookStatus = bookStatus;
|
||||
|
||||
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language);
|
||||
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language, copyright);
|
||||
|
||||
return new MockLibraryBook(
|
||||
book,
|
||||
|
||||
@@ -69,6 +69,17 @@ public static class LibraryBookQueries
|
||||
.getLibrary()
|
||||
.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// How many books a full search re-index writes: the same rows as
|
||||
/// <see cref="GetLibrary_Flat_NoTracking"/> without the podcast parents, which are stripped before
|
||||
/// indexing. A row count only; no entities are loaded, so this is cheap enough to check the index against.
|
||||
/// </summary>
|
||||
public int GetIndexableBookCount()
|
||||
=> context
|
||||
.LibraryBooks
|
||||
.AsNoTracking()
|
||||
.Count(lb => !lb.IsDeleted && lb.Book.ContentType != ContentType.Parent);
|
||||
|
||||
/// <summary>Counts <see cref="LibraryBook"/> rows by <see cref="LibraryBook.IsDeleted"/> (no related entities loaded).</summary>
|
||||
public (int NotInTrash, int InTrash) GetLibraryBookCountsByTrashFlag()
|
||||
{
|
||||
|
||||
@@ -124,7 +124,7 @@ public class BookImporter : ItemsImporterBase
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.Title,
|
||||
item.Subtitle,
|
||||
item.Description,
|
||||
item.PlainTextDescription(),
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
authors,
|
||||
@@ -157,12 +157,54 @@ public class BookImporter : ItemsImporterBase
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
if (item.PdfUrl is not null)
|
||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
syncSupplement(item, book);
|
||||
|
||||
return book;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings a book's stored description into line with what this scan says about it.
|
||||
/// <para>
|
||||
/// The description used to be stored exactly as Audible sends it, which is HTML. It is flattened on the
|
||||
/// way in now, so every book imported before that change is still holding markup and needs rewriting -
|
||||
/// hence updating a description at all, which import never used to do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A scan that reports no description leaves the stored one alone. Episodes are imported from the
|
||||
/// catalog, and "no summary" there can just as easily mean the response group was not asked for.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void syncDescription(Item item, Book book)
|
||||
=> book.UpdateDescription(item.PlainTextDescription());
|
||||
|
||||
/// <summary>
|
||||
/// Brings a book's supplement into line with what this scan says about it.
|
||||
/// <para>
|
||||
/// Only new books used to get this far, so a title that gained a PDF after it was first imported never got
|
||||
/// one, and a title that lost its PDF went on claiming one - and, until issue #1973, went on being asked
|
||||
/// for on every run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A supplement is dropped only when Audible says outright that no supplement url is available. A missing
|
||||
/// url says nothing on its own: episodes are imported from the catalog, which is never asked for
|
||||
/// <c>pdf_url</c>, so there "no url" means "not asked" rather than "none exists". A PDF already downloaded
|
||||
/// is left alone either way, because the file is on disk and the library should go on saying so.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static void syncSupplement(Item item, Book book)
|
||||
{
|
||||
if (item.PdfUrl is not null)
|
||||
book.SetSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
else if (item.IsPdfUrlAvailable is false
|
||||
&& book.UserDefinedItem.PdfStatus is not LiberatedStatus.Liberated
|
||||
&& book.RemoveSupplements())
|
||||
{
|
||||
Serilog.Log.Logger.Information(
|
||||
"Audible no longer offers a supplement for {audibleProductId}, so Libation has stopped expecting one.",
|
||||
book.AudibleProductId);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBook(ImportItem importItem, Book book)
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
@@ -179,6 +221,8 @@ public class BookImporter : ItemsImporterBase
|
||||
// Update the book titles, since formatting can change
|
||||
book.UpdateTitle(item.Title, item.Subtitle);
|
||||
|
||||
syncDescription(item, book);
|
||||
|
||||
// set/update book-specific info which may have changed
|
||||
if (item.PictureId is not null)
|
||||
book.PictureId = item.PictureId;
|
||||
@@ -193,7 +237,11 @@ public class BookImporter : ItemsImporterBase
|
||||
// updateBook must update language on books which were imported before the migration which added language.
|
||||
// 2025-07-30
|
||||
// updateBook must update isSpatial on books which were imported before the migration which added isSpatial.
|
||||
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language);
|
||||
// 2026-08-26
|
||||
// updateBook must update copyright on books which were imported before the migration which added copyright.
|
||||
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language, item.CopyrightString());
|
||||
|
||||
syncSupplement(item, book);
|
||||
|
||||
book.UpdateProductRating(
|
||||
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||
|
||||
@@ -18,4 +18,8 @@
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
// ApplicationServices.Tests rather than a DtoImporterService.Tests of its own: this project has no test
|
||||
// assembly, and ApplicationServices references it, so its tests can already see the importers. A literal
|
||||
// because the dependency runs the other way, leaving nothing here to take the name from.
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ApplicationServices.Tests")]
|
||||
@@ -7,8 +7,10 @@ using Dinah.Core.ErrorHandling;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -16,17 +18,18 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook>
|
||||
public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook>, ILicensedDownload
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private CancellationTokenSource? cancellationTokenSource;
|
||||
private AudiobookDownloadBase? abDownloader;
|
||||
|
||||
/// <summary>
|
||||
/// Optional override to supply license info directly instead of querying the api based on Configuration options
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; }
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.AudioExists;
|
||||
protected override bool RecordsAttemptFailures => true;
|
||||
|
||||
@@ -41,6 +44,7 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
OnBegin(libraryBook);
|
||||
cancellationTokenSource = new CancellationTokenSource();
|
||||
var cancellationToken = cancellationTokenSource.Token;
|
||||
ObtainedLicense = null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -54,6 +58,11 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
//Processable instances are reusable, so don't set LicenseInfo
|
||||
//override from within a DownloadDecryptBook instance.
|
||||
var license = LicenseInfo ?? await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration, cancellationToken);
|
||||
|
||||
// Published before the download begins so the supplement step gets it even from a run that fails
|
||||
// later on: what it needs is the license, and this one is already granted.
|
||||
ObtainedLicense = license;
|
||||
|
||||
using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration, license);
|
||||
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
|
||||
|
||||
@@ -240,31 +249,69 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
|
||||
#endregion
|
||||
|
||||
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
||||
FillMissingTags(tags, options.LibraryBook.Book, options.LibraryBookDto, options.ContentMetadata.ContentReference, options.DrmType);
|
||||
}
|
||||
|
||||
/// <summary>Audible's own format for <c>rldt</c>, which Libation matches when it supplies the tag itself.</summary>
|
||||
private const string ReleaseDateFormat = "dd-MMM-yyyy";
|
||||
|
||||
/// <summary>
|
||||
/// Audible's ADRM (.aaxc) downloads arrive with most of these tags already written; its Widevine
|
||||
/// (DASH) downloads arrive with almost none of them, so the library's own data has to stand in.
|
||||
/// </summary>
|
||||
internal static void FillMissingTags(Mpeg4Lib.MetadataItems tags, Book book, LibraryBookDto dto, ContentReference contentReference, DrmType drmType)
|
||||
{
|
||||
tags.Title ??= dto.TitleWithSubtitle;
|
||||
tags.Album ??= tags.Title;
|
||||
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
||||
tags.Artist ??= string.Join("; ", book.Authors.Select(a => a.Name));
|
||||
tags.AlbumArtists ??= tags.Artist;
|
||||
tags.Genres = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
|
||||
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||
tags.Genres = string.Join(", ", book.LowestCategoryNames());
|
||||
tags.ProductID ??= contentReference.Sku;
|
||||
tags.Comment ??= 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)
|
||||
//Only the .aaxc files carry a copyright line of their own. AaxcDownloadConvertBase's fixup
|
||||
//normalizes "(P)" to "℗" after this runs, so the catalog's wording lands the same way.
|
||||
tags.Copyright ??= book.Copyright;
|
||||
tags.Publisher ??= book.Publisher;
|
||||
tags.Narrator ??= string.Join("; ", book.Narrators.Select(n => n.Name));
|
||||
tags.Asin = book.AudibleProductId;
|
||||
tags.Acr = contentReference.Acr;
|
||||
tags.Version = contentReference.Version;
|
||||
if (book.DatePublished is DateTime pubDate)
|
||||
{
|
||||
tags.Year ??= pubDate.Year.ToString();
|
||||
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||
tags.ReleaseDate ??= pubDate.ToString(ReleaseDateFormat);
|
||||
|
||||
//Audible's .aaxc files frequently carry 01-Jan-2000 where the real release date belongs.
|
||||
//The file's own tag is otherwise the better source - it is what Audible shipped with this
|
||||
//particular recording - so it still wins everywhere except for that one placeholder value.
|
||||
//
|
||||
//Only the exact sentinel is overridden, and only when the catalog disagrees with it, so a
|
||||
//title genuinely released on 01-Jan-2000 comes out the same either way. Two broader rules
|
||||
//were considered and rejected: always preferring the catalog date would discard Audible's
|
||||
//date on re-releases the catalog dates wrong, and treating any year below some floor as
|
||||
//bogus has no defensible floor, since real recordings predate 2000.
|
||||
if (IsPlaceholderReleaseDate(tags.ReleaseDate) && pubDate.Date != PlaceholderReleaseDate)
|
||||
{
|
||||
tags.ReleaseDate = pubDate.ToString(ReleaseDateFormat);
|
||||
//A file carrying the placeholder rldt carries the matching placeholder year.
|
||||
if (tags.Year == PlaceholderReleaseDate.Year.ToString())
|
||||
tags.Year = pubDate.Year.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
const string tagDomain = "org.libation";
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ACR", tags.Acr);
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", options.DrmType.ToString());
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", options.LibraryBook.Book.Locale);
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", drmType.ToString());
|
||||
tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", book.Locale);
|
||||
}
|
||||
|
||||
private static readonly DateTime PlaceholderReleaseDate = new(2000, 1, 1);
|
||||
|
||||
internal static bool IsPlaceholderReleaseDate(string? releaseDate)
|
||||
=> DateTime.TryParse(releaseDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
&& parsed.Date == PlaceholderReleaseDate;
|
||||
|
||||
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)
|
||||
{
|
||||
if (Configuration.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader)
|
||||
|
||||
@@ -47,10 +47,12 @@ public static class DownloadFailureClassifier
|
||||
|
||||
/// <summary>
|
||||
/// Audible attaches a rejection reason per validation type it ran. <c>GenericError</c> is Audible
|
||||
/// declining to say why, which in practice means an outage or throttling rather than a decision about the
|
||||
/// title; the GUI already reads it that way when it chooses which guidance to offer. Anything else names
|
||||
/// an eligibility problem with the account or the title, which will not change within the hour. Saying
|
||||
/// nothing at all is also treated as an outage: a refusal with no stated reason is not a settled one.
|
||||
/// declining to say why, which in practice means an outage rather than a decision about the title;
|
||||
/// the GUI already reads it that way when it chooses which guidance to offer. Explicit
|
||||
/// <c>CustomerThrottled</c> is a license denial the UI names separately; backoff still treats it as
|
||||
/// a settled refusal. Anything else names an eligibility problem with the account or the title,
|
||||
/// which will not change within the hour. Saying nothing at all is also treated as an outage: a
|
||||
/// refusal with no stated reason is not a settled one.
|
||||
/// </summary>
|
||||
private static DownloadFailureDiagnosis ClassifyLicenseDenial(ContentLicenseDeniedException ex)
|
||||
{
|
||||
|
||||
@@ -25,10 +25,17 @@ public partial class DownloadOptions
|
||||
public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||
{
|
||||
var license = await ChooseContent(api, libraryBook, config, token);
|
||||
|
||||
// Only the audiobook download needs this. A granted license always carries it, and a supplement is
|
||||
// fetched from the same license without ever looking at it, so the requirement belongs here rather
|
||||
// than in LicenseInfo.
|
||||
if (license.ContentMetadata is not ContentMetadata contentMetadata)
|
||||
throw new InvalidDataException($"Content license contains no content metadata for asin: [{libraryBook.Book.AudibleProductId}]");
|
||||
|
||||
Serilog.Log.Logger.Debug("Content License {@License}", new
|
||||
{
|
||||
license.DrmType,
|
||||
license.ContentMetadata.ContentReference
|
||||
contentMetadata.ContentReference
|
||||
});
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -40,8 +47,8 @@ public partial class DownloadOptions
|
||||
var metadata = await api.GetContentMetadataAsync(
|
||||
libraryBook.Book.AudibleProductId,
|
||||
license.DrmType,
|
||||
license.ContentMetadata.ContentReference.Acr,
|
||||
license.ContentMetadata.ContentReference.FileVersion);
|
||||
contentMetadata.ContentReference.Acr,
|
||||
contentMetadata.ContentReference.FileVersion);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
@@ -49,19 +56,19 @@ public partial class DownloadOptions
|
||||
{
|
||||
libraryBook.Book.AudibleProductId,
|
||||
license.DrmType,
|
||||
license.ContentMetadata.ContentReference.Acr,
|
||||
license.ContentMetadata.ContentReference.FileVersion
|
||||
contentMetadata.ContentReference.Acr,
|
||||
contentMetadata.ContentReference.FileVersion
|
||||
});
|
||||
}
|
||||
else if (metadata.ContentReference != license.ContentMetadata.ContentReference)
|
||||
else if (metadata.ContentReference != contentMetadata.ContentReference)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {DrmType}. {@Metadata}. {@License} ",
|
||||
license.DrmType,
|
||||
metadata.ContentReference,
|
||||
license.ContentMetadata.ContentReference);
|
||||
contentMetadata.ContentReference);
|
||||
}
|
||||
else
|
||||
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
|
||||
contentMetadata.ChapterInfo = metadata.ChapterInfo;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
return license;
|
||||
@@ -70,25 +77,29 @@ public partial class DownloadOptions
|
||||
public class LicenseInfo
|
||||
{
|
||||
public DrmType DrmType { get; set; }
|
||||
public ContentMetadata ContentMetadata { get; set; }
|
||||
public ContentMetadata? ContentMetadata { get; set; }
|
||||
public KeyData[]? DecryptionKeys { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Where Audible says this title's supplement can be fetched from, or null when it says the title has
|
||||
/// none. The license request asks for it (<c>response_groups</c> includes <c>pdf_url</c>), so carrying
|
||||
/// it here is what lets one license serve both the audiobook and the PDF.
|
||||
/// </summary>
|
||||
public string? PdfUrl { get; set; }
|
||||
|
||||
[JsonConstructor]
|
||||
private LicenseInfo()
|
||||
{
|
||||
ContentMetadata = null!;
|
||||
}
|
||||
private LicenseInfo() { }
|
||||
|
||||
public static LicenseInfo Create(ContentLicense license, IEnumerable<KeyData>? keys = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(license, nameof(license));
|
||||
ArgumentNullException.ThrowIfNull(license.ContentMetadata, nameof(license.ContentMetadata));
|
||||
|
||||
return new LicenseInfo
|
||||
{
|
||||
DrmType = license.DrmType,
|
||||
ContentMetadata = license.ContentMetadata,
|
||||
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher)
|
||||
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher),
|
||||
PdfUrl = license.PdfUrl
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +107,23 @@ public partial class DownloadOptions
|
||||
=> voucher?.Key is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests a plain content license, without the Widevine negotiation and the chapter-metadata correction
|
||||
/// that <see cref="GetDownloadLicenseAsync"/> layers on for the audiobook download.
|
||||
/// <para>
|
||||
/// A supplement needs nothing from a license but its <see cref="LicenseInfo.PdfUrl"/>, and asking for the
|
||||
/// same license the audiobook download asks for is what makes one license usable by both steps.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static async Task<LicenseInfo> GetSupplementLicenseAsync(Api api, LibraryBook libraryBook, Configuration config)
|
||||
{
|
||||
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, ChooseQuality(config));
|
||||
return LicenseInfo.Create(license);
|
||||
}
|
||||
|
||||
private static DownloadQuality ChooseQuality(Configuration config)
|
||||
=> config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
|
||||
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
|
||||
{
|
||||
Serilog.Log.Logger.Information("Download Settings {@Settings}", new
|
||||
@@ -107,7 +135,7 @@ public partial class DownloadOptions
|
||||
config.SpatialAudioCodec
|
||||
});
|
||||
|
||||
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
var dlQuality = ChooseQuality(config);
|
||||
|
||||
bool canUseWidevine = api.SupportsWidevine();
|
||||
if (!config.UseWidevine || !canUseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
|
||||
@@ -117,7 +145,7 @@ public partial class DownloadOptions
|
||||
if (canUseWidevine)
|
||||
Serilog.Log.Logger.Warning("Unable to get a Widevine CDM. Falling back to ADRM.");
|
||||
else
|
||||
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
|
||||
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. The iPhone registration cannot use Widevine. To use Widevine, remove and re-add the account while registered as Android.", libraryBook.Account.ToMask());
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -181,9 +209,12 @@ public partial class DownloadOptions
|
||||
/// </summary>
|
||||
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
|
||||
{
|
||||
if (licInfo.ContentMetadata?.ChapterInfo is not ChapterInfo chapterInfo)
|
||||
throw new InvalidDataException($"Content license contains no chapter info for asin: [{libraryBook.Book.AudibleProductId}]");
|
||||
|
||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||
var chapters
|
||||
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||
= flattenChapters(chapterInfo.Chapters, titleConcat)
|
||||
.OrderBy(c => c.StartOffsetMs)
|
||||
.ToList();
|
||||
|
||||
@@ -191,7 +222,7 @@ public partial class DownloadOptions
|
||||
combineCredits(chapters);
|
||||
|
||||
if (config.StripAudibleBrandAudio)
|
||||
stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
|
||||
stripBranding(chapters, chapterInfo.BrandIntroDurationMs, chapterInfo.BrandOutroDurationMs);
|
||||
|
||||
if (config.SplitFilesByChapter)
|
||||
combineShortChapters(chapters, config.MinimumFileDuration * 1000);
|
||||
@@ -199,7 +230,7 @@ public partial class DownloadOptions
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
|
||||
{
|
||||
ChapterInfo = new Mpeg4Lib.ChapterInfo(TimeSpan.FromMilliseconds(chapters[0].StartOffsetMs)),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(chapterInfo.RuntimeLengthMs),
|
||||
};
|
||||
|
||||
//Build AAXClean.ChapterInfo
|
||||
|
||||
@@ -56,13 +56,16 @@ public partial class DownloadOptions : IDownloadOptions, IDisposable
|
||||
|
||||
ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo));
|
||||
|
||||
if (licInfo.ContentMetadata.ContentUrl?.OfflineUrl is not string licUrl)
|
||||
if (licInfo.ContentMetadata is not AudibleApi.Common.ContentMetadata contentMetadata)
|
||||
throw new InvalidDataException("Content license doesn't contain content metadata");
|
||||
|
||||
if (contentMetadata.ContentUrl?.OfflineUrl is not string licUrl)
|
||||
throw new InvalidDataException("Content license doesn't contain an offline Url");
|
||||
|
||||
DownloadUrl = licUrl;
|
||||
DecryptionKeys = licInfo.DecryptionKeys;
|
||||
DrmType = licInfo.DrmType;
|
||||
ContentMetadata = licInfo.ContentMetadata;
|
||||
ContentMetadata = contentMetadata;
|
||||
InputType
|
||||
= licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash
|
||||
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 4 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax
|
||||
|
||||
@@ -13,44 +13,80 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
public class DownloadPdf : Processable, IProcessable<DownloadPdf>
|
||||
public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDownload
|
||||
{
|
||||
public override string Name => "Download Pdf";
|
||||
|
||||
/// <summary>
|
||||
/// A supplement is delivered through a content license, the same request the audiobook download makes, so
|
||||
/// a refusal of one is a refusal of the other and belongs in the record that keeps a scheduled run from
|
||||
/// asking again.
|
||||
/// </summary>
|
||||
protected override bool RecordsAttemptFailures => true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// A title Audible has a supplement for, whose supplement is not downloaded and was not written off. The
|
||||
/// status check matches <see cref="DataLayer.LibraryBookQueries.NeedsPdfDownload"/>, and treats
|
||||
/// <see cref="LiberatedStatus.Error"/> as "don't retry" exactly as the audiobook download does through
|
||||
/// <c>AudioExists</c>.
|
||||
/// </summary>
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
|
||||
&& !libraryBook.Book.PdfExists;
|
||||
&& libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
string? createdDirectory = null;
|
||||
ObtainedLicense = null;
|
||||
|
||||
try
|
||||
{
|
||||
// A license this run already holds is used as it is. Requesting another would repeat the request
|
||||
// the audiobook download just made, which is what filled the log in issue #1973.
|
||||
var carriedLicense = LicenseInfo;
|
||||
var license = carriedLicense ?? await requestLicenseAsync(libraryBook);
|
||||
ObtainedLicense = license;
|
||||
|
||||
if (license.PdfUrl is not string downloadUrl)
|
||||
return await noSupplementAvailableAsync(libraryBook);
|
||||
|
||||
var proposedDownloadFilePath = GetProposedDownloadFilePath(libraryBook);
|
||||
createdDirectory = createDirectoryFor(proposedDownloadFilePath);
|
||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
|
||||
if (result.IsSuccess)
|
||||
var result = await downloadAndVerifyAsync(libraryBook, downloadUrl, proposedDownloadFilePath);
|
||||
|
||||
// Audible's download links are signed and expire, and a carried license was granted before an
|
||||
// audiobook download that may have taken hours. One fresh license is worth trying before the
|
||||
// title is reported as failed; a license requested moments ago would only fail the same way.
|
||||
if (!result.IsSuccess && carriedLicense is not null)
|
||||
{
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
SetFileTime(libraryBook, actualDownloadedFilePath);
|
||||
if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir)
|
||||
SetDirectoryTime(libraryBook, outputDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keeping it would leave a file in the library that is not the supplement, under a name that
|
||||
// says it is, and would stop the folder cleanup below from running.
|
||||
FileUtility.SaferDelete(actualDownloadedFilePath);
|
||||
Serilog.Log.Logger.Information(
|
||||
"Requesting a new license to fetch the supplement of {libraryBook}: the one from its audiobook download did not work.",
|
||||
libraryBook.LogFriendly());
|
||||
|
||||
license = await requestLicenseAsync(libraryBook);
|
||||
ObtainedLicense = license;
|
||||
|
||||
if (license.PdfUrl is not string freshDownloadUrl)
|
||||
return await noSupplementAvailableAsync(libraryBook);
|
||||
|
||||
result = await downloadAndVerifyAsync(libraryBook, freshDownloadUrl, proposedDownloadFilePath);
|
||||
}
|
||||
|
||||
await libraryBook.UpdatePdfStatusAsync(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
// Refusals are left to reach ProcessSingleAsync, which records them for every step alike. Everything
|
||||
// else is still swallowed here, which is what stopped a missing PDF from taking the app down with it.
|
||||
catch (Exception ex) when (DownloadFailureClassifier.Classify(ex) is null)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
||||
|
||||
@@ -66,6 +102,75 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DownloadOptions.LicenseInfo> requestLicenseAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
return await DownloadOptions.GetSupplementLicenseAsync(api, libraryBook, Configuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audible granted a license and it carried no supplement link, which is Audible saying this title has no
|
||||
/// PDF to give. Written off the way the audiobook download writes off a title it should not attempt again:
|
||||
/// <see cref="LiberatedStatus.Error"/> means "don't retry" for a supplement exactly as it does for audio,
|
||||
/// and the same controls - a forced or named <c>liberate</c> run, Mark PDF as Download Pending - set it back.
|
||||
/// <para>
|
||||
/// Reported as a completed step and not as a failure. Nothing went wrong and nothing is left to attempt, and
|
||||
/// a failure here would put the queue's Abort / Retry / Ignore question to the user about a book whose audio
|
||||
/// is fine.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task<StatusHandler> noSupplementAvailableAsync(LibraryBook libraryBook)
|
||||
{
|
||||
const string explanation = "Audible has no PDF for this title, although the library listing says it has one. "
|
||||
+ "Its PDF will not be asked for again; mark the PDF 'Download Pending' to try once more.";
|
||||
|
||||
Serilog.Log.Logger.Information(
|
||||
"Audible granted a license for {libraryBook} that carried no supplement link, so it has no PDF to download. {explanation}",
|
||||
libraryBook.LogFriendly(),
|
||||
explanation);
|
||||
|
||||
OnStatusUpdate(explanation);
|
||||
await libraryBook.UpdatePdfStatusAsync(LiberatedStatus.Error);
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the supplement and keeps it only if what came back is one. A transport failure is reported as a
|
||||
/// failed status rather than thrown, so that a license worth retrying still gets retried.
|
||||
/// </summary>
|
||||
private async Task<StatusHandler> downloadAndVerifyAsync(LibraryBook libraryBook, string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
string actualDownloadedFilePath;
|
||||
try
|
||||
{
|
||||
actualDownloadedFilePath = await downloadPdfAsync(downloadUrl, proposedDownloadFilePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
||||
return new StatusHandler { $"Error downloading PDF. See log for details. Error summary: {ex.Message}" };
|
||||
}
|
||||
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
OnFileCreated(libraryBook, actualDownloadedFilePath);
|
||||
SetFileTime(libraryBook, actualDownloadedFilePath);
|
||||
if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir)
|
||||
SetDirectoryTime(libraryBook, outputDir);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keeping it would leave a file in the library that is not the supplement, under a name that
|
||||
// says it is, and would stop the folder cleanup in ProcessAsync from running.
|
||||
FileUtility.SaferDelete(actualDownloadedFilePath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The directory this run had to create, or null when it was already there.</summary>
|
||||
private static string? createDirectoryFor(string filePath)
|
||||
{
|
||||
@@ -113,17 +218,14 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>
|
||||
= Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId))
|
||||
?? AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration);
|
||||
|
||||
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension);
|
||||
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension, returnFirstExisting: Configuration.OverwriteExisting);
|
||||
}
|
||||
|
||||
private static string? getdownloadUrl(LibraryBook libraryBook)
|
||||
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
|
||||
|
||||
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
|
||||
private async Task<string> downloadPdfAsync(string downloadUrl, string proposedDownloadFilePath)
|
||||
{
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
|
||||
|
||||
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
|
||||
|
||||
var client = new HttpClient();
|
||||
|
||||
@@ -26,4 +26,8 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace FileLiberator;
|
||||
|
||||
/// <summary>
|
||||
/// A step that downloads something Audible delivers through a content license: the audiobook, and the
|
||||
/// supplement whose link the same license carries.
|
||||
/// <para>
|
||||
/// One license serves every such step for a title. The request is identical - <c>response_groups</c> asks for
|
||||
/// <c>pdf_url</c> alongside the content reference - so a run holding a license must hand it on rather than ask
|
||||
/// for another, which would be a second refusal to record for a title already refused once.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ILicensedDownload
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional override to supply license info directly instead of querying the api based on Configuration options
|
||||
/// </summary>
|
||||
DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The license this step ended up using, whether supplied or requested, so the next step for the same title
|
||||
/// can reuse it. Null until a license has been obtained, and reset at the start of every attempt: a
|
||||
/// Processable instance is reused across books, so a license left behind belongs to a different title.
|
||||
/// </summary>
|
||||
DownloadOptions.LicenseInfo? ObtainedLicense { get; }
|
||||
}
|
||||
@@ -45,8 +45,10 @@ public abstract class Processable
|
||||
|
||||
/// <summary>
|
||||
/// Whether a refusal from Audible during this step should be remembered, so a scheduled run stops asking
|
||||
/// for the same license every time. Only the audiobook download does: it is the request Audible refuses,
|
||||
/// and the record gates that same request.
|
||||
/// for the same license every time. True for the steps that request a content license - the audiobook
|
||||
/// download and the supplement download, which make the same request - because that request is the one
|
||||
/// Audible refuses and the one the record gates. A step that only works on files already on disk has
|
||||
/// nothing to record.
|
||||
/// </summary>
|
||||
protected virtual bool RecordsAttemptFailures => false;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AudibleUtilities;
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
@@ -21,18 +22,23 @@ public static class UtilityExtensions
|
||||
account: libraryBook.Account.ToMask()
|
||||
);
|
||||
|
||||
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
|
||||
|
||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||
{
|
||||
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale)
|
||||
?? throw new InvalidCredentialException($"No account found for '{libraryBook.Account}' and locale '{libraryBook.Book.Locale}'");
|
||||
|
||||
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: true, storeLocale: libraryBook.StoreLocale());
|
||||
return apiExtended.Api;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marketplace a download or license request must speak to: the one the book was scanned from, which is not
|
||||
/// always the account's home store. See issue #2020.
|
||||
/// </summary>
|
||||
internal static Locale StoreLocale(this LibraryBook libraryBook)
|
||||
=> Localization.Get(libraryBook.Book.Locale);
|
||||
|
||||
public static bool SupportsWidevine(this AudibleApi.Api api)
|
||||
{
|
||||
//TODO: Expose Api's identity maintainer directly instead of using reflection.
|
||||
|
||||
@@ -69,19 +69,33 @@ public class BackgroundFileSystem : IDisposable
|
||||
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
|
||||
}
|
||||
|
||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||
try
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
||||
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
|
||||
fileSystemWatcher = new FileSystemWatcher(RootDirectory)
|
||||
{
|
||||
IncludeSubdirectories = true,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
fileSystemWatcher.Created += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
|
||||
fileSystemWatcher.Error += FileSystemWatcher_Error;
|
||||
|
||||
backgroundScanner = new Task(BackgroundScanner);
|
||||
backgroundScanner.Start();
|
||||
backgroundScanner = new Task(BackgroundScanner);
|
||||
backgroundScanner.Start();
|
||||
}
|
||||
// Watching a directory fails for the same reasons reading one does, and a removable drive can be pulled
|
||||
// between the listing above and this. Constructing this type happens inside the static initializer that
|
||||
// builds the Books file cache, and the runtime caches a failed initializer for the life of the process:
|
||||
// one throw here would be rethrown at every later reader of the Books directory. Give up the live
|
||||
// updates instead. Dropping RootDirectory has the owner rebuild this once the directory works again.
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Could not watch a directory for changes, so its file cache was abandoned: {@DebugText}", new { path = (string?)RootDirectory });
|
||||
Stop();
|
||||
RootDirectory = null;
|
||||
}
|
||||
}
|
||||
private void Stop()
|
||||
{
|
||||
@@ -107,7 +121,17 @@ public class BackgroundFileSystem : IDisposable
|
||||
catch (ObjectDisposedException) { }
|
||||
|
||||
//Wait for background scanner to terminate before reinitializing.
|
||||
backgroundScanner?.Wait();
|
||||
try
|
||||
{
|
||||
backgroundScanner?.Wait();
|
||||
}
|
||||
// A scanner that died still has to be waited on, but its failure is not this caller's to raise: Stop() is
|
||||
// reached from Refresh() and from Dispose(), neither of which has anything to do with whatever went wrong,
|
||||
// and both of which are about to replace the scanner anyway.
|
||||
catch (AggregateException ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "The file cache's background scanner had already stopped on an error.");
|
||||
}
|
||||
|
||||
//Dispose of directoryChangesEvents after backgroundScanner exists. Clear the field first so a late event
|
||||
//has nothing to add to. CompleteAdding has to have happened while it was still set, or the scanner would
|
||||
@@ -146,8 +170,19 @@ public class BackgroundFileSystem : IDisposable
|
||||
{
|
||||
while (directoryChangesEvents?.TryTake(out var change, -1) is true)
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
UpdateLocalCache(change);
|
||||
try
|
||||
{
|
||||
lock (fsCacheLocker)
|
||||
UpdateLocalCache(change);
|
||||
}
|
||||
// One path this cannot read must not end the scan. An exception here used to be terminal twice over:
|
||||
// it stopped the cache tracking anything further, and it was stored on the task, so the next Stop()
|
||||
// rethrew it as an AggregateException at whoever had called Refresh() - a caller with nothing to do
|
||||
// with the file that went missing.
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Could not apply a file system change to the file cache: {@DebugText}", new { change.ChangeType, change.FullPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,14 +216,36 @@ public class BackgroundFileSystem : IDisposable
|
||||
{
|
||||
path = path.LongPathName;
|
||||
//Temporary files created when updating the db will disappear before their attributes can be read.
|
||||
if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path))
|
||||
if (Path.GetFileName(path).Contains("LibationContext.db"))
|
||||
return;
|
||||
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
|
||||
|
||||
// Whether it exists and what it is were two questions, and the answer to the first could stop being true
|
||||
// before the second was asked: a download's temp file, or a folder the user has just moved or deleted, is
|
||||
// gone by the time its attributes are read. One question instead, and its failure means the same thing
|
||||
// the existence check meant - there is nothing here to add.
|
||||
if (TryGetAttributes(path) is not FileAttributes attributes)
|
||||
return;
|
||||
|
||||
if (attributes.HasFlag(FileAttributes.Directory))
|
||||
AddUniqueFiles(SafestEnumerateFiles(path));
|
||||
else
|
||||
AddUniqueFile(path);
|
||||
}
|
||||
|
||||
/// <returns>What the path is, or null when it cannot be read and so has nothing to add.</returns>
|
||||
internal static FileAttributes? TryGetAttributes(LongPath path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.GetAttributes(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Nothing to add to the file cache for {@DebugText}", new { path = (string)path });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<LongPath> SafestEnumerateFiles(string path)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.4.1" />
|
||||
<PackageReference Include="Polly" Version="8.6.6" />
|
||||
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
|
||||
<PackageReference Include="Polly" Version="8.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -263,8 +263,10 @@ public static class FileUtility
|
||||
/// <param name="rootPath">Starting directory</param>
|
||||
/// <param name="patternMatch">Filename pattern match</param>
|
||||
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
|
||||
/// <param name="onIncomplete">Called with the reason when the walk ends early, so a caller that must not
|
||||
/// mistake a truncated list for an empty directory can tell the difference.</param>
|
||||
/// <returns>List of files</returns>
|
||||
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
|
||||
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly, Action<Exception>? onIncomplete = null)
|
||||
{
|
||||
var enumOptions = new EnumerationOptions
|
||||
{
|
||||
@@ -273,7 +275,101 @@ public static class FileUtility
|
||||
ReturnSpecialDirectories = false,
|
||||
MatchType = MatchType.Simple
|
||||
};
|
||||
return Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p);
|
||||
return IterateSafely(
|
||||
() => Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p),
|
||||
path,
|
||||
onIncomplete);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks a file system sequence so that a directory which stops being readable partway through ends the walk
|
||||
/// instead of throwing at whoever is consuming it.
|
||||
/// <para>
|
||||
/// <see cref="EnumerationOptions.IgnoreInaccessible"/> only forgives permissions. A disconnected or failing
|
||||
/// volume raises an I/O error from the enumerator itself, and because enumeration is lazy that error is
|
||||
/// raised wherever the sequence is finally walked - which is past any try/catch the caller wrapped around
|
||||
/// the call that produced it. Libation lost a whole session that way: a Books folder on a USB drive that
|
||||
/// started returning I/O errors took down the file cache, the type initializer that builds it, and with it
|
||||
/// every subsequent launch. See issue #1984.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static IEnumerable<LongPath> IterateSafely(Func<IEnumerable<LongPath>> getSequence, LongPath path, Action<Exception>? onIncomplete = null)
|
||||
{
|
||||
IEnumerator<LongPath> enumerator;
|
||||
try
|
||||
{
|
||||
//Opening the directory is itself a read, and fails the same way.
|
||||
enumerator = getSequence().GetEnumerator();
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadable(ex))
|
||||
{
|
||||
ReportIncomplete(ex, path, onIncomplete);
|
||||
yield break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
LongPath current;
|
||||
try
|
||||
{
|
||||
if (!enumerator.MoveNext())
|
||||
break;
|
||||
current = enumerator.Current;
|
||||
}
|
||||
catch (Exception ex) when (IsUnreadable(ex))
|
||||
{
|
||||
//Whatever has already been read is still good and still worth returning.
|
||||
ReportIncomplete(ex, path, onIncomplete);
|
||||
break;
|
||||
}
|
||||
|
||||
yield return current;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
enumerator.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsUnreadable(Exception ex)
|
||||
=> ex is IOException or UnauthorizedAccessException or System.Security.SecurityException;
|
||||
|
||||
private static void ReportIncomplete(Exception ex, LongPath path, Action<Exception>? onIncomplete)
|
||||
{
|
||||
try
|
||||
{
|
||||
//A directory that has simply gone is routine: temp folders are created and cleaned up under a scan
|
||||
//all the time. A directory that is there and cannot be read is worth seeing in a bug report.
|
||||
if (ex is DirectoryNotFoundException)
|
||||
Serilog.Log.Logger.Debug(ex, "Stopped listing files in a directory that is no longer there: {@DebugText}", new { path = (string)path });
|
||||
else
|
||||
Serilog.Log.Logger.Warning(ex, "Could not finish listing files. The results are incomplete: {@DebugText}", new { path = (string)path });
|
||||
}
|
||||
catch { /* logging must not be the thing that breaks a file listing */ }
|
||||
|
||||
onIncomplete?.Invoke(ex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a directory can actually be read, as opposed to merely existing. A removable drive that has been
|
||||
/// pulled, and a failing one, can both still answer that they are a directory while every read of them fails.
|
||||
/// </summary>
|
||||
public static bool CanEnumerate(LongPath path)
|
||||
{
|
||||
var readable = true;
|
||||
|
||||
//The first entry is enough. A volume that cannot be read fails on the first attempt, and this must not
|
||||
//pay for walking a whole library to answer the question.
|
||||
foreach (var _ in IterateSafely(
|
||||
() => Directory.EnumerateFileSystemEntries(path.Path).Select(p => (LongPath)p),
|
||||
path,
|
||||
_ => readable = false))
|
||||
break;
|
||||
|
||||
return readable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")]
|
||||
@@ -70,15 +70,17 @@
|
||||
<TrimmableAssembly Include="Avalonia.Themes.Default" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.14" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
public partial class MainVM : ViewModelBase
|
||||
{
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public MainVM()
|
||||
{
|
||||
|
||||
@@ -77,7 +77,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
item.IsChecked = false;
|
||||
}
|
||||
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public async Task RestoreCheckedAsync()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<!-- Remove obj folder -->
|
||||
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||
|
||||
@@ -29,6 +29,15 @@ public class App : Application
|
||||
|
||||
/// <summary>Set by <see cref="Program"/> when another Libation instance already holds this folder's lock.</summary>
|
||||
public static bool IsAnotherInstanceRunning { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set by <see cref="Program"/> when startup rolled back an incomplete in-app upgrade. The restored
|
||||
/// files on disk no longer match the assemblies this process loaded, so it shows this and quits.
|
||||
/// </summary>
|
||||
public static StartupRecoveryNotice? StartupRecoveryNotice { get; set; }
|
||||
|
||||
/// <summary>Set when the user accepted the offer to start Libation again. Read by <see cref="Program"/> after shutdown.</summary>
|
||||
public static bool RestartRequested { get; private set; }
|
||||
public static ChardonnayTheme? DefaultThemeColors { get; private set; }
|
||||
public static MainWindow? MainWindow { get; private set; }
|
||||
public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/");
|
||||
@@ -50,17 +59,27 @@ public class App : Application
|
||||
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
|
||||
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||
|
||||
// Another instance already owns this folder. No database work has been done in this process,
|
||||
// so just tell the user and shut down instead of racing on shared files. See issue #1931.
|
||||
if (IsAnotherInstanceRunning)
|
||||
// The install folder was just rolled back underneath us. See issue #2001.
|
||||
if (StartupRecoveryNotice is { } recovery)
|
||||
{
|
||||
_ = ShowAlreadyRunningThenShutdownAsync(desktop);
|
||||
_ = ShowRecoveryThenShutdownAsync(desktop, recovery);
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
if (InstallUpgradeManager.TakeStartupRecoveryAlert() is { } recovery)
|
||||
_ = MessageBox.Show(null, recovery.Body, recovery.Title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
// Another instance already owns this folder. No database work has been done in this process,
|
||||
// so just tell the user and shut down instead of racing on shared files. See issue #1931.
|
||||
if (IsAnotherInstanceRunning)
|
||||
{
|
||||
_ = ShowThenShutdownAsync(
|
||||
desktop,
|
||||
"Libation is already running.\r\n\r\n"
|
||||
+ "Please use the Libation window that is already open. Running more than one copy of "
|
||||
+ "Libation against the same folder at the same time can corrupt your library.",
|
||||
"Libation is already running");
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
BadBookActionDialogBase.ShowAsyncImpl = (owner, message, caption) =>
|
||||
Dialogs.BadBookActionDialog.ShowAsync(owner as Window, message, caption);
|
||||
@@ -80,21 +99,54 @@ public class App : Application
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private static async Task ShowAlreadyRunningThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
/// <summary>
|
||||
/// Reports the rollback and shuts down. When the restored install is worth going back into, the user is
|
||||
/// asked whether to start Libation again, and <see cref="Program"/> does it after this shuts down.
|
||||
/// </summary>
|
||||
private static async Task ShowRecoveryThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, StartupRecoveryNotice recovery)
|
||||
{
|
||||
if (!recovery.OfferRestart)
|
||||
{
|
||||
await ShowThenShutdownAsync(desktop, recovery.Body, recovery.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await MessageBox.Show(
|
||||
"Libation is already running.\r\n\r\n"
|
||||
+ "Please use the Libation window that is already open. Running more than one copy of "
|
||||
+ "Libation against the same folder at the same time can corrupt your library.",
|
||||
"Libation is already running",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
var answer = await MessageBox.Show(
|
||||
recovery.Body,
|
||||
recovery.Title,
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning,
|
||||
MessageBoxDefaultButton.Button1);
|
||||
|
||||
RestartRequested = answer is DialogResult.Yes;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to show the 'already running' message");
|
||||
// Serilog is unavailable on this path, and shutting down matters more than the question.
|
||||
StartupLog.Error(ex, "Failed to ask whether to restart after the rollback");
|
||||
}
|
||||
finally
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows one modal and then shuts the app down without opening the main window, for the cases where
|
||||
/// Libation has decided at startup that it must not keep running.
|
||||
/// </summary>
|
||||
private static async Task ShowThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, string body, string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MessageBox.Show(body, title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Serilog is unavailable on the rollback path, and shutting down matters more than the message.
|
||||
StartupLog.Error(ex, $"Failed to show the startup message '{title}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -1,56 +1,54 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Interactivity;
|
||||
using DataLayer;
|
||||
|
||||
namespace LibationAvalonia.Controls;
|
||||
|
||||
public class DataGridMyRatingColumn : DataGridBoundColumn
|
||||
public class DataGridMyRatingColumn : DataGridTemplateColumn
|
||||
{
|
||||
[AssignBinding] public BindingBase? BackgroundBinding { get; set; }
|
||||
[AssignBinding] public BindingBase? OpacityBinding { get; set; }
|
||||
[AssignBinding] public BindingBase? RatingBinding { get; set; }
|
||||
private static Rating DefaultRating => new Rating(0, 0, 0);
|
||||
public DataGridMyRatingColumn()
|
||||
{
|
||||
BindingTarget = MyRatingCellEditor.RatingProperty;
|
||||
this.IsReadOnly = false;
|
||||
//Must set the CellEditingTemplate to enable cell editing in a DataGridTemplateColumn.
|
||||
CellEditingTemplate = new FuncDataTemplate(typeof(Rating), (value, _) =>
|
||||
{
|
||||
var myRatingElement = CreateControl();
|
||||
myRatingElement.Name = "CellMyRatingEditor";
|
||||
myRatingElement.IsEditingMode = true;
|
||||
return myRatingElement;
|
||||
});
|
||||
}
|
||||
|
||||
protected override Control GenerateElement(DataGridCell cell, object dataItem)
|
||||
{
|
||||
var myRatingElement = new MyRatingCellEditor
|
||||
{
|
||||
Name = "CellMyRatingDisplay",
|
||||
IsEditingMode = false
|
||||
};
|
||||
var myRatingElement = CreateControl();
|
||||
myRatingElement.Name = "CellMyRatingDisplay";
|
||||
myRatingElement.IsEditingMode = false;
|
||||
|
||||
cell.Tag = this;
|
||||
|
||||
if (!IsReadOnly)
|
||||
ToolTip.SetTip(myRatingElement, "Click to change ratings");
|
||||
|
||||
if (Binding != null)
|
||||
myRatingElement.Bind(BindingTarget, Binding);
|
||||
if (BackgroundBinding != null)
|
||||
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
|
||||
if (OpacityBinding != null)
|
||||
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
|
||||
|
||||
return myRatingElement;
|
||||
}
|
||||
|
||||
protected override Control GenerateEditingElementDirect(DataGridCell cell, object dataItem)
|
||||
private MyRatingCellEditor CreateControl()
|
||||
{
|
||||
var myRatingElement = new MyRatingCellEditor
|
||||
{
|
||||
Name = "CellMyRatingEditor",
|
||||
IsEditingMode = true
|
||||
};
|
||||
var myRatingElement = new MyRatingCellEditor();
|
||||
|
||||
if (RatingBinding != null)
|
||||
myRatingElement.Bind(MyRatingCellEditor.RatingProperty, RatingBinding);
|
||||
if (BackgroundBinding != null)
|
||||
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
|
||||
if (OpacityBinding != null)
|
||||
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
|
||||
|
||||
return myRatingElement;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,13 +23,19 @@ public partial class MyRatingCellEditor : UserControl
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var subscriber = this.ObservableForProperty(p => p.Rating).Subscribe(o => DisplayStarRating(o.Value ?? new Rating(0, 0, 0)));
|
||||
Unloaded += (_, _) => subscriber.Dispose();
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
Rating = new Rating(5, 4, 3);
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == RatingProperty)
|
||||
{
|
||||
DisplayStarRating(change.GetNewValue<Rating>() ?? new Rating(0f, 0f, 0f));
|
||||
}
|
||||
}
|
||||
|
||||
private void DisplayStarRating(Rating rating)
|
||||
{
|
||||
var blankValue = IsEditingMode ? HOLLOW_STAR : string.Empty;
|
||||
|
||||
@@ -69,21 +69,45 @@
|
||||
</StackPanel>
|
||||
|
||||
</controls:GroupBox>
|
||||
<CheckBox
|
||||
<StackPanel
|
||||
Grid.Row="1"
|
||||
Margin="10,5"
|
||||
IsEnabled="{Binding !UseWebViewSettingDisabled}"
|
||||
IsChecked="{Binding UseWebView, Mode=TwoWay}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding UseWebViewText}" />
|
||||
<TextBlock
|
||||
IsVisible="{Binding UseWebViewSettingDisabled}"
|
||||
FontStyle="Italic"
|
||||
Opacity="0.8"
|
||||
Margin="0,2,0,0"
|
||||
Text="{Binding UseWebViewSnapMessage}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
Spacing="5">
|
||||
<CheckBox
|
||||
IsEnabled="{Binding !UseWebViewSettingDisabled}"
|
||||
IsChecked="{Binding UseWebView, Mode=TwoWay}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{Binding UseWebViewText}" />
|
||||
<TextBlock
|
||||
IsVisible="{Binding UseWebViewSettingDisabled}"
|
||||
FontStyle="Italic"
|
||||
Opacity="0.8"
|
||||
Margin="0,2,0,0"
|
||||
Text="{Binding UseWebViewSnapMessage}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
|
||||
<TextBlock
|
||||
Margin="0,8,0,0"
|
||||
Text="{Binding DeviceRegistrationKindText}" />
|
||||
<controls:WheelComboBox
|
||||
Height="25"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
SelectedItem="{Binding SelectedDeviceRegistration, Mode=TwoWay}"
|
||||
ItemsSource="{Binding DeviceRegistrationOptions}"
|
||||
ToolTip.Tip="{Binding DeviceRegistrationKindTip}" />
|
||||
<TextBlock
|
||||
FontStyle="Italic"
|
||||
Opacity="0.8"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding DeviceRegistrationReLoginNote}" />
|
||||
|
||||
<CheckBox
|
||||
IsChecked="{Binding CheckForUpgradesAtStartup, Mode=TwoWay}"
|
||||
ToolTip.Tip="{Binding CheckForUpgradesAtStartupTip}">
|
||||
<TextBlock Text="{Binding CheckForUpgradesAtStartupText}" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="2"
|
||||
|
||||
@@ -44,7 +44,7 @@ public partial class AboutDialog : DialogWindow
|
||||
|
||||
async Task OnUpgradeAvailable(UpgradeEventArgs e)
|
||||
{
|
||||
var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties, e.CapUpgrade).ShowDialogAsync(this);
|
||||
var notificationResult = await new UpgradeNotificationDialog(e.UpgradeProperties, e.CapUpgrade, e.UpgradeUnavailableReason).ShowDialogAsync(this);
|
||||
|
||||
e.Ignore = notificationResult == DialogResult.Ignore;
|
||||
e.InstallUpgrade = notificationResult == DialogResult.OK;
|
||||
|
||||
@@ -87,6 +87,23 @@
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn Width="Auto" Header="Marketplaces">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
|
||||
<Button
|
||||
Content="{Binding MarketplacesButtonText}"
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
IsEnabled="{Binding CanCheckMarketplaces}"
|
||||
ToolTip.Tip="{Binding MarketplacesButtonToolTip}"
|
||||
Click="MarketplacesButton_Clicked" />
|
||||
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
Binding="{Binding AccountName, Mode=TwoWay}"
|
||||
|
||||
@@ -3,6 +3,7 @@ using AudibleUtilities;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
@@ -17,10 +18,16 @@ namespace LibationAvalonia.Dialogs;
|
||||
public partial class AccountsDialog : DialogWindow
|
||||
{
|
||||
public AvaloniaList<AccountDto> Accounts { get; } = new();
|
||||
private bool _isDirty;
|
||||
private bool _closeConfirmed;
|
||||
public class AccountDto : ViewModels.ViewModelBase
|
||||
{
|
||||
public IReadOnlyList<Locale> Locales => AccountsDialog.Locales;
|
||||
public bool LibraryScan { get; set; } = true;
|
||||
public bool LibraryScan
|
||||
{
|
||||
get => field;
|
||||
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
} = true;
|
||||
public string? AccountId
|
||||
{
|
||||
get => field;
|
||||
@@ -42,9 +49,19 @@ public partial class AccountsDialog : DialogWindow
|
||||
}
|
||||
}
|
||||
|
||||
public string? AccountName { get; set; }
|
||||
public string? AccountName
|
||||
{
|
||||
get => field;
|
||||
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
}
|
||||
public bool IsDefault => string.IsNullOrEmpty(AccountId);
|
||||
|
||||
/// <summary>
|
||||
/// Marketplaces beyond <see cref="SelectedLocale"/> that this account should also scan. Edited by the
|
||||
/// marketplaces dialog and written on save, like every other field here.
|
||||
/// </summary>
|
||||
public List<string> AdditionalLocaleNames { get; } = new();
|
||||
|
||||
public bool CanExport
|
||||
{
|
||||
get => field;
|
||||
@@ -56,6 +73,17 @@ public partial class AccountsDialog : DialogWindow
|
||||
? "Export account authorization to audible-cli"
|
||||
: "Authenticate this account (e.g. library scan) before exporting to audible-cli.";
|
||||
|
||||
/// <summary>
|
||||
/// Checking other marketplaces uses this account's stored credentials, so it needs the same thing an
|
||||
/// export does: an account that has logged in at least once.
|
||||
/// </summary>
|
||||
public bool CanCheckMarketplaces => CanExport;
|
||||
|
||||
public string MarketplacesButtonText => MarketplacesUi.ButtonText(AdditionalLocaleNames.Count + 1);
|
||||
|
||||
public string MarketplacesButtonToolTip
|
||||
=> CanCheckMarketplaces ? MarketplacesUi.ButtonToolTip : MarketplacesUi.NotAuthenticatedToolTip;
|
||||
|
||||
public AccountDto() => RefreshCanExport();
|
||||
|
||||
public AccountDto(Account account)
|
||||
@@ -64,13 +92,28 @@ public partial class AccountsDialog : DialogWindow
|
||||
AccountId = account.AccountId;
|
||||
SelectedLocale = Locales.Single(l => l.Name == account.Locale?.Name);
|
||||
AccountName = account.AccountName;
|
||||
AdditionalLocaleNames.AddRange(account.AdditionalLocales.Select(l => l.Name));
|
||||
RefreshCanExportFromAccount(account);
|
||||
}
|
||||
|
||||
public void SetAdditionalLocaleNames(IEnumerable<string> localeNames)
|
||||
{
|
||||
AdditionalLocaleNames.Clear();
|
||||
AdditionalLocaleNames.AddRange(localeNames);
|
||||
this.RaisePropertyChanged(nameof(MarketplacesButtonText));
|
||||
}
|
||||
|
||||
private void RefreshCanExportFromAccount(Account account)
|
||||
{
|
||||
CanExport = account.IdentityTokens?.IsValid == true;
|
||||
RaiseDerivedFromCanExport();
|
||||
}
|
||||
|
||||
private void RaiseDerivedFromCanExport()
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
|
||||
this.RaisePropertyChanged(nameof(CanCheckMarketplaces));
|
||||
this.RaisePropertyChanged(nameof(MarketplacesButtonToolTip));
|
||||
}
|
||||
|
||||
private void RefreshCanExport()
|
||||
@@ -78,7 +121,7 @@ public partial class AccountsDialog : DialogWindow
|
||||
if (string.IsNullOrEmpty(AccountId) || SelectedLocale is null)
|
||||
{
|
||||
CanExport = false;
|
||||
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
|
||||
RaiseDerivedFromCanExport();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,7 +129,7 @@ public partial class AccountsDialog : DialogWindow
|
||||
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a =>
|
||||
a.AccountId == AccountId && a.Locale?.Name == SelectedLocale.Name);
|
||||
CanExport = account?.IdentityTokens?.IsValid == true;
|
||||
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
|
||||
RaiseDerivedFromCanExport();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +163,11 @@ public partial class AccountsDialog : DialogWindow
|
||||
else if (e.Action is NotifyCollectionChangedAction.Remove && e.OldItems?.Count > 0)
|
||||
{
|
||||
foreach (var oldItem in e.OldItems.OfType<AccountDto>())
|
||||
{
|
||||
oldItem.PropertyChanged -= AccountDto_PropertyChanged;
|
||||
if (!oldItem.IsDefault)
|
||||
_isDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +175,13 @@ public partial class AccountsDialog : DialogWindow
|
||||
|
||||
private void AccountDto_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
// only user-editable fields; skip derived props like CanExport / button tooltips
|
||||
if (e.PropertyName is nameof(AccountDto.LibraryScan)
|
||||
or nameof(AccountDto.AccountId)
|
||||
or nameof(AccountDto.SelectedLocale)
|
||||
or nameof(AccountDto.AccountName))
|
||||
_isDirty = true;
|
||||
|
||||
if (!Accounts.Any(a => a.IsDefault))
|
||||
addBlankAccount();
|
||||
}
|
||||
@@ -174,14 +228,17 @@ public partial class AccountsDialog : DialogWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is { } dup)
|
||||
if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is not null)
|
||||
{
|
||||
await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {dup.AccountId}\r\nCountry: {dup.Locale?.Name}", "Cannot Add Duplicate Account");
|
||||
await MessageBox.Show(this, Mkb79AuthImporter.DuplicateMessage(importResult), "Cannot Add Duplicate Account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (importResult.Account is { } account)
|
||||
{
|
||||
Accounts.Add(new AccountDto(account));
|
||||
_isDirty = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -199,6 +256,32 @@ public partial class AccountsDialog : DialogWindow
|
||||
Export(acc);
|
||||
}
|
||||
|
||||
public async void MarketplacesButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (e.Source is not Button btn || btn.DataContext is not AccountDto acc)
|
||||
return;
|
||||
|
||||
// the probe speaks to Audible with this account's stored credentials, so it needs the saved account,
|
||||
// not the grid's copy of it
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a =>
|
||||
a.AccountId == acc.AccountId && a.Locale?.Name == acc.SelectedLocale?.Name);
|
||||
|
||||
if (account is null || account.IdentityTokens?.IsValid != true)
|
||||
{
|
||||
await MessageBox.Show(this, MarketplacesUi.NotAuthenticatedToolTip, "Account Not Authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new MarketplacesDialog(account, persister.AccountsSettings, acc.AdditionalLocaleNames);
|
||||
|
||||
if (await dialog.ShowDialog<DialogResult>(this) == DialogResult.OK)
|
||||
{
|
||||
acc.SetAdditionalLocaleNames(dialog.SelectedAdditionalLocaleNames);
|
||||
_isDirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task SaveAndCloseAsync()
|
||||
{
|
||||
try
|
||||
@@ -226,6 +309,30 @@ public partial class AccountsDialog : DialogWindow
|
||||
public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await SaveAndCloseAsync();
|
||||
|
||||
protected override async void OnClosing(WindowClosingEventArgs e)
|
||||
{
|
||||
if (!_closeConfirmed && _isDirty && DialogResult != DialogResult.OK)
|
||||
{
|
||||
e.Cancel = true;
|
||||
|
||||
var result = await MessageBox.Show(
|
||||
this,
|
||||
"You have unsaved changes. Close without saving?",
|
||||
"Unsaved Changes",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning,
|
||||
MessageBoxDefaultButton.Button2);
|
||||
|
||||
if (result == DialogResult.Yes)
|
||||
{
|
||||
_closeConfirmed = true;
|
||||
Close(DialogResult.Cancel);
|
||||
}
|
||||
}
|
||||
|
||||
base.OnClosing(e);
|
||||
}
|
||||
|
||||
private void persist(AccountsSettings accountsSettings)
|
||||
{
|
||||
var existingAccounts = accountsSettings.Accounts;
|
||||
@@ -246,6 +353,7 @@ public partial class AccountsDialog : DialogWindow
|
||||
}
|
||||
|
||||
// upsert each. validation occurs through Account and AccountsSettings
|
||||
var upserted = new List<(AccountDto Dto, Account Account)>();
|
||||
foreach (var dto in Accounts.Where(a => a.AccountId is not null))
|
||||
{
|
||||
var acct = accountsSettings.Upsert(dto.AccountId!, dto.SelectedLocale?.Name);
|
||||
@@ -254,7 +362,15 @@ public partial class AccountsDialog : DialogWindow
|
||||
= string.IsNullOrWhiteSpace(dto.AccountName)
|
||||
? $"{dto.AccountId} - {dto.SelectedLocale?.Name}"
|
||||
: dto.AccountName.Trim();
|
||||
|
||||
// drop every marketplace before assigning any, so that moving one from one account to another in a
|
||||
// single sitting cannot trip the "no two accounts scan one marketplace" rule halfway through
|
||||
acct.SetAdditionalMarketplaces([]);
|
||||
upserted.Add((dto, acct));
|
||||
}
|
||||
|
||||
foreach (var (dto, acct) in upserted)
|
||||
acct.SetAdditionalMarketplaces(dto.AdditionalLocaleNames);
|
||||
}
|
||||
private async Task<bool> inputIsValid()
|
||||
{
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
FontSize="12"
|
||||
VerticalAlignment="Top"
|
||||
Margin="10,10,0,0"
|
||||
Text="To download again next time: change to Not Downloaded
To not download: change to Downloaded" />
|
||||
Text="To download again next time: change to Download Pending
To not download: change to Downloaded" />
|
||||
|
||||
<Grid Margin="0,10,0,5" ColumnDefinitions="Auto,Auto,50,Auto,Auto,*">
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ public partial class BookDetailsDialog : DialogWindow
|
||||
var status = libraryBook.Book.UserDefinedItem.BookStatus;
|
||||
|
||||
BookLiberatedItems.Add(new() { Status = LiberatedStatus.Liberated, Text = "Downloaded" });
|
||||
BookLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" });
|
||||
BookLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Download Pending" });
|
||||
|
||||
if (status == LiberatedStatus.Error)
|
||||
BookLiberatedItems.Add(new() { Status = LiberatedStatus.Error, Text = "Error" });
|
||||
@@ -162,7 +162,7 @@ public partial class BookDetailsDialog : DialogWindow
|
||||
if (status is not null)
|
||||
{
|
||||
PdfLiberatedItems.Add(new() { Status = LiberatedStatus.Liberated, Text = "Downloaded" });
|
||||
PdfLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" });
|
||||
PdfLiberatedItems.Add(new() { Status = LiberatedStatus.NotLiberated, Text = "Download Pending" });
|
||||
|
||||
PdfLiberatedSelectedItem = PdfLiberatedItems.SingleOrDefault(s => s.Status == status);
|
||||
}
|
||||
|
||||
@@ -136,8 +136,8 @@ public partial class FindBetterQualityBooksDialog : DialogWindow
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Failed to mark books as Not Liberated");
|
||||
await MessageBox.Show(this, "An error occurred while marking books as Not Liberated. Please see the logs for more information.", "Error Marking Books", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
Serilog.Log.Error(ex, "Failed to mark books as Download Pending");
|
||||
await MessageBox.Show(this, "An error occurred while marking books as Download Pending. Please see the logs for more information.", "Error Marking Books", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -50,7 +50,7 @@ public partial class ImageDisplayDialog : DialogWindow, INotifyPropertyChanged
|
||||
|
||||
try
|
||||
{
|
||||
_bitmapHolder.CoverImage?.Save(selectedFile);
|
||||
_bitmapHolder.CoverImage?.Save(selectedFile, JpegBitmapEncoderOptions.Default);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="If the audio file can be found, set download status to 'Downloaded'" />
|
||||
Text="If the audio file can be found, mark the book 'Downloaded'" />
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
Margin="0,0,0,10"
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="If the audio file cannot be found, set download status to 'Not Downloaded'" />
|
||||
Text="If the audio file cannot be found, mark the book 'Download Pending'" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<TextBlock
|
||||
Grid.ColumnSpan="2"
|
||||
Margin="10"
|
||||
Text="To download again next time: change to Not Downloaded
To not download: change to Downloaded"/>
|
||||
Text="To download again next time: change to Download Pending
To not download: change to Downloaded"/>
|
||||
|
||||
<StackPanel
|
||||
Margin="10,0"
|
||||
|
||||
@@ -30,7 +30,7 @@ public partial class LiberatedStatusBatchManualDialog : DialogWindow
|
||||
public List<liberatedComboBoxItem> BookStatuses { get; } =
|
||||
[
|
||||
new liberatedComboBoxItem { Status = LiberatedStatus.Liberated, Text = "Downloaded" },
|
||||
new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" },
|
||||
new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Download Pending" },
|
||||
];
|
||||
|
||||
public LiberatedStatusBatchManualDialog(bool isPdf) : this()
|
||||
|
||||
@@ -10,7 +10,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace LibationAvalonia.Dialogs;
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using AudibleApi;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs.Login;
|
||||
|
||||
public class AvaloniaLoginCallback : ILoginCallback
|
||||
{
|
||||
public string DeviceName { get; } = "Libation";
|
||||
|
||||
public Task<string> Get2faCodeAsync(string prompt) => throw new System.NotSupportedException();
|
||||
public Task<(string password, string guess)> GetCaptchaAnswerAsync(string password, byte[] captchaImage)
|
||||
=> throw new System.NotSupportedException();
|
||||
public Task<(string name, string value)> GetMfaChoiceAsync(MfaConfig mfaConfig)
|
||||
=> throw new System.NotSupportedException();
|
||||
public Task<(string email, string password)> GetLoginAsync()
|
||||
=> throw new System.NotSupportedException();
|
||||
public Task ShowApprovalNeededAsync() => throw new System.NotSupportedException();
|
||||
}
|
||||
@@ -14,19 +14,17 @@ namespace LibationAvalonia.Dialogs.Login;
|
||||
|
||||
public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
{
|
||||
public ILoginCallback LoginCallback { get; } = new AvaloniaLoginCallback();
|
||||
|
||||
private readonly Account _account;
|
||||
|
||||
public AvaloniaLoginChoiceEager(Account account)
|
||||
{
|
||||
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
_account = ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
}
|
||||
|
||||
public async Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
|
||||
public async Task<string?> StartAsync(ChoiceIn choiceIn)
|
||||
=> await Dispatcher.UIThread.InvokeAsync(() => StartAsyncInternal(choiceIn));
|
||||
|
||||
private async Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn)
|
||||
private async Task<string?> StartAsyncInternal(ChoiceIn choiceIn)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -35,7 +33,7 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await BrowserLoginAsync(choiceIn) is ChoiceOut external)
|
||||
if (await BrowserLoginAsync(choiceIn) is string external)
|
||||
return external;
|
||||
}
|
||||
catch (Exception ex) when (WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(ex))
|
||||
@@ -65,17 +63,17 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
|
||||
var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl);
|
||||
return await externalDialog.ShowDialogAsync() is DialogResult.OK
|
||||
? ChoiceOut.External(externalDialog.ResponseUrl)
|
||||
? externalDialog.ResponseUrl
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<ChoiceOut?> BrowserLoginAsync(ChoiceIn shoiceIn)
|
||||
private async Task<string?> BrowserLoginAsync(ChoiceIn shoiceIn)
|
||||
{
|
||||
// Time-of-use: setting can change before Show (e.g. settings saved) — never open NativeWebView when disabled.
|
||||
if (!Configuration.Instance.UseWebView)
|
||||
return null;
|
||||
|
||||
TaskCompletionSource<ChoiceOut?> tcs = new();
|
||||
TaskCompletionSource<string?> tcs = new();
|
||||
|
||||
NativeWebDialog dialog = new()
|
||||
{
|
||||
@@ -89,7 +87,7 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
{
|
||||
if (e.Request?.AbsolutePath.StartsWith("/ap/maplanding") is true)
|
||||
{
|
||||
tcs.TrySetResult(ChoiceOut.External(e.Request.ToString()));
|
||||
tcs.TrySetResult(e.Request.ToString());
|
||||
dialog.Close();
|
||||
}
|
||||
};
|
||||
@@ -182,20 +180,21 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
|
||||
void Dialog_EnvironmentRequested(object? sender, WebViewEnvironmentRequestedEventArgs e)
|
||||
{
|
||||
var userAgent = Configuration.Instance.GetDeviceRegistrationProfile().UserAgent;
|
||||
// Private browsing & user agent setting
|
||||
switch (e)
|
||||
{
|
||||
case WindowsWebView2EnvironmentRequestedEventArgs webView2Args:
|
||||
webView2Args.IsInPrivateModeEnabled = true;
|
||||
webView2Args.AdditionalBrowserArguments = "--user-agent=\"" + Resources.User_Agent + "\"";
|
||||
webView2Args.AdditionalBrowserArguments = "--user-agent=\"" + userAgent + "\"";
|
||||
break;
|
||||
case AppleWKWebViewEnvironmentRequestedEventArgs appleArgs:
|
||||
appleArgs.NonPersistentDataStore = true;
|
||||
appleArgs.ApplicationNameForUserAgent = Resources.User_Agent;
|
||||
appleArgs.ApplicationNameForUserAgent = userAgent;
|
||||
break;
|
||||
case GtkWebViewEnvironmentRequestedEventArgs gtkArgs:
|
||||
gtkArgs.EphemeralDataManager = true;
|
||||
gtkArgs.ApplicationNameForUserAgent = Resources.User_Agent;
|
||||
gtkArgs.ApplicationNameForUserAgent = userAgent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<Window
|
||||
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="560" d:DesignHeight="520"
|
||||
MinWidth="400" MinHeight="360"
|
||||
Width="560" Height="520"
|
||||
x:Class="LibationAvalonia.Dialogs.MarketplacesDialog"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:MarketplacesDialog"
|
||||
x:CompileBindings="True"
|
||||
Title="Marketplaces"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
RowDefinitions="Auto,Auto,*,Auto,Auto"
|
||||
Margin="10">
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding Intro}" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Margin="0,10,0,0"
|
||||
FontWeight="Bold"
|
||||
Text="{Binding AccountLabel}" />
|
||||
|
||||
<ListBox
|
||||
Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
Margin="0,10"
|
||||
Name="lbMarketplaces"
|
||||
ItemsSource="{Binding Marketplaces}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox
|
||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||
IsEnabled="{Binding CanCheck}"
|
||||
ToolTip.Tip="{Binding ToolTip}">
|
||||
<TextBlock Text="{Binding Text}" />
|
||||
</CheckBox>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="3"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="3"
|
||||
TextWrapping="Wrap"
|
||||
Margin="0,0,0,10"
|
||||
Name="StatusTextBlock" />
|
||||
|
||||
<Button
|
||||
Grid.Row="4"
|
||||
Grid.Column="0"
|
||||
Padding="20,6"
|
||||
Name="CheckButton"
|
||||
Content="{Binding CheckButtonText}"
|
||||
Click="CheckButton_Clicked" />
|
||||
|
||||
<Button
|
||||
Grid.Row="4"
|
||||
Grid.Column="2"
|
||||
Classes="SaveButton"
|
||||
HorizontalAlignment="Right"
|
||||
Content="Save"
|
||||
Name="SaveButton"
|
||||
Command="{Binding SaveAndClose}" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,148 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Collections;
|
||||
using LibationUiBase;
|
||||
using ReactiveUI;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs;
|
||||
|
||||
/// <summary>
|
||||
/// Which Audible marketplaces one account should read. Opened from the accounts grid, and only for an account
|
||||
/// that has already logged in - the check is made with that account's own credentials.
|
||||
/// </summary>
|
||||
public partial class MarketplacesDialog : DialogWindow
|
||||
{
|
||||
public string Intro => MarketplacesUi.Intro;
|
||||
public string CheckButtonText => MarketplacesUi.CheckButton;
|
||||
public string AccountLabel { get; } = "";
|
||||
|
||||
public AvaloniaList<ListItem> Marketplaces { get; } = new();
|
||||
|
||||
/// <summary>The additional marketplaces the user checked. The registered one is never among them.</summary>
|
||||
public IReadOnlyList<string> SelectedAdditionalLocaleNames
|
||||
=> Marketplaces
|
||||
.Where(m => m.IsChecked && m.CanCheck)
|
||||
.Select(m => m.Locale.Name)
|
||||
.ToList();
|
||||
|
||||
public class ListItem : ViewModels.ViewModelBase
|
||||
{
|
||||
public ListItem(Locale locale, string text, bool isChecked, bool canCheck, string? toolTip = null)
|
||||
{
|
||||
Locale = locale;
|
||||
Text = text;
|
||||
IsChecked = isChecked;
|
||||
CanCheck = canCheck;
|
||||
ToolTip = toolTip;
|
||||
}
|
||||
|
||||
public Locale Locale { get; }
|
||||
public string Text
|
||||
{
|
||||
get => field;
|
||||
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
}
|
||||
public bool IsChecked
|
||||
{
|
||||
get => field;
|
||||
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
}
|
||||
public bool CanCheck
|
||||
{
|
||||
get => field;
|
||||
set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
}
|
||||
public string? ToolTip { get; }
|
||||
public override string ToString() => Text;
|
||||
}
|
||||
|
||||
private readonly Account? account;
|
||||
private readonly AccountsSettings? accountsSettings;
|
||||
|
||||
// parameterless ctor for the axaml designer
|
||||
public MarketplacesDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
/// <param name="selectedAdditionalLocaleNames">
|
||||
/// What the accounts grid currently shows for this account, which may not yet be saved.
|
||||
/// </param>
|
||||
public MarketplacesDialog(Account account, AccountsSettings accountsSettings, IEnumerable<string> selectedAdditionalLocaleNames)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
this.account = account;
|
||||
this.accountsSettings = accountsSettings;
|
||||
|
||||
AccountLabel = AccountCredentialStatus.FormatAccountLabel(account);
|
||||
|
||||
var selected = selectedAdditionalLocaleNames.ToHashSet();
|
||||
|
||||
// list every candidate up front, so the dialog is a full picture before anything is asked of Audible
|
||||
foreach (var locale in MarketplaceProbe.CandidateLocales(account))
|
||||
{
|
||||
var isRegistered = locale.Name == account.Locale?.Name;
|
||||
|
||||
Marketplaces.Add(new ListItem(
|
||||
locale,
|
||||
isRegistered
|
||||
? $"{locale.Name} - this account's own marketplace"
|
||||
: locale.Name,
|
||||
isChecked: isRegistered || selected.Contains(locale.Name),
|
||||
canCheck: !isRegistered,
|
||||
toolTip: isRegistered ? "Always scanned. This is where the account is registered." : null));
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = MarketplacesUi.ButtonToolTip;
|
||||
ControlToFocusOnShow = CheckButton;
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
public async void CheckButton_Clicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await ProbeAsync();
|
||||
|
||||
public async Task ProbeAsync()
|
||||
{
|
||||
if (account is null || accountsSettings is null)
|
||||
return;
|
||||
|
||||
CheckButton.IsEnabled = false;
|
||||
StatusTextBlock.Text = MarketplacesUi.Checking;
|
||||
|
||||
var results = new List<MarketplaceProbeResult>();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var result in MarketplaceProbe.ProbeAsync(account, accountsSettings))
|
||||
{
|
||||
results.Add(result);
|
||||
|
||||
if (Marketplaces.FirstOrDefault(m => m.Locale.Name == result.Locale.Name) is not { } item)
|
||||
continue;
|
||||
|
||||
item.Text = MarketplacesUi.ResultText(result);
|
||||
|
||||
// a marketplace another account already scans must not be checkable here: two rows scanning one
|
||||
// marketplace would import it twice
|
||||
if (result.Outcome is MarketplaceProbeOutcome.ScannedByAnotherAccount or MarketplaceProbeOutcome.Failed)
|
||||
item.CanCheck = false;
|
||||
|
||||
if (result.Outcome is MarketplaceProbeOutcome.TitlesFound)
|
||||
item.IsChecked = true;
|
||||
}
|
||||
|
||||
StatusTextBlock.Text = MarketplacesUi.Summary(results);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CheckButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
public new void SaveAndClose() => base.SaveAndClose();
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
|
||||
MinWidth="600" MinHeight="450"
|
||||
MaxWidth="600" MaxHeight="450"
|
||||
Width="600" Height="450"
|
||||
MaxWidth="600" MaxHeight="620"
|
||||
Width="600" Height="560"
|
||||
x:Class="LibationAvalonia.Dialogs.MessageBoxAlertAdminDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
@@ -20,12 +20,22 @@
|
||||
Margin="10,10,10,0"
|
||||
ColumnDefinitions="Auto,*">
|
||||
|
||||
<Image Grid.Column="0" Width="64" Height="64" Source="/Assets/MBIcons/Error_64.png" />
|
||||
<TextBlock
|
||||
<Image Grid.Column="0" Width="64" Height="64" VerticalAlignment="Top" Source="/Assets/MBIcons/Error_64.png" />
|
||||
|
||||
<!--
|
||||
Scrolled and capped: a startup failure that names the file, its version and the recovery steps
|
||||
runs far longer than the two lines this dialog was built for, and without a bound on the
|
||||
description the rows below it were pushed out of the window. See issue #2001.
|
||||
-->
|
||||
<ScrollViewer
|
||||
Grid.Column="1"
|
||||
Margin="10"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding ErrorDescription}" />
|
||||
MaxHeight="260"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
Margin="10"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding ErrorDescription}" />
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<TextBox
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Collections;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -17,7 +18,8 @@ public partial class ScanAccountsDialog : DialogWindow
|
||||
{
|
||||
Account = account;
|
||||
IsChecked = account.LibraryScan;
|
||||
Text = $"{account.AccountName} ({account.AccountId} - {account.Locale?.Name})";
|
||||
// lists every marketplace: one checkbox here can scan more than one
|
||||
Text = MarketplacesUi.ScanPickerText(account);
|
||||
}
|
||||
public Account Account { get; }
|
||||
public string Text { get; }
|
||||
|
||||
@@ -14,6 +14,7 @@ public partial class UpgradeNotificationDialog : DialogWindow
|
||||
public string? ReleaseNotes { get; }
|
||||
public string? OkText { get; }
|
||||
private string? PackageUrl { get; }
|
||||
private bool CanUpgrade { get; } = true;
|
||||
public UpgradeNotificationDialog()
|
||||
{
|
||||
if (Design.IsDesignMode)
|
||||
@@ -30,18 +31,23 @@ public partial class UpgradeNotificationDialog : DialogWindow
|
||||
ControlToFocusOnShow = btnYes;
|
||||
}
|
||||
|
||||
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade) : this()
|
||||
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade, string? upgradeUnavailableReason = null) : this()
|
||||
{
|
||||
Title = $"Libation version {upgradeProperties.LatestRelease.ToVersionString()} is now available.";
|
||||
CanUpgrade = canUpgrade;
|
||||
PackageUrl = upgradeProperties.ZipUrl;
|
||||
DownloadLinkText = upgradeProperties.ZipName;
|
||||
ReleaseNotes = upgradeProperties.Notes;
|
||||
TopMessage = canUpgrade ? UpdateMessage : "";
|
||||
TopMessage = canUpgrade ? UpdateMessage : upgradeUnavailableReason ?? "";
|
||||
OkText = canUpgrade ? "Yes" : "OK";
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
public void OK_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.OK);
|
||||
// When Libation cannot install the upgrade itself, this button reads "OK" and the dialog is a
|
||||
// notice with a download link. Reporting OK there would be read as "yes, install it", starting a
|
||||
// download and an install that was never on offer, so acknowledging a notice closes and no more.
|
||||
public void OK_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> Close(CanUpgrade ? DialogResult.OK : DialogResult.Cancel);
|
||||
public void DontRemind_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.Ignore);
|
||||
public void Download_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
=> Go.To.Url(PackageUrl);
|
||||
|
||||
@@ -70,7 +70,9 @@ public static class FormSaveExtension
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to save {form} size and location", form.GetType().Name);
|
||||
// The crash dialog restores its own position, so this runs while the install may be broken.
|
||||
// Logging through Serilog here took the dialog down with it. See issue #2001.
|
||||
StartupLog.Error(ex, $"Failed to restore {form.GetType().Name} size and location");
|
||||
}
|
||||
}
|
||||
public static void SaveSizeAndLocation(this Window form, Configuration config)
|
||||
@@ -99,7 +101,7 @@ public static class FormSaveExtension
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to save {form} size and location", form.GetType().Name);
|
||||
StartupLog.Error(ex, $"Failed to save {form.GetType().Name} size and location");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,19 +72,23 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.14" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.1.2" />
|
||||
<PackageReference Include="Avalonia.Controls.WebView" Version="12.1.0" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.2" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.2" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LibationUiBase\LibationUiBase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<!-- Remove obj folder -->
|
||||
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||
|
||||
@@ -3,14 +3,12 @@ using AppScaffolding;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using FileManager;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI.Avalonia;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -56,21 +54,34 @@ static class Program
|
||||
try
|
||||
{
|
||||
var config = LibationScaffolding.RunPreConfigMigrations();
|
||||
StartupAssemblyBootstrap.RecoverFromIncompleteUpgradeIfNeeded();
|
||||
|
||||
// Prevent a second instance from racing on the same database, search index, and log file.
|
||||
// Hold the lock for the whole process; skip all database access when we are not the first
|
||||
// instance so the running copy's state is never touched. See issue #1931.
|
||||
SingleInstanceLock = SingleInstance.TryAcquire(config.LibationFiles.Location);
|
||||
App.IsAnotherInstanceRunning = !SingleInstanceLock.IsFirstInstance;
|
||||
// A rollback swaps install files out from under assemblies this process has already loaded, so
|
||||
// it must not go on to touch the database or open a window. App reports it and shuts down
|
||||
// instead. See issue #2001.
|
||||
App.StartupRecoveryNotice = StartupAssemblyBootstrap.RecoverFromIncompleteUpgradeIfNeeded();
|
||||
|
||||
if (SingleInstanceLock.IsFirstInstance && config.LibationFiles.SettingsAreValid)
|
||||
if (App.StartupRecoveryNotice is null)
|
||||
{
|
||||
App.RunMigrations(config);
|
||||
StartupAssemblyBootstrap.PrepareForBackgroundDataAccess();
|
||||
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
// Prevent a second instance from racing on the same database, search index, and log file.
|
||||
// Hold the lock for the whole process; skip all database access when we are not the first
|
||||
// instance so the running copy's state is never touched. See issue #1931.
|
||||
SingleInstanceLock = SingleInstance.TryAcquire(config.LibationFiles.Location);
|
||||
App.IsAnotherInstanceRunning = !SingleInstanceLock.IsFirstInstance;
|
||||
|
||||
if (SingleInstanceLock.IsFirstInstance && config.LibationFiles.SettingsAreValid)
|
||||
{
|
||||
App.RunMigrations(config);
|
||||
StartupAssemblyBootstrap.PrepareForBackgroundDataAccess();
|
||||
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
}
|
||||
}
|
||||
|
||||
BuildAvaloniaApp()?.StartWithClassicDesktopLifetime([], ShutdownMode.OnExplicitShutdown);
|
||||
|
||||
// After the lifetime ends, so the new process starts into a folder this one has finished with
|
||||
// and does not race it for the single-instance lock.
|
||||
if (App.RestartRequested)
|
||||
InstallRelauncher.TryRelaunch();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -112,13 +123,14 @@ static class Program
|
||||
|
||||
private static void LogAndShowCrashMessage(Exception exception)
|
||||
{
|
||||
string? crashLogFile = null;
|
||||
try
|
||||
{
|
||||
//Try to log the error message before displaying the crash dialog
|
||||
if (Configuration.Instance.SerilogInitialized)
|
||||
Serilog.Log.Logger.Error(exception, "CRASH");
|
||||
else
|
||||
LogErrorWithoutSerilog(exception);
|
||||
crashLogFile = PreLoggingCrashLog.TryWrite(exception, [("ReleaseIdentifier", LibationScaffolding.ReleaseIdentifier.ToString())]);
|
||||
}
|
||||
catch { /* continue to show the crash dialog even if logging fails */ }
|
||||
|
||||
@@ -127,7 +139,7 @@ static class Program
|
||||
|
||||
try
|
||||
{
|
||||
Dispatcher.UIThread.Invoke(() => DisplayErrorMessage(exception));
|
||||
Dispatcher.UIThread.Invoke(() => DisplayErrorMessage(exception, crashLogFile));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -135,7 +147,7 @@ static class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisplayErrorMessage(Exception exception)
|
||||
private static void DisplayErrorMessage(Exception exception, string? crashLogFile)
|
||||
{
|
||||
var dispatcher = new DispatcherFrame();
|
||||
|
||||
@@ -143,10 +155,10 @@ static class Program
|
||||
exception,
|
||||
new FatalStartupMessage(
|
||||
"Libation Crash",
|
||||
"""
|
||||
$"""
|
||||
Libation encountered a fatal error and must close.
|
||||
|
||||
Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder.
|
||||
{DescribeCrashLog(crashLogFile)}
|
||||
"""));
|
||||
|
||||
var mbAlert = new MessageBoxAlertAdminDialog(fatalMessage.Body, fatalMessage.Title, exception);
|
||||
@@ -155,68 +167,17 @@ static class Program
|
||||
Dispatcher.UIThread.PushFrame(dispatcher);
|
||||
}
|
||||
|
||||
private static void LogErrorWithoutSerilog(object exceptionObject)
|
||||
{
|
||||
var logError = $"""
|
||||
{DateTime.Now} - Libation Crash
|
||||
OS {Configuration.OS}
|
||||
Version {LibationScaffolding.BuildVersion}
|
||||
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
|
||||
InteropFunctionsType {InteropFactory.InteropFunctionsType}
|
||||
LibationFiles {getConfigValue(c => c.LibationFiles.Location)}
|
||||
Books Folder {getConfigValue(c => c.Books)}
|
||||
=== EXCEPTION ===
|
||||
{exceptionObject}
|
||||
""";
|
||||
/// <summary>
|
||||
/// Names the file the crash was actually written to. This used to name LibationCrash.log
|
||||
/// unconditionally, which is not where the record goes when a Log*.log already exists, so reporters
|
||||
/// went looking for a file that was not there and attached nothing. See issue #2001.
|
||||
/// </summary>
|
||||
private static string DescribeCrashLog(string? crashLogFile)
|
||||
=> crashLogFile is null
|
||||
? "Please consider reporting this issue on GitHub. Libation could not write this error to a log file, so please include the text below."
|
||||
: $"""
|
||||
Please consider reporting this issue on GitHub, including the contents of this file:
|
||||
{crashLogFile}
|
||||
""";
|
||||
|
||||
LongPath logFile;
|
||||
try
|
||||
{
|
||||
//Try to add crash message to the newest existing Libation log file
|
||||
//then to LibationFiles/LibationCrash.log
|
||||
//then to %UserProfile%/LibationCrash.log
|
||||
string logDir = Configuration.Instance.LibationFiles.Location;
|
||||
var existingLogFiles = Directory.GetFiles(logDir, "Log*.log");
|
||||
|
||||
logFile = existingLogFiles.Length == 0 ? getFallbackLogFile()
|
||||
: existingLogFiles.Select(f => new FileInfo(f)).OrderByDescending(f => f.CreationTimeUtc).First().FullName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
logFile = getFallbackLogFile();
|
||||
}
|
||||
|
||||
|
||||
using var sw = new StreamWriter(logFile, true);
|
||||
sw.WriteLine(logError);
|
||||
|
||||
static string getConfigValue(Func<Configuration, string?> selector)
|
||||
{
|
||||
try
|
||||
{
|
||||
return selector(Configuration.Instance) ?? "[null]";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
static string getFallbackLogFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
string logDir = Configuration.Instance.LibationFiles.Location;
|
||||
if (!Directory.Exists(logDir))
|
||||
logDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
return Path.Combine(logDir, "LibationCrash.log");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LibationCrash.log");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using ReactiveUI;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
@@ -17,6 +18,43 @@ partial class MainVM
|
||||
/// <summary> The "Begin PDF Only Backup" menu item header text </summary>
|
||||
public string PdfBackupsToolStripText { get; private set; } = "Begin PDF Only Backups: 0";
|
||||
|
||||
/// <summary> How many books are in the trash, where nothing else on screen would reveal them </summary>
|
||||
public int BooksInTrash
|
||||
{
|
||||
get => field;
|
||||
private set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref field, value);
|
||||
this.RaisePropertyChanged(nameof(TrashBinMenuText));
|
||||
this.RaisePropertyChanged(nameof(TrashBinStatusText));
|
||||
this.RaisePropertyChanged(nameof(TrashBinStatusVisible));
|
||||
RaiseGettingStartedChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public string TrashBinMenuText => TrashBinUi.MenuText(BooksInTrash);
|
||||
public string TrashBinStatusText => TrashBinUi.StatusText(BooksInTrash);
|
||||
public bool TrashBinStatusVisible => TrashBinUi.ShowStatus(BooksInTrash);
|
||||
public string TrashBinStatusToolTip => TrashBinUi.StatusToolTip;
|
||||
|
||||
/// <summary>
|
||||
/// Re-read the trash count. Kept off <see cref="LibraryCommands.GetCounts"/> on purpose: that also runs
|
||||
/// against the visible subset on every filter change, where a database round trip would be wasted work.
|
||||
/// </summary>
|
||||
public async Task RefreshBooksInTrashAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await Task.Run(DbContexts.GetTrashedBookCount);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => BooksInTrash = count);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
//A stale count must not take down the window that displays it.
|
||||
Serilog.Log.Logger.Error(ex, "Failed to count books in the trash");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary> The user's library statistics </summary>
|
||||
public LibraryCommands.LibraryStats? LibraryStats
|
||||
{
|
||||
@@ -37,6 +75,9 @@ partial class MainVM
|
||||
|
||||
this.RaisePropertyChanged(nameof(BookBackupsToolStripText));
|
||||
this.RaisePropertyChanged(nameof(PdfBackupsToolStripText));
|
||||
|
||||
//Whether the library is empty is only known once these counts have been taken.
|
||||
RaiseGettingStartedChanged();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
@@ -19,7 +20,17 @@ partial class MainVM
|
||||
private QuickFilters.NamedFilter? lastGoodFilter => new(lastGoodSearch, null);
|
||||
|
||||
/// <summary> Library filterting query </summary>
|
||||
public QuickFilters.NamedFilter? SelectedNamedFilter { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = new(string.Empty, null);
|
||||
public QuickFilters.NamedFilter? SelectedNamedFilter
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref field, value);
|
||||
//Which explanation the empty grid carries depends on whether a filter is applied.
|
||||
this.RaisePropertyChanged(nameof(HasActiveFilter));
|
||||
RaiseGettingStartedChanged();
|
||||
}
|
||||
} = new(string.Empty, null);
|
||||
public AvaloniaList<Control> QuickFilterMenuItems { get; } = new();
|
||||
/// <summary> Indicates if the first quick filter is the default filter </summary>
|
||||
public bool FirstFilterIsDefault { get => field; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref field, value); }
|
||||
@@ -88,6 +99,7 @@ partial class MainVM
|
||||
{
|
||||
await ProductsDisplay.Filter(namedFilter?.Filter);
|
||||
lastGoodSearch = namedFilter?.Filter ?? "";
|
||||
await RefreshNoMatchesStateAsync(namedFilter?.Filter);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -96,6 +108,88 @@ partial class MainVM
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A trashed book is filtered out of the library and out of the search index, so searching for one looks
|
||||
/// exactly like searching for a book that was never imported. When a filter matches nothing, say whether
|
||||
/// the thing being looked for is sitting in the trash.
|
||||
/// </summary>
|
||||
private async Task RefreshNoMatchesStateAsync(string? searchString)
|
||||
{
|
||||
NoMatchesText = GridEmptyStateUi.NoMatchesText(searchString);
|
||||
|
||||
//Search the trash whenever a filter emptied the grid, including when the library itself is empty:
|
||||
//someone who trashed everything and then searched for one of those books is exactly who needs telling.
|
||||
if (_visibleCount > 0 || string.IsNullOrWhiteSpace(searchString))
|
||||
{
|
||||
NoMatchesTrashHintVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var matches = await Task.Run(() => TrashBinSearch.Search(searchString));
|
||||
|
||||
NoMatchesTrashHintText = GridEmptyStateUi.NoMatchesTrashHintText(matches.Count);
|
||||
NoMatchesTrashHintVisible = matches.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary> Shown over the grid when a filter matches nothing </summary>
|
||||
public string NoMatchesText { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); } = "";
|
||||
/// <summary> Follow-up naming matches that are in the trash </summary>
|
||||
public string NoMatchesTrashHintText { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); } = "";
|
||||
public bool NoMatchesTrashHintVisible { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
|
||||
/// <summary> The grid is empty because of a filter, not because the library is empty </summary>
|
||||
public bool NoMatchesVisible { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
|
||||
|
||||
#region Getting started
|
||||
|
||||
/// <summary>A filter is applied, so an empty grid is the filter's doing rather than the library's.</summary>
|
||||
public bool HasActiveFilter => !string.IsNullOrWhiteSpace(SelectedNamedFilter?.Filter);
|
||||
|
||||
/// <summary>
|
||||
/// The grid is empty because there is nothing in the library at all. Held back until
|
||||
/// <see cref="LibraryStats"/> has been counted at least once, which is what keeps "Add your Audible
|
||||
/// account" off the screen during the moment between the window appearing and a full library loading.
|
||||
/// Also held back mid-scan, where "no books yet" would be answering a question already being answered,
|
||||
/// and while a filter is applied, because someone searching is not someone getting started.
|
||||
/// </summary>
|
||||
public bool GettingStartedVisible
|
||||
=> !ActivelyScanning
|
||||
&& !HasActiveFilter
|
||||
&& LibraryStats is { HasBookResults: false };
|
||||
|
||||
public string GettingStartedHeadline => GridEmptyStateUi.EmptyLibraryHeadline(AnyAccounts);
|
||||
public string GettingStartedDetail => GridEmptyStateUi.EmptyLibraryDetail(AnyAccounts);
|
||||
|
||||
/// <summary>Adding an account comes first; telling someone without one to scan is a dead end.</summary>
|
||||
public bool GettingStartedAddAccountVisible => !AnyAccounts;
|
||||
public bool GettingStartedScanVisible => AnyAccounts;
|
||||
|
||||
/// <summary>
|
||||
/// An empty library whose books are all in the trash. "Libation is empty" would be wrong on its own:
|
||||
/// the books are not gone, they are one click away.
|
||||
/// </summary>
|
||||
public bool GettingStartedTrashVisible => BooksInTrash > 0;
|
||||
public string GettingStartedTrashText => GridEmptyStateUi.EmptyLibraryTrashHintText(BooksInTrash);
|
||||
|
||||
internal void RaiseGettingStartedChanged()
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(GettingStartedVisible));
|
||||
this.RaisePropertyChanged(nameof(GettingStartedHeadline));
|
||||
this.RaisePropertyChanged(nameof(GettingStartedDetail));
|
||||
this.RaisePropertyChanged(nameof(GettingStartedAddAccountVisible));
|
||||
this.RaisePropertyChanged(nameof(GettingStartedScanVisible));
|
||||
this.RaisePropertyChanged(nameof(GettingStartedTrashVisible));
|
||||
this.RaisePropertyChanged(nameof(GettingStartedTrashText));
|
||||
updateNoMatchesVisible();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scan whatever accounts exist. The button only shows when there is at least one, and an empty library
|
||||
/// is a case where every account is worth scanning, so this does not need the menu's one/many split.
|
||||
/// </summary>
|
||||
public async Task GettingStartedScanAsync() => await ScanAllAccountsAsync();
|
||||
|
||||
#endregion
|
||||
|
||||
private void updateFiltersMenu(object? _ = null, object? __ = null)
|
||||
{
|
||||
if (NativeMenu.GetMenu(MainWindow)?.Items[3] is not NativeMenuItem ss ||
|
||||
|
||||
@@ -56,6 +56,7 @@ public partial class MainVM
|
||||
this.RaisePropertyChanged(nameof(AnyAccounts));
|
||||
this.RaisePropertyChanged(nameof(OneAccount));
|
||||
this.RaisePropertyChanged(nameof(MultipleAccounts));
|
||||
RaiseGettingStartedChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +192,7 @@ public partial class MainVM
|
||||
this.RaisePropertyChanged(nameof(ActivelyScanning));
|
||||
this.RaisePropertyChanged(nameof(RemoveMenuItemsEnabled));
|
||||
this.RaisePropertyChanged(nameof(ScanningText));
|
||||
RaiseGettingStartedChanged();
|
||||
}
|
||||
|
||||
private async Task scanLibrariesRemovedBooks(params Account[] accounts)
|
||||
|
||||
@@ -3,8 +3,8 @@ using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Reactive;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace LibationAvalonia.ViewModels;
|
||||
|
||||
@@ -12,7 +12,7 @@ partial class MainVM
|
||||
{
|
||||
public string FindBetterQualityBooksTip => Configuration.GetHelpText("FindBetterQualityBooks");
|
||||
public bool MenuBarVisible { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = !Configuration.IsMacOs;
|
||||
public ReactiveCommand<Unit, Unit> LaunchHangover { get; private set; } = null!;
|
||||
public ICommand LaunchHangover { get; private set; } = null!;
|
||||
|
||||
private void Configure_Settings()
|
||||
{
|
||||
@@ -20,13 +20,18 @@ partial class MainVM
|
||||
|
||||
if (App.Current is Avalonia.Application app &&
|
||||
NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu)
|
||||
aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync);
|
||||
aboutMenu.Command = ReactiveCommand.CreateFromTask(ShowAboutAsync);
|
||||
}
|
||||
|
||||
public Task ShowAboutAsync() => new LibationAvalonia.Dialogs.AboutDialog().ShowDialog(MainWindow);
|
||||
public Task ShowAccountsAsync() => new LibationAvalonia.Dialogs.AccountsDialog().ShowDialog(MainWindow);
|
||||
public Task ShowSettingsAsync() => new LibationAvalonia.Dialogs.SettingsDialog().ShowDialog(MainWindow);
|
||||
public Task ShowTrashBinAsync() => new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
|
||||
public async Task ShowTrashBinAsync()
|
||||
{
|
||||
await new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
|
||||
//Restoring or permanently deleting inside the dialog changes the count behind it.
|
||||
await RefreshBooksInTrashAsync();
|
||||
}
|
||||
public Task ShowFindBetterQualityBooksAsync() => new LibationAvalonia.Dialogs.FindBetterQualityBooksDialog().ShowDialog(MainWindow);
|
||||
|
||||
private async Task LaunchHangoverAsync()
|
||||
|
||||
@@ -37,8 +37,16 @@ partial class MainVM
|
||||
_visibleCount = visibleCount;
|
||||
this.RaisePropertyChanged(nameof(VisibleCountText));
|
||||
this.RaisePropertyChanged(nameof(VisibleCountMenuItemText));
|
||||
updateNoMatchesVisible();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An empty grid only needs explaining when a filter emptied it. Mutually exclusive with the
|
||||
/// getting-started panel, which stands down while a filter is applied.
|
||||
/// </summary>
|
||||
private void updateNoMatchesVisible()
|
||||
=> NoMatchesVisible = _visibleCount == 0 && HasActiveFilter;
|
||||
|
||||
private void setVisibleNotLiberatedCount(int visibleNotLiberated)
|
||||
{
|
||||
_visibleNotLiberated = visibleNotLiberated;
|
||||
@@ -191,8 +199,19 @@ partial class MainVM
|
||||
|
||||
private async Task setLiberatedVisibleMenuItemAsync()
|
||||
{
|
||||
var visible = ProductsDisplay.GetVisibleBookEntries();
|
||||
var libraryStats = await Task.Run(() => LibraryCommands.GetCounts(visible));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => setVisibleNotLiberatedCount(libraryStats.PendingBooks));
|
||||
try
|
||||
{
|
||||
var visible = ProductsDisplay.GetVisibleBookEntries();
|
||||
var libraryStats = await Task.Run(() => LibraryCommands.GetCounts(visible));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => setVisibleNotLiberatedCount(libraryStats.PendingBooks));
|
||||
}
|
||||
// Every caller is an async void event handler, where a failure is not a faulted task anyone awaits but
|
||||
// an unhandled exception that closes Libation. Counting these books reads the file system, which can
|
||||
// fail at any moment for reasons that have nothing to do with the app - a Books folder on a drive that
|
||||
// was just unplugged is enough. A stale number above a menu item is not worth the session.
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error counting the visible books which are not yet liberated");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ public partial class MainVM : ViewModelBase
|
||||
|
||||
await Task.WhenAll(
|
||||
SetBackupCountsAsync(fullLibrary),
|
||||
RefreshBooksInTrashAsync(),
|
||||
Task.Run(() => ProductsDisplay.UpdateGridAsync(fullLibrary)));
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
@@ -35,6 +36,8 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0];
|
||||
LastWriteTime = DateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? DateTimeSources[0];
|
||||
UseWebView = config.UseWebView;
|
||||
SelectedDeviceRegistration = DeviceRegistrationSettingsUi.Display(config.DeviceRegistrationKind);
|
||||
CheckForUpgradesAtStartup = config.CheckForUpgradesAtStartup;
|
||||
LoggingLevel = config.LogLevel;
|
||||
GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor);
|
||||
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
|
||||
@@ -77,6 +80,8 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
config.CreationTime = CreationTime.Value;
|
||||
config.LastWriteTime = LastWriteTime.Value;
|
||||
config.UseWebView = UseWebView;
|
||||
config.DeviceRegistrationKind = SelectedDeviceRegistration.Value;
|
||||
config.CheckForUpgradesAtStartup = CheckForUpgradesAtStartup;
|
||||
config.LogLevel = LoggingLevel;
|
||||
config.TokenStorageMethod = SelectedTokenStorageMethod;
|
||||
initialTokenStorageMethod = SelectedTokenStorageMethod;
|
||||
@@ -142,13 +147,18 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
.ToArray();
|
||||
|
||||
public string UseWebViewText { get; } = Configuration.GetDescription(nameof(Configuration.UseWebView));
|
||||
public string DeviceRegistrationKindText { get; } = DeviceRegistrationSettingsUi.SettingLabel;
|
||||
public string DeviceRegistrationKindTip { get; } = Configuration.GetHelpText(nameof(Configuration.DeviceRegistrationKind));
|
||||
public string DeviceRegistrationReLoginNote { get; } = DeviceRegistrationSettingsUi.ReLoginNote;
|
||||
public EnumDisplay<DeviceRegistrationKind>[] DeviceRegistrationOptions { get; } = DeviceRegistrationSettingsUi.Options;
|
||||
/// <summary>When true, the Use WebView setting is disabled (e.g. when running in Linux Snap to avoid portal/sandbox crashes).</summary>
|
||||
public bool UseWebViewSettingDisabled => Configuration.IsRunningUnderSnap;
|
||||
public string UseWebViewSnapMessage { get; } = Configuration.IsRunningUnderSnap ? "Disabled when running in Linux Snap (avoids login crash). Use external browser instead." : "";
|
||||
public string CheckForUpgradesAtStartupText { get; } = Configuration.GetDescription(nameof(Configuration.CheckForUpgradesAtStartup));
|
||||
public string CheckForUpgradesAtStartupTip { get; } = Configuration.GetHelpText(nameof(Configuration.CheckForUpgradesAtStartup));
|
||||
public Serilog.Events.LogEventLevel[] LoggingLevels { get; } = Enum.GetValues<Serilog.Events.LogEventLevel>();
|
||||
public string GridScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridScaleFactor));
|
||||
public string GridFontScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridFontScaleFactor));
|
||||
public string BetaOptInText { get; } = Configuration.GetDescription(nameof(Configuration.BetaOptIn));
|
||||
public EnumDisplay<Configuration.Theme>[] Themes { get; }
|
||||
= Enum.GetValues<Configuration.Theme>()
|
||||
.Select(v => new EnumDisplay<Configuration.Theme>(v))
|
||||
@@ -171,6 +181,8 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
public EnumDisplay<Configuration.DateTimeSource> CreationTime { get; set; }
|
||||
public EnumDisplay<Configuration.DateTimeSource> LastWriteTime { get; set; }
|
||||
public bool UseWebView { get; set; }
|
||||
public EnumDisplay<DeviceRegistrationKind> SelectedDeviceRegistration { get; set; }
|
||||
public bool CheckForUpgradesAtStartup { get; set; }
|
||||
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }
|
||||
|
||||
public bool EncryptTokens
|
||||
|
||||
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user