mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-10 04:37:08 -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 |
No files matched your search
@@ -124,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" },
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.11</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,
|
||||
@@ -332,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
@@ -82,25 +82,12 @@ public static class LibraryCommands
|
||||
|
||||
return inactive;
|
||||
}
|
||||
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 scanning 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 scanning library");
|
||||
throw;
|
||||
@@ -164,7 +151,10 @@ 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
|
||||
};
|
||||
//Importing only adds and updates, so a partially scanned library is still worth importing.
|
||||
@@ -185,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;
|
||||
@@ -328,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))
|
||||
{
|
||||
@@ -352,23 +340,29 @@ public static class LibraryCommands
|
||||
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);
|
||||
|
||||
@@ -385,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") },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using AudibleApi.Common;
|
||||
using ClosedXML.Excel;
|
||||
using CsvHelper;
|
||||
using DataLayer;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.4.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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,9 +11,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.5.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.5.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<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>
|
||||
@@ -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; }
|
||||
|
||||
@@ -285,13 +295,14 @@ public class Book
|
||||
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}";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -124,7 +124,7 @@ public class BookImporter : ItemsImporterBase
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.Title,
|
||||
item.Subtitle,
|
||||
item.Description,
|
||||
item.PlainTextDescription(),
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
authors,
|
||||
@@ -162,6 +162,21 @@ public class BookImporter : ItemsImporterBase
|
||||
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>
|
||||
@@ -206,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;
|
||||
@@ -220,7 +237,9 @@ 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);
|
||||
|
||||
|
||||
@@ -18,4 +18,8 @@
|
||||
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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;
|
||||
@@ -247,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)
|
||||
{
|
||||
|
||||
@@ -145,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();
|
||||
|
||||
@@ -112,7 +112,7 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDown
|
||||
/// 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, Set PDF Not Downloaded - set it back.
|
||||
/// 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
|
||||
@@ -122,7 +122,7 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDown
|
||||
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; set the PDF status back to Not Downloaded to try once more.";
|
||||
+ "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}",
|
||||
@@ -218,7 +218,7 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDown
|
||||
= 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)
|
||||
|
||||
@@ -26,4 +26,8 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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.
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.5.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'">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -33,6 +34,7 @@ public partial class UpgradeNotificationDialog : DialogWindow
|
||||
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;
|
||||
@@ -41,7 +43,11 @@ public partial class UpgradeNotificationDialog : DialogWindow
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +20,7 @@ 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -278,7 +278,7 @@ public partial class MainWindow : ReactiveWindow<MainVM>
|
||||
upgrader.UpgradeFailed += async (_, message) => await Dispatcher.UIThread.InvokeAsync(() => { setProgressVisible(false); MessageBox.Show(this, message, "Upgrade Failed", MessageBoxButtons.OK, MessageBoxIcon.Error); });
|
||||
|
||||
#if !DEBUG
|
||||
Opened += async (_, _) => await upgrader.CheckForUpgradeAsync(upgradeAvailable);
|
||||
Opened += async (_, _) => await upgrader.CheckForUpgradeAtStartupAsync(upgradeAvailable);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
SortMemberPath="ProductRating" CanUserSort="True"
|
||||
OpacityBinding="{Binding Liberate.Opacity}"
|
||||
ClipboardContentBinding="{Binding ProductRating}"
|
||||
Binding="{Binding ProductRating}">
|
||||
RatingBinding="{Binding ProductRating}">
|
||||
<controls:DataGridMyRatingColumn.Width>
|
||||
<Binding x:DataType="vm:ProductsDisplayViewModel" Path="ProductRatingWidth" Mode="TwoWay" />
|
||||
</controls:DataGridMyRatingColumn.Width>
|
||||
@@ -269,7 +269,7 @@
|
||||
SortMemberPath="MyRating" CanUserSort="True"
|
||||
OpacityBinding="{Binding Liberate.Opacity}"
|
||||
ClipboardContentBinding="{Binding MyRating}"
|
||||
Binding="{Binding MyRating, Mode=TwoWay}">
|
||||
RatingBinding="{Binding MyRating, Mode=TwoWay}">
|
||||
<controls:DataGridMyRatingColumn.Width>
|
||||
<Binding x:DataType="vm:ProductsDisplayViewModel" Path="MyRatingWidth" Mode="TwoWay" />
|
||||
</controls:DataGridMyRatingColumn.Width>
|
||||
|
||||
@@ -13,7 +13,11 @@ internal static class ContentLicenseDeniedCliSummary
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
|
||||
yield return "Audible denied a content license (download not allowed for this account/title).";
|
||||
yield return ex.IsCustomerThrottled
|
||||
? "Audible denied a content license because this account is being throttled. Wait 24 to 48 hours before trying again. This is not a Libation bug."
|
||||
: "Audible denied a content license (download not allowed for this account/title).";
|
||||
if (ex.IsCustomerThrottled)
|
||||
yield return "If the official Audible app can play this title, try an experimental device registration (--device-registration with login-external after removing the account) or import credentials from audible-cli.";
|
||||
yield return ex.Message;
|
||||
|
||||
if (ex.Ownership?.Message is { } own && !string.IsNullOrWhiteSpace(own))
|
||||
@@ -30,8 +34,8 @@ internal static class ContentLicenseDeniedCliSummary
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audible never says "you are being throttled", so this suggestion relies on Libation's own record of recent
|
||||
/// downloads. Silent unless that record makes throttling a plausible explanation and no limit is set yet.
|
||||
/// Extra pacing hint from Libation's own download record when Audible did not name CustomerThrottled.
|
||||
/// Silent unless that record makes throttling a plausible explanation and no limit is set yet.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> SuggestDailyLimitLines()
|
||||
{
|
||||
|
||||
@@ -56,4 +56,8 @@
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -59,10 +59,8 @@ internal class ImportAccountOptions : OptionsBase
|
||||
Console.Error.WriteLine(result.Message ?? "Invalid import file.");
|
||||
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
||||
return;
|
||||
case Mkb79ImportOutcome.DuplicateAccount when result.Account is { } dup:
|
||||
Console.Error.WriteLine(
|
||||
$"An account with that account id and country already exists.{Environment.NewLine}"
|
||||
+ $"Account ID: {dup.AccountId}{Environment.NewLine}Country: {dup.Locale?.Name}");
|
||||
case Mkb79ImportOutcome.DuplicateAccount when result.Account is not null:
|
||||
Console.Error.WriteLine(Mkb79AuthImporter.DuplicateMessage(result));
|
||||
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
||||
return;
|
||||
case Mkb79ImportOutcome.Success when result.Account is { } account:
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
using AudibleUtilities;
|
||||
using CommandLine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationCli;
|
||||
|
||||
[Verb("list-accounts", HelpText = "List configured Audible accounts: locale, whether the account is included in automatic GUI scans ('Scan library'), and whether stored credentials are valid.")]
|
||||
[Verb("list-accounts", HelpText = "List configured Audible accounts: locale, any further marketplaces the account also scans, whether the account is included in automatic GUI scans ('Scan library'), and whether stored credentials are valid.")]
|
||||
internal class ListAccountsOptions : OptionsBase
|
||||
{
|
||||
[Option('b', "bare", HelpText = "Print tab-separated values without table borders (account id, name, locale, scan library, authenticated).")]
|
||||
[Option('b', "bare", HelpText = "Print tab-separated values without table borders (account id, name, locale, scan library, authenticated, also-scans).")]
|
||||
public bool Bare { get; set; }
|
||||
|
||||
protected override Task ProcessAsync()
|
||||
@@ -29,28 +30,44 @@ internal class ListAccountsOptions : OptionsBase
|
||||
a.AccountName ?? "",
|
||||
a.Locale?.Name ?? "",
|
||||
a.LibraryScan ? "yes" : "no",
|
||||
a.IdentityTokens?.IsValid == true ? "yes" : "no"))
|
||||
a.IdentityTokens?.IsValid == true ? "yes" : "no",
|
||||
string.Join(", ", a.AdditionalLocales.Select(l => l.Name))))
|
||||
.ToArray();
|
||||
|
||||
if (Bare)
|
||||
{
|
||||
// the extra field goes last and is always present, so a script reading the first five keeps working
|
||||
foreach (var r in rows)
|
||||
Console.WriteLine($"{r.AccountId}\t{r.AccountName}\t{r.Locale}\t{r.LibraryScan}\t{r.Authenticated}");
|
||||
Console.WriteLine($"{r.AccountId}\t{r.AccountName}\t{r.Locale}\t{r.LibraryScan}\t{r.Authenticated}\t{r.AlsoScans}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Out.DrawTable(
|
||||
rows,
|
||||
new TextTableOptions(),
|
||||
new ColumnDef<AccountListRow>("Account ID", r => r.AccountId),
|
||||
new ColumnDef<AccountListRow>("Name", r => r.AccountName),
|
||||
new ColumnDef<AccountListRow>("Locale", r => r.Locale),
|
||||
new ColumnDef<AccountListRow>("Scan library", r => r.LibraryScan),
|
||||
new ColumnDef<AccountListRow>("Authenticated", r => r.Authenticated));
|
||||
// 'Locale' alone would misreport what a scan does for an account reading several marketplaces. The
|
||||
// column is left out entirely when no account has any, which is the ordinary case.
|
||||
var columns = new List<ColumnDef<AccountListRow>>
|
||||
{
|
||||
new("Account ID", r => r.AccountId),
|
||||
new("Name", r => r.AccountName),
|
||||
new("Locale", r => r.Locale)
|
||||
};
|
||||
|
||||
if (rows.Any(r => r.AlsoScans.Length > 0))
|
||||
columns.Add(new ColumnDef<AccountListRow>("Also scans", r => r.AlsoScans));
|
||||
|
||||
columns.Add(new ColumnDef<AccountListRow>("Scan library", r => r.LibraryScan));
|
||||
columns.Add(new ColumnDef<AccountListRow>("Authenticated", r => r.Authenticated));
|
||||
|
||||
Console.Out.DrawTable(rows, new TextTableOptions(), columns.ToArray());
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed record AccountListRow(string AccountId, string AccountName, string Locale, string LibraryScan, string Authenticated);
|
||||
private sealed record AccountListRow(
|
||||
string AccountId,
|
||||
string AccountName,
|
||||
string Locale,
|
||||
string LibraryScan,
|
||||
string Authenticated,
|
||||
string AlsoScans);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using CommandLine;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@@ -20,6 +21,9 @@ internal class LoginExternalOptions : OptionsBase
|
||||
[Option("response-url", Required = false, HelpText = "Final browser URL after login. Use when stdin is not a TTY (e.g. scripts, Docker).")]
|
||||
public string? ResponseUrl { get; set; }
|
||||
|
||||
[Option("device-registration", Required = false, HelpText = "CurrentAndroid, RetailAndroid, or Mkb79IPhone. Defaults to Settings. Only used for a new sign-in; remove the account first.")]
|
||||
public string? DeviceRegistration { get; set; }
|
||||
|
||||
protected override async Task ProcessAsync()
|
||||
{
|
||||
var accountId = AccountId?.Trim();
|
||||
@@ -46,6 +50,13 @@ internal class LoginExternalOptions : OptionsBase
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryResolveRegistrationProfile(out var registrationProfile, out var registrationError))
|
||||
{
|
||||
PrintVerbUsage("ERROR", "=====", registrationError);
|
||||
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
||||
return;
|
||||
}
|
||||
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
// Persist by canonical locale name ("germany"), not the user input ("de").
|
||||
var account = persister.AccountsSettings.Upsert(accountId, locale.Name);
|
||||
@@ -54,6 +65,9 @@ internal class LoginExternalOptions : OptionsBase
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"Account '{accountId}' ({locale.Name}) is already authenticated. No browser login needed.");
|
||||
if (!string.IsNullOrWhiteSpace(DeviceRegistration))
|
||||
Console.WriteLine(
|
||||
"Device registration only applies to a new sign-in. Remove the account first, then run login-external again.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,7 +87,8 @@ internal class LoginExternalOptions : OptionsBase
|
||||
loginExternal,
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
account.GetIdentityTokensJsonPath(),
|
||||
registrationProfile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -103,8 +118,29 @@ internal class LoginExternalOptions : OptionsBase
|
||||
?? AudibleApi.Locale.Empty;
|
||||
}
|
||||
|
||||
internal static bool IsEmptyLocale(AudibleApi.Locale locale)
|
||||
=> string.IsNullOrEmpty(locale.CountryCode);
|
||||
internal static bool IsEmptyLocale(Locale locale) => string.IsNullOrEmpty(locale.CountryCode);
|
||||
|
||||
internal bool TryResolveRegistrationProfile(out DeviceRegistrationProfile profile, out string error)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DeviceRegistration))
|
||||
{
|
||||
profile = Configuration.Instance.GetDeviceRegistrationProfile();
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Enum.TryParse<DeviceRegistrationKind>(DeviceRegistration, ignoreCase: true, out var kind)
|
||||
&& Enum.IsDefined(kind))
|
||||
{
|
||||
profile = DeviceRegistrationProfile.FromKind(kind);
|
||||
error = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
profile = DeviceRegistrationProfile.Default;
|
||||
error = $"Unknown device registration '{DeviceRegistration}'. Use CurrentAndroid, RetailAndroid, or Mkb79IPhone.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class CliLoginExternal : ILoginExternal
|
||||
{
|
||||
@@ -112,8 +148,6 @@ internal class LoginExternalOptions : OptionsBase
|
||||
|
||||
public CliLoginExternal(string? presetResponseUrl) => _presetResponseUrl = presetResponseUrl;
|
||||
|
||||
public string DeviceName => "Libation";
|
||||
|
||||
public string GetResponseUrl(string loginUrl, CookieCollection signInCookies)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_presetResponseUrl))
|
||||
|
||||
@@ -15,11 +15,22 @@ namespace LibationCli;
|
||||
public class SetDownloadStatusOptions : OptionsBase
|
||||
{
|
||||
//https://github.com/commandlineparser/commandline/wiki/Option-Groups
|
||||
[Option(shortName: 'd', longName: "downloaded", Group = "Download Status", HelpText = "set download status to 'Downloaded'")]
|
||||
[Option(shortName: 'd', longName: "downloaded", Group = "Download Status", HelpText = "if the audio file can be found, mark the book 'Downloaded'")]
|
||||
public bool SetDownloaded { get; set; }
|
||||
|
||||
[Option(shortName: 'n', longName: "not-downloaded", Group = "Download Status", HelpText = "set download status to 'Not Downloaded'")]
|
||||
public bool SetNotDownloaded { get; set; }
|
||||
[Option(shortName: 'p', longName: "download-pending", Group = "Download Status", HelpText = "if the audio file cannot be found, mark the book 'Download Pending' (previously 'Not Downloaded')")]
|
||||
public bool SetDownloadPending { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="SetDownloadPending"/> was called while the status was named "Not Downloaded". Kept
|
||||
/// working so scripts written against the old name survive the rename, and kept out of the help so the
|
||||
/// new name is the only one offered. <see cref="SetPending"/> is what the verb acts on.
|
||||
/// </summary>
|
||||
[Option(shortName: 'n', longName: "not-downloaded", Group = "Download Status", Hidden = true)]
|
||||
public bool SetNotDownloadedLegacy { get; set; }
|
||||
|
||||
/// <summary>Whether the run was asked for 'Download Pending', under either flag name.</summary>
|
||||
internal bool SetPending => SetDownloadPending || SetNotDownloadedLegacy;
|
||||
|
||||
[Option('f', "force", HelpText = "Set the download status regardless of whether the book's audio file can be found. Only one download status option may be used with this option.")]
|
||||
public bool Force { get; set; }
|
||||
@@ -29,7 +40,7 @@ public class SetDownloadStatusOptions : OptionsBase
|
||||
|
||||
protected override async Task ProcessAsync()
|
||||
{
|
||||
if (Force && SetDownloaded && SetNotDownloaded)
|
||||
if (Force && SetDownloaded && SetPending)
|
||||
{
|
||||
PrintVerbUsage("ERROR:\nWhen run with --force option, only one download status option may be used.");
|
||||
return;
|
||||
@@ -58,7 +69,7 @@ public class SetDownloadStatusOptions : OptionsBase
|
||||
}
|
||||
else
|
||||
{
|
||||
var bulkSetStatus = new BulkSetDownloadStatus(libraryBooks, SetDownloaded, SetNotDownloaded);
|
||||
var bulkSetStatus = new BulkSetDownloadStatus(libraryBooks, SetDownloaded, SetPending);
|
||||
await Task.Run(() => bulkSetStatus.Discover());
|
||||
await bulkSetStatus.ExecuteAsync();
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class Program
|
||||
#if DEBUG
|
||||
string input = "";
|
||||
|
||||
//input = " set-status -n --force B017V4IM1G";
|
||||
//input = " set-status -p --force B017V4IM1G";
|
||||
//input = " liberate B017V4IM1G";
|
||||
//input = " convert B017V4IM1G";
|
||||
//input = " search \"-liberated\"";
|
||||
|
||||
@@ -52,7 +52,8 @@ public static class CloudSyncedFolders
|
||||
catch (Exception ex)
|
||||
{
|
||||
// cldapi.dll is absent before Windows 10 1709, where there are no sync roots to find.
|
||||
Serilog.Log.Logger.Debug(ex, "Could not read cloud sync root information for {Path}", path);
|
||||
// Reached while building a crash message, so it must not need Serilog. See issue #2001.
|
||||
StartupLog.Debug(ex, $"Could not read cloud sync root information for {path}");
|
||||
return CloudSyncStatus.NotSynced;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,30 @@ public partial class Configuration
|
||||
When enabled, books from the Audible Plus catalog (titles you stream or borrow under your membership, not purchased) are imported into Libation.
|
||||
|
||||
Downloading or liberating many Plus titles in a short time can cause Audible to temporarily deny content licenses ("license denied") for a day or two. That limit is enforced by Audible, not Libation — waiting and retrying usually fixes it. If problems persist after several days, report on Libation's GitHub with logs.
|
||||
""" },
|
||||
{nameof(CheckForUpgradesAtStartup), """
|
||||
When enabled, Libation asks GitHub whether a newer
|
||||
release exists each time it starts, and offers it to
|
||||
you if there is one.
|
||||
|
||||
Turn this off if something else keeps Libation up to
|
||||
date, such as a package manager or an AppImage
|
||||
updater. You can still check whenever you like:
|
||||
Settings > About has a "Check for Upgrade" button
|
||||
that works either way.
|
||||
""" },
|
||||
{nameof(DeviceRegistrationKind), """
|
||||
Which virtual device Libation registers with Amazon
|
||||
when you sign in.
|
||||
|
||||
Android emulator is the default and is required for
|
||||
Widevine. The experimental options exist because
|
||||
Audible has been refusing download licenses for some
|
||||
emulator registrations.
|
||||
|
||||
This only applies to a new sign-in. Remove and re-add
|
||||
the account (or run login-external) after changing it.
|
||||
The iPhone option cannot use Widevine.
|
||||
""" }
|
||||
}.AsReadOnly();
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ public partial class Configuration
|
||||
(KnownDirectories.MyDocs, () => MyDocs),
|
||||
// this is important to not let very early calls try to accidentally load LibationFiles too early.
|
||||
// also, keep this at bottom of this list
|
||||
(KnownDirectories.LibationFiles, () => Instance.LibationFiles.Location)
|
||||
(KnownDirectories.LibationFiles, () => Instance!.LibationFiles.Location)
|
||||
};
|
||||
public static string? GetKnownDirectoryPath(KnownDirectories directory)
|
||||
{
|
||||
|
||||
@@ -291,6 +291,7 @@ public partial class Configuration
|
||||
_ = LameEncoderQuality;
|
||||
_ = ClipsBookmarksFileFormat;
|
||||
_ = TokenStorageMethod;
|
||||
_ = DeviceRegistrationKind;
|
||||
_ = SpatialAudioCodec;
|
||||
_ = FileDownloadQuality;
|
||||
_ = CreationTime;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AudibleApi;
|
||||
using AudibleApi.Authorization;
|
||||
using FileManager;
|
||||
using Newtonsoft.Json;
|
||||
@@ -117,8 +118,8 @@ public partial class Configuration
|
||||
[Description("Book display font size")]
|
||||
public float GridFontScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); }
|
||||
|
||||
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
|
||||
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
[Description("Check for new Libation versions at startup")]
|
||||
public bool CheckForUpgradesAtStartup { get => GetNonString(defaultValue: true); set => SetNonString(value); }
|
||||
|
||||
[Description("Location for book storage. Includes destination of newly liberated books")]
|
||||
public LongPath? Books
|
||||
@@ -352,6 +353,16 @@ public partial class Configuration
|
||||
set => SetNonString(value);
|
||||
}
|
||||
|
||||
[Description("Experimental: virtual device to register as when signing in.")]
|
||||
public DeviceRegistrationKind DeviceRegistrationKind
|
||||
{
|
||||
get => GetNonString(defaultValue: DeviceRegistrationKind.CurrentAndroid);
|
||||
set => SetNonString(value);
|
||||
}
|
||||
|
||||
public DeviceRegistrationProfile GetDeviceRegistrationProfile()
|
||||
=> DeviceRegistrationProfile.FromKind(DeviceRegistrationKind);
|
||||
|
||||
[Description("Use Widevine DRM")]
|
||||
public bool UseWidevine { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
|
||||
@@ -55,9 +55,9 @@ public static class DailyDownloadLimitUserMessage
|
||||
=> $"Skipped {skippedCount} title(s) because of your daily download limit. They remain un-liberated and will be tried on the next run.";
|
||||
|
||||
/// <summary>
|
||||
/// Suggests turning the limit on after a license denial that looks like Audible throttling. Returns null when
|
||||
/// the suggestion would be unhelpful: a limit is already configured, or too little was downloaded recently for
|
||||
/// throttling to be a plausible explanation.
|
||||
/// Suggests turning the limit on after a license denial that looks like Audible throttling but did not
|
||||
/// name CustomerThrottled. Returns null when the suggestion would be unhelpful: a limit is already
|
||||
/// configured, or too little was downloaded recently for throttling to be a plausible explanation.
|
||||
/// </summary>
|
||||
public static string? BuildSuggestionParagraph(Configuration config, IReadOnlyList<DownloadHistoryEntry> history, DateTimeOffset now)
|
||||
{
|
||||
|
||||
@@ -67,7 +67,8 @@ public static class EssentialFileValidator
|
||||
// ensure we can open for read and write
|
||||
}
|
||||
|
||||
Log.Logger.Debug("Essential file validated: {DisplayName} at \"{Path}\"", displayName, path);
|
||||
// A Serilog failure here would be caught below and retried as if the file itself were bad.
|
||||
StartupLog.Debug($"Essential file validated: {displayName} at \"{path}\"");
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
|
||||
namespace LibationFileManager;
|
||||
|
||||
/// <summary>
|
||||
/// An assembly the runtime could not bind to a usable file in Libation's install folder, with the version
|
||||
/// the build was compiled against and the version actually on disk (null when the file is not there).
|
||||
/// </summary>
|
||||
public sealed record InstallAssemblyFailure(
|
||||
string AssemblyName,
|
||||
Version RequestedVersion,
|
||||
Version? InstalledVersion,
|
||||
string ExpectedPath)
|
||||
{
|
||||
public string FileName => System.IO.Path.GetFileName(ExpectedPath);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace LibationFileManager;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a fresh copy of Libation after startup has repaired the install folder underneath the running one.
|
||||
/// <para/>
|
||||
/// The running process cannot simply carry on: it has already loaded the assemblies that the rollback just
|
||||
/// replaced on disk. Restarting is the only way to pick up the restored files, so the user is asked whether
|
||||
/// to do it now.
|
||||
/// </summary>
|
||||
public static class InstallRelauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Set on the child process so it knows not to offer a restart of its own.
|
||||
/// <para/>
|
||||
/// A rollback deletes the pending upgrade marker before it returns, so it normally cannot happen twice.
|
||||
/// But that delete is best-effort and swallows its own failure, and a marker that outlives a rollback
|
||||
/// would otherwise mean every launch rolls back and offers to restart again. One relaunch, then.
|
||||
/// </summary>
|
||||
public const string RelaunchedEnvironmentVariable = "LIBATION_RELAUNCHED_AFTER_ROLLBACK";
|
||||
|
||||
/// <summary>True when this process was started by <see cref="TryRelaunch"/>.</summary>
|
||||
public static bool WasRelaunched
|
||||
=> Environment.GetEnvironmentVariable(RelaunchedEnvironmentVariable) == "1";
|
||||
|
||||
/// <summary>
|
||||
/// Overridable so tests can assert on the decision to relaunch without starting a process.
|
||||
/// </summary>
|
||||
public static Func<string, bool> StartProcess { get; set; } = StartDetached;
|
||||
|
||||
/// <summary>
|
||||
/// Starts a new Libation process. Best effort: if it does not work the user can start Libation
|
||||
/// themselves, which is what the message tells them to do anyway.
|
||||
/// </summary>
|
||||
/// <returns>True when a new process was started.</returns>
|
||||
public static bool TryRelaunch()
|
||||
{
|
||||
try
|
||||
{
|
||||
var executable = Environment.ProcessPath;
|
||||
if (string.IsNullOrWhiteSpace(executable) || !File.Exists(executable))
|
||||
{
|
||||
StartupLog.Error($"Cannot restart Libation: no executable at '{executable}'");
|
||||
return false;
|
||||
}
|
||||
|
||||
var started = StartProcess(executable);
|
||||
StartupLog.Information(started
|
||||
? $"Restarting Libation from {executable}"
|
||||
: $"Could not restart Libation from {executable}");
|
||||
|
||||
return started;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StartupLog.Error(ex, "Could not restart Libation");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StartDetached(string executable)
|
||||
{
|
||||
// UseShellExecute must stay false: setting an environment variable for the child is not allowed
|
||||
// with the shell, and the marker is what stops a restart loop.
|
||||
var startInfo = new ProcessStartInfo(executable)
|
||||
{
|
||||
// The install folder was just rewritten, so start from it rather than inheriting a stale one.
|
||||
WorkingDirectory = Configuration.ProcessDirectory,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
startInfo.Environment[RelaunchedEnvironmentVariable] = "1";
|
||||
|
||||
return Process.Start(startInfo) is not null;
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,55 @@ namespace LibationFileManager;
|
||||
public readonly record struct UpgradeVerificationResult(
|
||||
bool Success,
|
||||
IReadOnlyList<string> FailedFiles,
|
||||
string Summary);
|
||||
string Summary)
|
||||
{
|
||||
/// <summary>
|
||||
/// Manifest files whose on-disk content did match the upgrade package. Any of these means the overlay
|
||||
/// got at least partway through, so files outside the manifest were probably replaced too, and putting
|
||||
/// the backed-up files back cannot return the install to a single version.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> MatchedFiles { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How much of the install a rollback managed to put back, which decides how firmly Libation should push
|
||||
/// the user towards a clean reinstall and whether restarting is worth offering at all.
|
||||
/// </summary>
|
||||
public enum RollbackConfidence
|
||||
{
|
||||
/// <summary>Nothing to report: no rollback happened.</summary>
|
||||
NotRolledBack,
|
||||
|
||||
/// <summary>
|
||||
/// Every backed-up file is back, and no manifest file matched the upgrade package, so the overlay had
|
||||
/// not begun replacing files. The install is the version that was working before.
|
||||
/// </summary>
|
||||
RestoredToPreviousVersion,
|
||||
|
||||
/// <summary>
|
||||
/// Every backed-up file is back, but the overlay had already replaced some, so files outside the
|
||||
/// backup set are probably still from the new version. Libation should run, but the install is mixed.
|
||||
/// </summary>
|
||||
RestoredButInstallIsMixed,
|
||||
|
||||
/// <summary>At least one file could not be put back. Nothing about this install can be trusted.</summary>
|
||||
RestoreIncomplete,
|
||||
}
|
||||
|
||||
public sealed record UpgradeRecoveryResult(
|
||||
bool RolledBack,
|
||||
string Title,
|
||||
string Message,
|
||||
IReadOnlyList<string> FailedFiles);
|
||||
IReadOnlyList<string> FailedFiles)
|
||||
{
|
||||
public RollbackConfidence Confidence { get; init; } = RollbackConfidence.NotRolledBack;
|
||||
|
||||
/// <summary>
|
||||
/// Whether restarting into this install is worth offering. False when the restore left files behind,
|
||||
/// where inviting the user back in would be inviting them into a broken install.
|
||||
/// </summary>
|
||||
public bool WorthRestarting => RolledBack && Confidence is not RollbackConfidence.RestoreIncomplete;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backups, verifies, and rolls back flat zip overlay upgrades (Windows ZipExtractor flow).
|
||||
@@ -30,6 +72,9 @@ public static class InstallUpgradeManager
|
||||
public const string PendingStateFileName = "pending.json";
|
||||
public const string BackupFolderName = "backup";
|
||||
|
||||
/// <summary>Suffix for a loaded assembly moved out of the way so its replacement can be written.</summary>
|
||||
public const string DisplacedFileSuffix = ".libation-old";
|
||||
|
||||
public const string LibationUiBaseIntegrityTypeName = "LibationUiBase.ShowBadBookDialogAsyncDelegate";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@@ -39,12 +84,24 @@ public static class InstallUpgradeManager
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Files an overlay upgrade must have replaced for Libation to start at all, so they are both backed up
|
||||
/// and hash-verified.
|
||||
/// <para/>
|
||||
/// The bottom four were added after issue #2001: Libation cannot reach its own crash dialog without
|
||||
/// them, yet they were absent from this list, so an overlay that left a stale <c>Serilog.dll</c> behind
|
||||
/// still passed verification and the pending marker was cleared as a success.
|
||||
/// </summary>
|
||||
private static readonly string[] AlwaysCriticalFileNames =
|
||||
[
|
||||
"LibationUiBase.dll",
|
||||
"LibationFileManager.dll",
|
||||
"AppScaffolding.dll",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite.dll",
|
||||
"Serilog.dll",
|
||||
"Dinah.Core.dll",
|
||||
"FileManager.dll",
|
||||
"Newtonsoft.Json.dll",
|
||||
];
|
||||
|
||||
private static FatalStartupMessage? s_StartupRecoveryAlert;
|
||||
@@ -117,12 +174,8 @@ public static class InstallUpgradeManager
|
||||
var pendingPath = GetPendingStatePath(installDirectory);
|
||||
File.WriteAllText(pendingPath, JsonSerializer.Serialize(pending, JsonOptions));
|
||||
|
||||
Serilog.Log.Logger.Information(
|
||||
"Prepared in-app upgrade to {TargetVersion}. Backed up {BackedUpCount} files to {BackupDirectory}. Expecting {ExpectedCount} install files to match the upgrade package.",
|
||||
targetVersion,
|
||||
backedUpFiles.Count,
|
||||
backupDirectory,
|
||||
expectedHashes.Count);
|
||||
StartupLog.Information(
|
||||
$"Prepared in-app upgrade to {targetVersion}. Backed up {backedUpFiles.Count} files to {backupDirectory}. Expecting {expectedHashes.Count} install files to match the upgrade package.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -133,6 +186,9 @@ public static class InstallUpgradeManager
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(installDirectory);
|
||||
|
||||
// A previous run's rollback left these behind because it could not delete a file it still had open.
|
||||
DeleteDisplacedFiles(installDirectory);
|
||||
|
||||
var pendingPath = GetPendingStatePath(installDirectory);
|
||||
if (!File.Exists(pendingPath))
|
||||
return null;
|
||||
@@ -145,7 +201,7 @@ public static class InstallUpgradeManager
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Could not read pending upgrade state at {PendingPath}. Attempting emergency rollback.", pendingPath);
|
||||
StartupLog.Error(ex, $"Could not read pending upgrade state at {pendingPath}. Attempting emergency rollback.");
|
||||
return RollbackAndReport(installDirectory, pendingPath, null, ["Could not read pending upgrade state."], ex.Message);
|
||||
}
|
||||
|
||||
@@ -153,18 +209,20 @@ public static class InstallUpgradeManager
|
||||
if (verification.Success)
|
||||
{
|
||||
CompleteUpgrade(installDirectory);
|
||||
Serilog.Log.Logger.Information(
|
||||
"In-app upgrade to {TargetVersion} verified successfully at startup.",
|
||||
pending.TargetVersion);
|
||||
StartupLog.Information($"In-app upgrade to {pending.TargetVersion} verified successfully at startup.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Error(
|
||||
"Incomplete in-app upgrade detected at startup. Target version {TargetVersion}. {Summary}",
|
||||
pending.TargetVersion,
|
||||
verification.Summary);
|
||||
StartupLog.Error(
|
||||
$"Incomplete in-app upgrade detected at startup. Target version {pending.TargetVersion}. {verification.Summary}");
|
||||
|
||||
return RollbackAndReport(installDirectory, pendingPath, pending, verification.FailedFiles, verification.Summary);
|
||||
return RollbackAndReport(
|
||||
installDirectory,
|
||||
pendingPath,
|
||||
pending,
|
||||
verification.FailedFiles,
|
||||
verification.Summary,
|
||||
verification.MatchedFiles);
|
||||
}
|
||||
|
||||
public static UpgradeVerificationResult VerifyInstallMatchesUpgrade(
|
||||
@@ -178,6 +236,7 @@ public static class InstallUpgradeManager
|
||||
return new UpgradeVerificationResult(true, [], "No pending upgrade verification manifest.");
|
||||
|
||||
var failedFiles = new List<string>();
|
||||
var matchedFiles = new List<string>();
|
||||
foreach (var (fileName, expectedHash) in expectedFileHashesSha256)
|
||||
{
|
||||
var installPath = Path.Combine(installDirectory, fileName);
|
||||
@@ -188,7 +247,9 @@ public static class InstallUpgradeManager
|
||||
}
|
||||
|
||||
var actualHash = ComputeSha256Hex(installPath);
|
||||
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
|
||||
matchedFiles.Add(fileName);
|
||||
else
|
||||
failedFiles.Add($"{fileName}: on-disk content does not match upgrade package (file was not replaced)");
|
||||
}
|
||||
|
||||
@@ -197,13 +258,13 @@ public static class InstallUpgradeManager
|
||||
failedFiles.Add(typeCheckFailure);
|
||||
|
||||
if (failedFiles.Count == 0)
|
||||
return new UpgradeVerificationResult(true, failedFiles, "Install folder matches upgrade package.");
|
||||
return new UpgradeVerificationResult(true, failedFiles, "Install folder matches upgrade package.") { MatchedFiles = matchedFiles };
|
||||
|
||||
var summary =
|
||||
$"Upgrade integrity check failed for {failedFiles.Count} item(s):{Environment.NewLine}"
|
||||
+ string.Join(Environment.NewLine, failedFiles.Select(f => $" - {f}"));
|
||||
|
||||
return new UpgradeVerificationResult(false, failedFiles, summary);
|
||||
return new UpgradeVerificationResult(false, failedFiles, summary) { MatchedFiles = matchedFiles };
|
||||
}
|
||||
|
||||
public static void RollbackAfterFailedUpgrade(string installDirectory, string reason)
|
||||
@@ -235,6 +296,8 @@ public static class InstallUpgradeManager
|
||||
|
||||
public static void CompleteUpgrade(string installDirectory)
|
||||
{
|
||||
DeleteDisplacedFiles(installDirectory);
|
||||
|
||||
var stateDirectory = GetUpgradeStateDirectory(installDirectory);
|
||||
if (!Directory.Exists(stateDirectory))
|
||||
return;
|
||||
@@ -245,7 +308,7 @@ public static class InstallUpgradeManager
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Could not delete upgrade state directory {StateDirectory}", stateDirectory);
|
||||
StartupLog.Warning(ex, $"Could not delete upgrade state directory {stateDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,15 +337,15 @@ public static class InstallUpgradeManager
|
||||
string pendingPath,
|
||||
PendingUpgradeState? pending,
|
||||
IReadOnlyList<string> failedFiles,
|
||||
string summary)
|
||||
string summary,
|
||||
IReadOnlyList<string>? filesTheOverlayHadReplaced = null)
|
||||
{
|
||||
var restoredFiles = RestoreFromBackup(installDirectory);
|
||||
var restore = RestoreFromBackup(installDirectory);
|
||||
var confidence = GradeRollback(restore, filesTheOverlayHadReplaced);
|
||||
|
||||
Serilog.Log.Logger.Error(
|
||||
"In-app upgrade failed. Rolled back {RestoredCount} file(s) in {InstallDirectory}. {Summary}",
|
||||
restoredFiles.Count,
|
||||
installDirectory,
|
||||
summary);
|
||||
StartupLog.Error(
|
||||
$"In-app upgrade failed. Rolled back {restore.RestoredFiles.Count} file(s) in {installDirectory}, "
|
||||
+ $"{restore.UnrestoredFiles.Count} could not be restored. Outcome: {confidence}. {summary}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -291,58 +354,238 @@ public static class InstallUpgradeManager
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Could not delete pending upgrade state at {PendingPath}", pendingPath);
|
||||
StartupLog.Warning(ex, $"Could not delete pending upgrade state at {pendingPath}");
|
||||
}
|
||||
|
||||
var targetVersion = pending?.TargetVersion ?? "unknown";
|
||||
var title = "In-app upgrade failed -- Libation was restored";
|
||||
var title = confidence is RollbackConfidence.RestoreIncomplete
|
||||
? "In-app upgrade failed -- this install needs replacing"
|
||||
: "In-app upgrade failed -- Libation was restored";
|
||||
|
||||
var message = $"""
|
||||
Libation attempted an in-app upgrade to version {targetVersion}, but one or more install files were not updated correctly.
|
||||
|
||||
Libation restored your previous install files from backup so you can continue using the app.
|
||||
{DescribeRollbackOutcome(confidence)}
|
||||
|
||||
Details:
|
||||
{summary}
|
||||
|
||||
{DescribeUnrestoredFiles(restore.UnrestoredFiles)}
|
||||
Install folder:
|
||||
{installDirectory}
|
||||
|
||||
Your library database, accounts, and settings are stored separately and were not changed.
|
||||
|
||||
To upgrade safely:
|
||||
1. Quit Libation completely.
|
||||
2. Download the latest release zip from GitHub.
|
||||
3. Extract it to a new folder (do not copy files on top of this install folder).
|
||||
4. Run Libation from the new folder.
|
||||
{DescribeRecoveryAdvice(confidence)}
|
||||
|
||||
More help:
|
||||
{StartupAssemblyBootstrap.TroubleshootIncompleteUpgradeUrl}
|
||||
""";
|
||||
|
||||
s_StartupRecoveryAlert = new FatalStartupMessage(title, message);
|
||||
return new UpgradeRecoveryResult(true, title, message, failedFiles);
|
||||
return new UpgradeRecoveryResult(true, title, message, failedFiles) { Confidence = confidence };
|
||||
}
|
||||
|
||||
private static List<string> RestoreFromBackup(string installDirectory)
|
||||
/// <summary>
|
||||
/// A restore that could not write every file leaves an install nobody should trust. Short of that, the
|
||||
/// question is whether the overlay had already begun replacing files: if it had, the backup covers only
|
||||
/// the dozen names in <see cref="GetCriticalFileNames"/> out of the few hundred in the folder, so
|
||||
/// putting those back cannot bring the install to a single version.
|
||||
/// </summary>
|
||||
private static RollbackConfidence GradeRollback(RestoreResult restore, IReadOnlyList<string>? filesTheOverlayHadReplaced)
|
||||
=> restore.UnrestoredFiles.Count > 0 ? RollbackConfidence.RestoreIncomplete
|
||||
: filesTheOverlayHadReplaced?.Count > 0 ? RollbackConfidence.RestoredButInstallIsMixed
|
||||
: RollbackConfidence.RestoredToPreviousVersion;
|
||||
|
||||
private static string DescribeRollbackOutcome(RollbackConfidence confidence)
|
||||
=> confidence switch
|
||||
{
|
||||
RollbackConfidence.RestoredToPreviousVersion =>
|
||||
"Libation put your previous install files back, and checked each one afterwards. The upgrade had not started replacing files, so this install is the version you were running before.",
|
||||
|
||||
RollbackConfidence.RestoredButInstallIsMixed =>
|
||||
"Libation put your previous install files back and checked each one afterwards. The upgrade had already replaced some other files though, and those are not covered by the backup, so this install is now a mixture of both versions. It should start, but installing a fresh copy is the only way to be sure of it.",
|
||||
|
||||
RollbackConfidence.RestoreIncomplete =>
|
||||
"Libation could not put all of your previous install files back, so this install is incomplete. Please install a fresh copy before using Libation again.",
|
||||
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static string DescribeUnrestoredFiles(IReadOnlyList<string> unrestoredFiles)
|
||||
=> unrestoredFiles.Count == 0
|
||||
? string.Empty
|
||||
: $"""
|
||||
|
||||
Could not be restored:
|
||||
{string.Join(Environment.NewLine, unrestoredFiles.Select(f => $" - {f}"))}
|
||||
|
||||
""";
|
||||
|
||||
private static string DescribeRecoveryAdvice(RollbackConfidence confidence)
|
||||
=> confidence is RollbackConfidence.RestoredToPreviousVersion
|
||||
? """
|
||||
When you next want to upgrade:
|
||||
1. Quit Libation completely.
|
||||
2. Download the latest release from GitHub. The setup.exe installer is the easiest option.
|
||||
3. If you use the zip instead, extract it to a new folder (do not copy files on top of this install folder).
|
||||
"""
|
||||
: """
|
||||
To get back to a clean install:
|
||||
1. Quit Libation completely.
|
||||
2. Download the latest release from GitHub. The setup.exe installer is the easiest option.
|
||||
3. If you use the zip instead, extract it to a new folder (do not copy files on top of this install folder).
|
||||
4. Run Libation from the new folder.
|
||||
""";
|
||||
|
||||
private readonly record struct RestoreResult(IReadOnlyList<string> RestoredFiles, IReadOnlyList<string> UnrestoredFiles);
|
||||
|
||||
/// <summary>
|
||||
/// Puts every backed-up file back, then reads each one to confirm it now matches its backup copy.
|
||||
/// <para/>
|
||||
/// The check is the point: the rollback used to restore, announce success and delete the pending marker
|
||||
/// without ever looking at what it had written, so a copy that half succeeded still reported "Libation
|
||||
/// restored your previous install files". One file failing no longer abandons the rest either.
|
||||
/// </summary>
|
||||
private static RestoreResult RestoreFromBackup(string installDirectory)
|
||||
{
|
||||
var backupDirectory = GetBackupDirectory(installDirectory);
|
||||
var restoredFiles = new List<string>();
|
||||
var unrestoredFiles = new List<string>();
|
||||
|
||||
if (!Directory.Exists(backupDirectory))
|
||||
return restoredFiles;
|
||||
return new RestoreResult(restoredFiles, unrestoredFiles);
|
||||
|
||||
foreach (var backupFile in Directory.EnumerateFiles(backupDirectory, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(backupDirectory, backupFile);
|
||||
var targetPath = Path.Combine(installDirectory, relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
File.Copy(backupFile, targetPath, overwrite: true);
|
||||
restoredFiles.Add(relativePath);
|
||||
|
||||
Serilog.Log.Logger.Information("Upgrade rollback restored {FileName}", relativePath);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
ReplaceInstallFile(backupFile, targetPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
unrestoredFiles.Add($"{relativePath}: could not be restored ({ex.Message})");
|
||||
StartupLog.Error(ex, $"Upgrade rollback could not restore {relativePath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!FilesMatch(backupFile, targetPath))
|
||||
{
|
||||
unrestoredFiles.Add($"{relativePath}: restored copy does not match the backup");
|
||||
StartupLog.Error($"Upgrade rollback wrote {relativePath}, but it does not match the backup");
|
||||
continue;
|
||||
}
|
||||
|
||||
restoredFiles.Add(relativePath);
|
||||
StartupLog.Information($"Upgrade rollback restored {relativePath}");
|
||||
}
|
||||
|
||||
return restoredFiles;
|
||||
return new RestoreResult(restoredFiles, unrestoredFiles);
|
||||
}
|
||||
|
||||
private static bool FilesMatch(string left, string right)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(left)
|
||||
&& File.Exists(right)
|
||||
&& string.Equals(ComputeSha256Hex(left), ComputeSha256Hex(right), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StartupLog.Warning(ex, $"Could not compare {left} with {right}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts <paramref name="source"/> at <paramref name="targetPath"/> even when this process has already
|
||||
/// loaded the file it is replacing.
|
||||
/// <para/>
|
||||
/// Every backed-up file is an assembly, and by the time startup recovery runs, at least
|
||||
/// LibationFileManager and AppScaffolding are loaded and memory-mapped. Writing over a mapped file
|
||||
/// in place corrupts the mapping: <c>File.Copy(overwrite: true)</c> segfaulted the process outright on
|
||||
/// Linux, and Windows denies the write, so the rollback could never finish. Renaming is permitted on
|
||||
/// both, because .NET opens assemblies with <c>FileShare.Delete</c>, and the mapping keeps working off
|
||||
/// the moved inode until the process exits. See issue #2001.
|
||||
/// </summary>
|
||||
private static void ReplaceInstallFile(string source, string targetPath)
|
||||
{
|
||||
string? displaced = null;
|
||||
if (File.Exists(targetPath))
|
||||
{
|
||||
displaced = targetPath + DisplacedFileSuffix;
|
||||
TryDelete(displaced);
|
||||
File.Move(targetPath, displaced, overwrite: true);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Copy(source, targetPath, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Moving the old file aside and then failing to write the new one would leave nothing at all
|
||||
// where a file used to be. Put it back and let the caller report the failure.
|
||||
if (displaced is not null && File.Exists(displaced) && !File.Exists(targetPath))
|
||||
TryMove(displaced, targetPath);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryMove(string from, string to)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(from, to, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StartupLog.Error(ex, $"Could not put {from} back to {to}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the files a previous rollback moved aside. Their replacements are on disk and the process
|
||||
/// that was holding them has exited, so nothing needs them any more.
|
||||
/// <para/>
|
||||
/// Top level only: every backed-up name comes from <see cref="GetCriticalFileNames"/>, which yields
|
||||
/// bare file names, so a displaced file can only ever sit next to the executable. That keeps this cheap
|
||||
/// enough to run on every startup, which it has to, because the rollback that creates these files also
|
||||
/// deletes the pending marker that would otherwise signal there is cleaning up to do.
|
||||
/// </summary>
|
||||
private static void DeleteDisplacedFiles(string installDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(installDirectory))
|
||||
return;
|
||||
|
||||
foreach (var displaced in Directory.EnumerateFiles(installDirectory, $"*{DisplacedFileSuffix}", SearchOption.TopDirectoryOnly))
|
||||
TryDelete(displaced);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StartupLog.Warning(ex, $"Could not clean up displaced install files in {installDirectory}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Still held by something, or not ours to delete. It is inert either way.
|
||||
StartupLog.Debug(ex, $"Could not delete {path}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildExpectedHashesFromZip(string upgradeBundlePath, IReadOnlyList<string> criticalFileNames)
|
||||
@@ -357,7 +600,7 @@ public static class InstallUpgradeManager
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Upgrade package does not contain expected file {FileName}", fileName);
|
||||
StartupLog.Warning($"Upgrade package does not contain expected file {fileName}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ public static class InteropFactory
|
||||
// nothing to load (normal for LibationCli / Docker: OS interop is optional)
|
||||
if (configApp is null)
|
||||
{
|
||||
Serilog.Log.Logger.Warning($"Unable to locate *{CONFIG_APP_ENDING}; continuing without OS interop helpers");
|
||||
StartupLog.Warning($"Unable to locate *{CONFIG_APP_ENDING}; continuing without OS interop helpers");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ public static class InteropFactory
|
||||
catch (Exception e)
|
||||
{
|
||||
//None of the interop functions are strictly necessary for Libation to run.
|
||||
Serilog.Log.Logger.Warning(e, "Unable to load types from assembly {configApp}; continuing without OS interop helpers", configApp);
|
||||
StartupLog.Warning(e, $"Unable to load types from assembly {configApp}; continuing without OS interop helpers");
|
||||
}
|
||||
}
|
||||
private static string? getOSConfigApp()
|
||||
@@ -104,7 +104,7 @@ public static class InteropFactory
|
||||
|
||||
// Let the runtime handle any dll not found exceptions
|
||||
if (assembly is null)
|
||||
Serilog.Log.Logger.Warning($"Unable to load module {args.Name}");
|
||||
StartupLog.Warning($"Unable to load module {args.Name}");
|
||||
|
||||
return assembly;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="11.0.4.1" />
|
||||
<PackageReference Include="AudibleApi" Version="14.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.11" />
|
||||
<PackageReference Include="NameParserSharp" Version="1.5.0" />
|
||||
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
|
||||
@@ -23,6 +23,10 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using Dinah.Core.Logging;
|
||||
using FileManager;
|
||||
using FileManager;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@@ -100,11 +98,11 @@ public class LibationFiles
|
||||
{
|
||||
// now it's set in the file again but no settings have moved yet
|
||||
File.WriteAllText(AppsettingsJsonFile, endingContents);
|
||||
Log.Logger.TryLogInformation("Libation files changed {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, pathToPersist });
|
||||
StartupLog.Information($"Libation files changed. {AppsettingsJsonFile} {LIBATION_FILES_KEY}={pathToPersist}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, pathToPersist });
|
||||
StartupLog.Error(ex, $"Failed to change Libation files location. {AppsettingsJsonFile} {LIBATION_FILES_KEY}={pathToPersist}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +123,12 @@ public class LibationFiles
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Failed to load settings file: {SettingsFile}", settingsFile);
|
||||
StartupLog.Error(ex, $"Failed to load settings file: {settingsFile}");
|
||||
try
|
||||
{
|
||||
Log.Logger.Information("Deleting invalid settings file: {SettingsFile}", settingsFile);
|
||||
StartupLog.Information($"Deleting invalid settings file: {settingsFile}");
|
||||
FileUtility.SaferDelete(settingsFile);
|
||||
Log.Logger.Information("Creating a new, empty setting file: {SettingsFile}", settingsFile);
|
||||
StartupLog.Information($"Creating a new, empty setting file: {settingsFile}");
|
||||
try
|
||||
{
|
||||
File.WriteAllText(settingsFile, "{}");
|
||||
@@ -138,12 +136,12 @@ public class LibationFiles
|
||||
}
|
||||
catch (Exception createEx)
|
||||
{
|
||||
Log.Logger.Error(createEx, "Failed to create new settings file: {SettingsFile}", settingsFile);
|
||||
StartupLog.Error(createEx, $"Failed to create new settings file: {settingsFile}");
|
||||
}
|
||||
}
|
||||
catch (Exception deleteEx)
|
||||
{
|
||||
Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {SettingsFile}", settingsFile);
|
||||
StartupLog.Error(deleteEx, $"Failed to delete the invalid settings file: {settingsFile}");
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -228,7 +226,7 @@ public class LibationFiles
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.TryLogError(ex, $"Failed to create {appsettingsFile}");
|
||||
StartupLog.Error(ex, $"Failed to create {appsettingsFile}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,7 +296,7 @@ public class LibationFiles
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Log.Error(e, "Failed to run shell command. {@Arguments}", psi.ArgumentList);
|
||||
StartupLog.Error(e, $"Failed to run shell command: {string.Join(' ', psi.ArgumentList)}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using FileManager;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationFileManager;
|
||||
|
||||
/// <summary>
|
||||
/// Writes a crash record without Serilog, for the startup window where Serilog is not configured yet or
|
||||
/// cannot be loaded at all.
|
||||
/// <para/>
|
||||
/// Shared by both UIs. Chardonnay had a private copy of this and Classic had nothing, so a Classic user
|
||||
/// whose startup failed before logging was told, correctly, that the error could not be written anywhere.
|
||||
/// See issue #2001.
|
||||
/// </summary>
|
||||
public static class PreLoggingCrashLog
|
||||
{
|
||||
public const string CrashFileName = "LibationCrash.log";
|
||||
|
||||
/// <summary>
|
||||
/// Appends a crash record to the newest Libation log file, falling back to <see cref="CrashFileName"/>
|
||||
/// in the Libation files folder and then in the user profile.
|
||||
/// </summary>
|
||||
/// <param name="exception">The failure to record.</param>
|
||||
/// <param name="extraFields">
|
||||
/// Anything the caller knows that this assembly cannot see, such as the release identifier.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The file written, to be named in the crash dialog, or null when nothing could be written. Callers
|
||||
/// used to tell users to attach <c>LibationCrash.log</c> unconditionally, which is the wrong file
|
||||
/// whenever a <c>Log*.log</c> already exists, and no file at all when this returns null.
|
||||
/// <para/>
|
||||
/// Reported without the Windows extended-length prefix. This path is for a person to read and to paste
|
||||
/// into Explorer, so a <c>\\?\</c> in front of it would be noise at best.
|
||||
/// </returns>
|
||||
public static string? TryWrite(Exception? exception, IEnumerable<(string Name, string Value)>? extraFields = null)
|
||||
{
|
||||
string record;
|
||||
try
|
||||
{
|
||||
record = BuildRecord(exception, extraFields);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Losing the whole record because one field could not be read is what happened before.
|
||||
record = $"{DateTime.Now} - Libation Crash{Environment.NewLine} (crash record could not be built: {ex}){Environment.NewLine} === EXCEPTION ==={Environment.NewLine} {exception}";
|
||||
}
|
||||
|
||||
foreach (var candidate in ResolveCandidateFiles())
|
||||
{
|
||||
if (TryAppend(candidate, record))
|
||||
return candidate.PathWithoutPrefix;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every value is read through <see cref="Describe"/>, so a property that throws contributes its error
|
||||
/// text instead of taking the record down with it. <c>Books</c> genuinely does throw this early, and
|
||||
/// <c>InteropFactory.InteropFunctionsType</c> ran a static constructor that itself needed Serilog.
|
||||
/// </summary>
|
||||
private static string BuildRecord(Exception? exception, IEnumerable<(string Name, string Value)>? extraFields)
|
||||
{
|
||||
var fields = new List<(string Name, string Value)>
|
||||
{
|
||||
("OS", Describe(() => Configuration.OS.ToString())),
|
||||
("Version", Describe(() => Configuration.LibationVersion?.ToString() ?? "[null]")),
|
||||
("InteropFunctionsType", Describe(() => InteropFactory.InteropFunctionsType?.ToString() ?? "[null]")),
|
||||
("LibationFiles", Describe(() => Configuration.Instance.LibationFiles.Location.PathWithoutPrefix)),
|
||||
("Books Folder", Describe(() => Configuration.Instance.Books ?? "[null]")),
|
||||
};
|
||||
|
||||
if (extraFields is not null)
|
||||
fields.AddRange(extraFields);
|
||||
|
||||
var width = fields.Max(f => f.Name.Length) + 2;
|
||||
var lines = fields.Select(f => $" {f.Name.PadRight(width)}{f.Value}");
|
||||
|
||||
return $"""
|
||||
{DateTime.Now} - Libation Crash
|
||||
{string.Join(Environment.NewLine, lines)}
|
||||
=== EXCEPTION ===
|
||||
{exception}
|
||||
""";
|
||||
}
|
||||
|
||||
private static string Describe(Func<string> read)
|
||||
{
|
||||
try
|
||||
{
|
||||
return read();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ex.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<LongPath> ResolveCandidateFiles()
|
||||
{
|
||||
var logDirectory = Describe(() => Configuration.Instance.LibationFiles.Location.Path);
|
||||
|
||||
if (Directory.Exists(logDirectory))
|
||||
{
|
||||
var newestLog = NewestLogFile(logDirectory);
|
||||
if (newestLog is not null)
|
||||
yield return newestLog;
|
||||
|
||||
yield return Path.Combine(logDirectory, CrashFileName);
|
||||
}
|
||||
|
||||
var userProfile = Describe(() => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
|
||||
if (Directory.Exists(userProfile))
|
||||
yield return Path.Combine(userProfile, CrashFileName);
|
||||
}
|
||||
|
||||
private static string? NewestLogFile(string logDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.GetFiles(logDirectory, "Log*.log")
|
||||
.Select(f => new FileInfo(f))
|
||||
.OrderByDescending(f => f.CreationTimeUtc)
|
||||
.FirstOrDefault()
|
||||
?.FullName;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryAppend(LongPath path, string record)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Without this the record ran straight onto the end of the last log line.
|
||||
var separator = NeedsLeadingNewLine(path) ? Environment.NewLine : string.Empty;
|
||||
|
||||
using var writer = new StreamWriter(path, append: true);
|
||||
writer.WriteLine(separator + record);
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool NeedsLeadingNewLine(LongPath path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists || info.Length == 0)
|
||||
return false;
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
stream.Seek(-1, SeekOrigin.End);
|
||||
var last = stream.ReadByte();
|
||||
return last is not ('\n' or '\r');
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ public sealed class SingleInstance : IDisposable
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let the guard itself prevent startup. Fail open by treating this as the first instance.
|
||||
Serilog.Log.Logger.Warning(ex, "Could not evaluate the single-instance lock; continuing without it.");
|
||||
StartupLog.Warning(ex, "Could not evaluate the single-instance lock; continuing without it.");
|
||||
mutex?.Dispose();
|
||||
return new SingleInstance(null, true);
|
||||
}
|
||||
|
||||
@@ -32,18 +32,57 @@ public static class StartupAssemblyBootstrap
|
||||
/// Call once immediately after <c>RunPreConfigMigrations</c>, before assigning UI assembly hooks such as
|
||||
/// <c>BadBookActionDialogBase.ShowAsyncImpl</c>.
|
||||
/// </summary>
|
||||
public static void RecoverFromIncompleteUpgradeIfNeeded()
|
||||
/// <returns>
|
||||
/// The notice to show when a rollback replaced install files, in which case the caller must show it and
|
||||
/// quit rather than continue. This process has already loaded the assemblies that were just swapped out
|
||||
/// from under it, so what is in memory no longer matches what is on disk. Null when there was nothing to
|
||||
/// recover, which is the normal case.
|
||||
/// </returns>
|
||||
public static StartupRecoveryNotice? RecoverFromIncompleteUpgradeIfNeeded()
|
||||
{
|
||||
try
|
||||
{
|
||||
InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(Configuration.ProcessDirectory);
|
||||
var recovery = InstallUpgradeManager.RecoverPendingUpgradeIfNeeded(Configuration.ProcessDirectory);
|
||||
if (recovery?.RolledBack != true)
|
||||
return null;
|
||||
|
||||
InstallUpgradeManager.TakeStartupRecoveryAlert();
|
||||
|
||||
var offerRestart = ShouldOfferRestart(recovery, InstallRelauncher.WasRelaunched);
|
||||
|
||||
return new StartupRecoveryNotice(
|
||||
new FatalStartupMessage(
|
||||
recovery.Title,
|
||||
recovery.Message + Environment.NewLine + Environment.NewLine + DescribeRestart(offerRestart, recovery.Confidence)),
|
||||
offerRestart);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed while recovering from a pending in-app upgrade");
|
||||
StartupLog.Error(ex, "Failed while recovering from a pending in-app upgrade");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether to ask the user about restarting: only for an install the rollback fully restored, and only
|
||||
/// in a process that was not itself started by a restart. A rollback deletes the pending marker before
|
||||
/// returning, but that delete swallows its own failure, and a marker that outlives one rollback would
|
||||
/// otherwise let this repeat on every launch.
|
||||
/// </summary>
|
||||
public static bool ShouldOfferRestart(UpgradeRecoveryResult recovery, bool wasRelaunched)
|
||||
=> recovery.WorthRestarting && !wasRelaunched;
|
||||
|
||||
/// <summary>
|
||||
/// Libation has to close either way, because the files underneath it just changed. How warmly to suggest
|
||||
/// coming straight back depends on what it found when it checked its own work.
|
||||
/// </summary>
|
||||
private static string DescribeRestart(bool offerRestart, RollbackConfidence confidence)
|
||||
=> !offerRestart
|
||||
? "Libation needs to close now."
|
||||
: confidence is RollbackConfidence.RestoredButInstallIsMixed
|
||||
? "Libation needs to close now. You can start it again if you would like to carry on for the moment, though a fresh install would be better.\n\nWould you like to start Libation again now?"
|
||||
: "Libation needs to close now so it can load the files it just put back.\n\nWould you like to start Libation again now?";
|
||||
|
||||
private static void TrySyncWindowsInstallMetadata()
|
||||
{
|
||||
if (!Configuration.IsWindows || InteropFactory.InteropFunctionsType is null)
|
||||
@@ -55,7 +94,7 @@ public static class StartupAssemblyBootstrap
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Could not run install metadata sync at startup");
|
||||
StartupLog.Warning(ex, "Could not run install metadata sync at startup");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +221,121 @@ public static class StartupAssemblyBootstrap
|
||||
public static bool IsInstallFolderAssemblyLoadFailure(Exception ex) =>
|
||||
IsApplicationControlBlockedAssembly(ex)
|
||||
|| IsMissingDependencyAssembly(ex)
|
||||
|| IsIncompleteUpgradeAssemblyFailure(ex);
|
||||
|| IsIncompleteUpgradeAssemblyFailure(ex)
|
||||
|| TryGetInstallAssemblyFailure(ex, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an assembly the runtime could not bind to a usable file in the install folder, whatever the
|
||||
/// assembly is called.
|
||||
/// <para/>
|
||||
/// The name-based checks above only recognise the handful of assemblies that had already caused a bug
|
||||
/// report, so a missing <c>Serilog.dll</c> fell through all of them to a generic "fatal error" dialog
|
||||
/// with no rollback attempted. See issue #2001.
|
||||
/// <para/>
|
||||
/// A stale file reports identically to an absent one: the loader says "the system cannot find the file
|
||||
/// specified" either way, because it rejects a file whose version is below the reference and then has
|
||||
/// nothing left to bind. The on-disk version is therefore worth reading and telling the user about.
|
||||
/// </summary>
|
||||
public static bool TryGetInstallAssemblyFailure(Exception? ex, out InstallAssemblyFailure? failure)
|
||||
{
|
||||
failure = null;
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AggregateException aggregate)
|
||||
{
|
||||
foreach (var inner in aggregate.InnerExceptions)
|
||||
{
|
||||
if (TryGetInstallAssemblyFailure(inner, out failure))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (current is not FileNotFoundException and not FileLoadException)
|
||||
continue;
|
||||
|
||||
var fileName = (current as FileNotFoundException)?.FileName ?? (current as FileLoadException)?.FileName;
|
||||
if (!TryParseAssemblyReference(fileName, out var assemblyName, out var requestedVersion)
|
||||
|| assemblyName is null
|
||||
|| requestedVersion is null)
|
||||
continue;
|
||||
|
||||
var path = FindInstallAssemblyPath(assemblyName);
|
||||
var installedVersion = path is null ? null : TryReadAssemblyVersion(path);
|
||||
|
||||
// Present, readable and no older than the reference: this bind failed for some other reason,
|
||||
// so leave it to a caller that knows more rather than blaming the install folder.
|
||||
if (installedVersion is not null && installedVersion >= requestedVersion)
|
||||
continue;
|
||||
|
||||
failure = new InstallAssemblyFailure(
|
||||
assemblyName,
|
||||
requestedVersion,
|
||||
installedVersion,
|
||||
path ?? Path.Combine(Configuration.ProcessDirectory, $"{assemblyName}.dll"));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string DescribeInstallAssemblyFailure(InstallAssemblyFailure failure)
|
||||
=> failure.InstalledVersion is null
|
||||
? $"{failure.FileName} is missing from the install folder. This build of Libation needs version {failure.RequestedVersion}."
|
||||
: $"{failure.FileName} in the install folder is version {failure.InstalledVersion}, but this build of Libation needs version {failure.RequestedVersion}. The upgrade did not replace this file.";
|
||||
|
||||
/// <summary>
|
||||
/// True only for a genuine assembly reference, which always carries a version. This keeps the check off
|
||||
/// the <see cref="FileNotFoundException"/>s that carry a plain file path, such as the one
|
||||
/// <see cref="ValidateEntityFrameworkCoreSqlitePresent"/> raises.
|
||||
/// </summary>
|
||||
private static bool TryParseAssemblyReference(string? fileName, out string? assemblyName, out Version? version)
|
||||
{
|
||||
assemblyName = null;
|
||||
version = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = new AssemblyName(fileName);
|
||||
if (string.IsNullOrWhiteSpace(parsed.Name) || parsed.Version is null)
|
||||
return false;
|
||||
|
||||
assemblyName = parsed.Name;
|
||||
version = parsed.Version;
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindInstallAssemblyPath(string assemblyName)
|
||||
{
|
||||
foreach (var extension in new[] { ".dll", ".exe" })
|
||||
{
|
||||
var path = Path.Combine(Configuration.ProcessDirectory, assemblyName + extension);
|
||||
if (File.Exists(path))
|
||||
return path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Version? TryReadAssemblyVersion(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return AssemblyName.GetAssemblyName(path).Version;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Not a managed assembly, or unreadable. Either way we cannot name a version.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static FatalStartupMessage? GetStartupFailureMessage(Exception ex)
|
||||
{
|
||||
@@ -217,6 +370,14 @@ public static class StartupAssemblyBootstrap
|
||||
GetLibraryLoadFailureMessage());
|
||||
}
|
||||
|
||||
// Last, so the checks above keep naming the specific cause they recognise.
|
||||
if (TryGetInstallAssemblyFailure(ex, out _))
|
||||
{
|
||||
return new FatalStartupMessage(
|
||||
"Libation could not load a required file",
|
||||
GetIncompleteUpgradeFailureMessage(ex));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -249,7 +410,9 @@ public static class StartupAssemblyBootstrap
|
||||
/// </summary>
|
||||
public static FatalStartupMessage GetFatalStartupMessage(Exception ex, FatalStartupMessage genericFallback)
|
||||
{
|
||||
if (IsIncompleteUpgradeAssemblyFailure(ex))
|
||||
// TryEmergencyRollback is a no-op without a backup folder, so it is safe to offer it to any
|
||||
// assembly failure that points at the install folder rather than only the named ones.
|
||||
if (IsIncompleteUpgradeAssemblyFailure(ex) || TryGetInstallAssemblyFailure(ex, out _))
|
||||
{
|
||||
var recovery = InstallUpgradeManager.TryEmergencyRollback(Configuration.ProcessDirectory);
|
||||
if (recovery.RolledBack)
|
||||
@@ -265,12 +428,17 @@ public static class StartupAssemblyBootstrap
|
||||
|
||||
public static string GetIncompleteUpgradeFailureMessage(Exception? ex = null)
|
||||
{
|
||||
var detail = ex?.Message;
|
||||
// Naming the file and both versions turns an opaque loader message into something the user, and
|
||||
// anyone reading their bug report, can act on without guessing.
|
||||
var detail = TryGetInstallAssemblyFailure(ex, out var assemblyFailure) && assemblyFailure is not null
|
||||
? DescribeInstallAssemblyFailure(assemblyFailure)
|
||||
: ex?.Message;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(detail))
|
||||
detail = "(no additional detail)";
|
||||
|
||||
return $"""
|
||||
Libation could not load a required component after an in-app upgrade. This usually means the upgrade overlay did not replace every install file.
|
||||
Libation could not load a required file from its install folder. This usually means an in-app upgrade, or a zip extracted over an existing install, did not replace every file.
|
||||
|
||||
Technical detail:
|
||||
{detail}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace LibationFileManager;
|
||||
|
||||
public enum StartupLogLevel
|
||||
{
|
||||
Debug,
|
||||
Information,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
public sealed record StartupLogEntry(DateTimeOffset Timestamp, StartupLogLevel Level, string Message, Exception? Exception);
|
||||
|
||||
/// <summary>
|
||||
/// Logging for the startup window that runs before <see cref="Configuration.ConfigureLogging"/>, where
|
||||
/// <c>Serilog.Log.Logger</c> is still Serilog's silent logger and writes nowhere.
|
||||
/// <para/>
|
||||
/// Nothing here names a Serilog type, which is the point. Libation's releases are ReadyToRun, so a
|
||||
/// Serilog reference resolves lazily when the line holding it runs; if the install folder's
|
||||
/// <c>Serilog.dll</c> is missing or older than the one this build was compiled against, that line throws.
|
||||
/// A <c>catch</c> block cannot protect its own logging call, so an install broken badly enough to need
|
||||
/// the upgrade recovery in <see cref="InstallUpgradeManager"/> was the exact case in which that recovery
|
||||
/// destroyed the real exception and aborted. See issue #2001.
|
||||
/// <para/>
|
||||
/// Entries recorded before <see cref="ReplayTo"/> are buffered and handed over in order once real logging
|
||||
/// exists, so startup diagnostics reach the log file instead of vanishing. Every method here swallows its
|
||||
/// own failures: this must never be the reason startup ends.
|
||||
/// </summary>
|
||||
public static class StartupLog
|
||||
{
|
||||
/// <summary>
|
||||
/// Room for a whole startup and then some. Bounded so a caller in a retry loop cannot grow this
|
||||
/// without limit while there is still no sink to drain it.
|
||||
/// </summary>
|
||||
private const int MaxBufferedEntries = 500;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly List<StartupLogEntry> Buffered = [];
|
||||
private static Action<StartupLogEntry>? Sink;
|
||||
|
||||
public static void Debug(string message) => Record(StartupLogLevel.Debug, message, null);
|
||||
public static void Debug(Exception? exception, string message) => Record(StartupLogLevel.Debug, message, exception);
|
||||
public static void Information(string message) => Record(StartupLogLevel.Information, message, null);
|
||||
public static void Warning(string message) => Record(StartupLogLevel.Warning, message, null);
|
||||
public static void Warning(Exception? exception, string message) => Record(StartupLogLevel.Warning, message, exception);
|
||||
public static void Error(string message) => Record(StartupLogLevel.Error, message, null);
|
||||
public static void Error(Exception? exception, string message) => Record(StartupLogLevel.Error, message, exception);
|
||||
|
||||
/// <summary>
|
||||
/// Hands every buffered entry to <paramref name="sink"/> in the order it was recorded, then sends
|
||||
/// later entries straight through. Call once, immediately after logging is configured.
|
||||
/// </summary>
|
||||
public static void ReplayTo(Action<StartupLogEntry> sink)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sink);
|
||||
|
||||
StartupLogEntry[] pending;
|
||||
lock (Gate)
|
||||
{
|
||||
Sink = sink;
|
||||
pending = [.. Buffered];
|
||||
Buffered.Clear();
|
||||
}
|
||||
|
||||
foreach (var entry in pending)
|
||||
Deliver(sink, entry);
|
||||
}
|
||||
|
||||
/// <summary>Entries recorded but not yet handed to a sink.</summary>
|
||||
public static IReadOnlyList<StartupLogEntry> BufferedEntries
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
return [.. Buffered];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the sink and anything buffered. Test support: this is process-wide state.</summary>
|
||||
public static void ResetForTests()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
Sink = null;
|
||||
Buffered.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Record(StartupLogLevel level, string message, Exception? exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new StartupLogEntry(DateTimeOffset.Now, level, message, exception);
|
||||
|
||||
Action<StartupLogEntry>? sink;
|
||||
lock (Gate)
|
||||
{
|
||||
sink = Sink;
|
||||
if (sink is null)
|
||||
{
|
||||
if (Buffered.Count < MaxBufferedEntries)
|
||||
Buffered.Add(entry);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Deliver(sink, entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A logger that can end startup is worse than no logger at all.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sink is where Serilog lives, so it gets its own frame that is never inlined into a caller.
|
||||
/// A load failure raised inside it is then contained here rather than surfacing in the middle of
|
||||
/// whatever startup step asked for the log line.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static void Deliver(Action<StartupLogEntry> sink, StartupLogEntry entry)
|
||||
{
|
||||
try
|
||||
{
|
||||
sink(entry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// see Record
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user