Compare commits

...
31 Commits
Author SHA1 Message Date
Robert McRackan aa829df265 log accounts changes 2026-09-08 00:15:08 -04:00
Robert McRackan e84a0a121a "Import library" 2026-09-06 21:57:46 -04:00
rmcrackan 087c107685 Merge pull request #2038 from rmcrackan/rmcrackan/mbucari-fix
Rmcrackan/mbucari fix
2026-09-06 21:36:09 -04:00
Robert McRackan af7a1e8f69 Increment version to 14.2 2026-09-06 21:27:30 -04:00
Robert McRackan cbbd8b7955 Update docs per @MBurari 's fixes 2026-09-06 21:27:10 -04:00
rmcrackan edc737df4a Merge pull request #2032 from Mbucari/master
Update Deendencies and fix warnings
2026-09-06 09:07:28 -04:00
Mbucari 8050e11a41 Revert SkiaSharp to 3.119.4, matching Avalonia 2026-09-06 01:11:20 -06:00
Mbucari bdf55009b5 Fix tests and warnings from new deps 2026-09-06 00:27:17 -06:00
Mbucari a5a28c9d62 Replace ImageSharp dependency with SkiaSharp 2026-09-05 23:52:14 -06:00
Mbucari 70909a40dc Update Dependencies
Replace obsolete Avalonia.Diagnostics package with AvaloniaUI.DiagnosticsSupport.

Update Avalonia to 12.1, applying necessary fixes.
2026-09-05 23:23:00 -06:00
Mbucari a922cfdf40 Add option to overwrite existing files in DownloadPdf 2026-09-05 16:37:57 -06:00
rmcrackan bc806bada0 Merge pull request #2029 from rmcrackan/rmcrackan/2021-device-registration
Rmcrackan/2021 device registration
2026-09-04 21:05:32 -04:00
Robert McRackanandCursor 36207bbab3 Document experimental device registration and the License Denied workaround.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:51:28 -04:00
Robert McRackanandCursor f4dee94785 Add an experimental device-registration setting so License Denied users can re-register without changing the Android default.
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:48:48 -04:00
rmcrackan 3d563a2dcc Merge pull request #2025 from rmcrackan/cursor/2024-podcast-series-number
Do not use Audible's sentinel episode numbers as podcast series order.
2026-09-03 16:26:26 -04:00
Robert McRackanandCursor 67b476eaa0 Do not use Audible's sentinel episode numbers as podcast series order.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 16:18:39 -04:00
rmcrackan a0adf75b53 Merge pull request #2023 from rmcrackan/cursor/throttling-license-ui
Cursor/throttling license UI
2026-09-03 08:18:36 -04:00
Robert McRackan 014beaf71f incr ver 2026-09-02 20:05:35 -04:00
Robert McRackanandCursor 74a028acaa Show a dialog when Audible throttles a download license.
CustomerThrottled was treated as a generic License Denied, so non-Plus titles got no explanation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 20:04:44 -04:00
rmcrackan 66bf8b9de9 Merge pull request #2022 from rmcrackan/rmcrackan/2020-download-store-locale
License extra-marketplace titles against the store they were scanned from
2026-09-02 15:18:56 -04:00
Robert McRackanandCursor 635780a4cf Bump version to 14.0.1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:10:59 -04:00
Robert McRackanandCursor 82fb235de9 Drop the ApiExtendedFunc test hook. Pass the book's marketplace straight to CreateAsync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:06:43 -04:00
Robert McRackanandCursor 336a01db01 Request download licenses from the marketplace the book was scanned from, not only the account's home store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 14:52:44 -04:00
rmcrackan a9df4add33 Merge pull request #2019 from rmcrackan/rmcrackan/dirty-accounts
Warn if accounts dialog is dirty when user attempts to close it
2026-08-31 17:26:38 -04:00
Robert McRackan 129486ce89 Warn if accounts dialog is dirty when user attempts to close it. This includes editing items from the Marketplaces child-dialog 2026-08-31 17:15:56 -04:00
rmcrackan 3e7191adc7 Merge pull request #2017 from rmcrackan/rmcrackan/2015-handle-bad-download
Rmcrackan/2015 handle bad download
2026-08-30 21:45:35 -04:00
Robert McRackan 4cd784787e avoid infinite retry loops 2026-08-30 21:40:38 -04:00
Robert McRackan 5503a9d768 Allow the download to resume from the last flushed position when TLS corruption occurs, just like it already does for premature connection closures. 2026-08-30 21:36:13 -04:00
rmcrackan 094e207c0b Merge pull request #2016 from rmcrackan/rmcrackan/cull-dead-code
Rmcrackan/cull dead code
2026-08-30 14:24:38 -04:00
Robert McRackan 09be09bd1b Cull dead code. Simplify 2026-08-30 14:16:27 -04:00
Robert McRackan 19a867488b Update dependencies 2026-08-30 14:15:35 -04:00
106 changed files with 1436 additions and 303 deletions

No files matched your search

+1
View File
@@ -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" },
],
+4
View File
@@ -21,4 +21,8 @@
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
+23 -5
View File
@@ -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)
{
+4 -1
View File
@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>14.0.0</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>
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<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>
@@ -18,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>
+12 -38
View File
@@ -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;
@@ -188,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;
@@ -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();
}
@@ -165,6 +166,8 @@ 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;
}
+73 -12
View File
@@ -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;
@@ -108,7 +104,8 @@ 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.
@@ -444,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();
}
@@ -477,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,9 +7,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="12.0.0.1" />
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
<PackageReference Include="Google.Protobuf" Version="3.36.1">
<PrivateAssets>all</PrivateAssets>
<PublicAssets>runtime</PublicAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -29,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 }
+30 -27
View File
@@ -1,5 +1,4 @@
using HtmlAgilityPack;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
namespace AudibleUtilities;
@@ -11,35 +10,39 @@ namespace AudibleUtilities;
/// </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 "";
/// <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 (HtmlEntity.DeEntitize(html) ?? html).Trim();
// 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();
var doc = new HtmlDocument();
doc.LoadHtml(BlockBoundary().Replace(html, "\n"));
// Replace block-level boundaries with newlines, then strip all remaining tags.
var stripped = StripTags().Replace(BlockBoundary().Replace(html, "\n"), "");
var text = HtmlEntity.DeEntitize(doc.DocumentNode.InnerText) ?? doc.DocumentNode.InnerText;
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);
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);
}
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>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();
}
@@ -35,4 +35,8 @@
<Folder Include="Migrations\" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -31,4 +31,8 @@
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
+7 -3
View File
@@ -11,9 +11,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="10.2.6.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.6.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>
@@ -18,4 +18,8 @@
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -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();
+1 -1
View File
@@ -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>
+10 -4
View File
@@ -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 -2
View File
@@ -6,8 +6,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="10.2.6.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">
+1 -1
View File
@@ -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()
{
+4
View File
@@ -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)" />
@@ -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;
@@ -87,6 +87,21 @@
</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}">
@@ -18,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;
@@ -43,7 +49,11 @@ 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>
@@ -153,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;
}
}
}
@@ -161,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();
}
@@ -214,7 +235,10 @@ public partial class AccountsDialog : DialogWindow
}
if (importResult.Account is { } account)
{
Accounts.Add(new AccountDto(account));
_isDirty = true;
}
}
catch (Exception ex)
{
@@ -252,7 +276,10 @@ public partial class AccountsDialog : DialogWindow
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()
@@ -282,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;
@@ -50,7 +50,7 @@ public partial class ImageDisplayDialog : DialogWindow, INotifyPropertyChanged
try
{
_bitmapHolder.CoverImage?.Save(selectedFile);
_bitmapHolder.CoverImage?.Save(selectedFile, JpegBitmapEncoderOptions.Default);
}
catch (Exception ex)
{
@@ -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;
}
}
@@ -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,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,7 @@ 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);
@@ -78,6 +80,7 @@ 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;
@@ -144,6 +147,10 @@ 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." : "";
@@ -174,6 +181,7 @@ 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; }
@@ -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()
{
+4
View File
@@ -56,4 +56,8 @@
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -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))
@@ -179,6 +179,19 @@ public partial class Configuration
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;
@@ -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)
{
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="12.0.0.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>
@@ -8,6 +8,21 @@ namespace LibationFileManager.Templates;
public class SeriesOrder : IFormattable
{
/// <summary>
/// A numeric span from the original order string. Keep the original digits for unformatted
/// output so large values (e.g. 2147483647) are not rounded through <see cref="float"/> into
/// scientific notation (issue #2024). Apply the numeric format only when the template asks.
/// </summary>
private readonly record struct NumberPart(string Raw, decimal Value) : IFormattable
{
public override string ToString() => Raw;
public string ToString(string? format, IFormatProvider? formatProvider)
=> string.IsNullOrEmpty(format)
? Raw
: Value.ToString(format, formatProvider ?? CultureInfo.InvariantCulture);
}
private object[] OrderParts { get; }
private SeriesOrder(object[] orderParts)
{
@@ -17,26 +32,25 @@ public class SeriesOrder : IFormattable
public override string ToString() => ToString(null, null);
/// <summary>
/// Use float formatters to format the number parts of the order.
/// Use numeric formatters to format the number parts of the order.
/// </summary>
public string ToString(string? format, IFormatProvider? formatProvider)
=> string.Concat(OrderParts.Select(p => p switch
{
float f => f.ToString(format, formatProvider ?? CultureInfo.InvariantCulture),
IFormattable f => f.ToString(format, formatProvider),
IFormattable f => f.ToString(format, formatProvider ?? CultureInfo.InvariantCulture),
_ => p.ToString(),
})).Trim();
public static SeriesOrder Parse(string? order)
{
List<object> parts = [];
while (TryParseNumber(order, out var value, out var range))
while (TryParseNumber(order, out var number, out var range))
{
var prefix = order[..range.Start.Value];
if (!string.IsNullOrEmpty(prefix))
parts.Add(prefix);
parts.Add(value);
parts.Add(number);
order = order[range.End.Value..];
}
@@ -51,12 +65,12 @@ public class SeriesOrder : IFormattable
/// Try to parse any positive number from within the string (greedy).
/// </summary>
/// <param name="numString">the string to search for a numeric value</param>
/// <param name="value">If this function succeeds, the number that was found; otherwise zero.</param>
/// <param name="range">If this function succeeds, the range of characters representing <paramref name="value"/> in <paramref name="numString"/>; otherwise default</param>
/// <param name="number">If this function succeeds, the number that was found; otherwise default.</param>
/// <param name="range">If this function succeeds, the range of characters representing <paramref name="number"/> in <paramref name="numString"/>; otherwise default</param>
/// <returns>True if a number was found; otherwise false.</returns>
private static bool TryParseNumber([NotNullWhen(true)] string? numString, out float value, out Range range)
private static bool TryParseNumber([NotNullWhen(true)] string? numString, out NumberPart number, out Range range)
{
value = 0;
number = default;
if (string.IsNullOrWhiteSpace(numString))
{
range = default;
@@ -73,14 +87,15 @@ public class SeriesOrder : IFormattable
for (var e = numString.Length; e > s; e--)
{
//The float parser will succeed with trailing whitespace,
//The decimal parser will succeed with trailing whitespace,
//but we want to preserve it in the final display string.
if (char.IsWhiteSpace(numString[e - 1]))
continue;
var substring = numString[s..e];
if (float.TryParse(substring, CultureInfo.InvariantCulture, out value))
if (decimal.TryParse(substring, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var value))
{
number = new NumberPart(substring, value);
range = new Range(s, e);
return true;
}
@@ -18,6 +18,10 @@
<ProjectReference Include="..\FileManager\FileManager.csproj" />
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
@@ -6,8 +6,8 @@ namespace LibationUiBase;
/// <summary>
/// User-facing copy when Audible denies a content license (download/decrypt). Covers temporary
/// service issues and Audible Plus throttling — often mistaken for a Libation bug.
/// Shared by WinForms and Avalonia via the process queue.
/// service issues, explicit CustomerThrottled refusals, and Audible Plus denials — often mistaken
/// for a Libation bug. Shared by WinForms and Avalonia via the process queue.
/// </summary>
public static class ContentLicenseDeniedUserMessage
{
@@ -22,9 +22,24 @@ public static class ContentLicenseDeniedUserMessage
Heavy use of the Audible Plus catalog in a short time can also produce "license denied" responses; community reports often involve on the order of dozens of titles — Audible does not publish a fixed limit. Waiting 24 to 48 hours before trying again is usually enough.
If the official Audible app can play this title, {DeviceRegistrationSettingsUi.RemoveSaveReAddAccountSteps}
If the problem continues after several days, open an issue on Libation's GitHub and include your logs.
""" + AppendSuggestion();
/// <summary>Audible named CustomerThrottled. Shown for any title, Plus or owned.</summary>
public static string BuildDialogBodyForThrottling(string bookTitleWithSubtitle)
=> $"""
You were denied a content license for {bookTitleWithSubtitle}
Audible refused this download because your account is being throttled. This is a temporary rate limit on Audible's side, not a Libation bug.
Wait 24 to 48 hours before trying again. In the meantime you should still be able to play this title in the Audible app or website.
If it still fails after several days, open an issue on Libation's GitHub and include your logs.
""" + DeviceRegistrationSettingsUi.ThrottlingWorkaround + AppendSuggestion();
/// <summary>License denied on an Audible Plus title — often rate limiting, not a Libation defect.</summary>
public static string BuildDialogBodyForPlusCatalog(string bookTitleWithSubtitle)
=> $"""
@@ -35,12 +50,15 @@ public static class ContentLicenseDeniedUserMessage
Try waiting 24 to 48 hours and liberate again. If it still fails after several days, open an issue on Libation's GitHub with logs.
If you should not have access to this title (for example it left Plus before you downloaded), confirm in the Audible app or website.
If the official Audible app can play this title, {DeviceRegistrationSettingsUi.RemoveSaveReAddAccountSteps}
""" + AppendSuggestion();
/// <summary>
/// Audible reports no distinct "throttled" reason, so this suggestion is what turns a guess into evidence:
/// it only appears when Libation's own record shows enough recent downloads for throttling to be plausible,
/// and when the user has no daily limit configured yet. Logged as well as shown.
/// When Audible names CustomerThrottled, the throttling dialog already says so. This extra paragraph is
/// for denials without that reason: it only appears when Libation's own record shows enough recent
/// downloads for throttling to be plausible, and when the user has no daily limit configured yet.
/// Logged as well as shown.
/// </summary>
private static string AppendSuggestion()
{
@@ -0,0 +1,29 @@
using AudibleApi;
using System.Linq;
namespace LibationUiBase;
/// <summary>Shared copy for the experimental device-registration setting (Avalonia and WinForms).</summary>
public static class DeviceRegistrationSettingsUi
{
public static EnumDisplay<DeviceRegistrationKind>[] Options { get; } =
DeviceRegistrationProfile.AllProfiles.Select(p => new EnumDisplay<DeviceRegistrationKind>(p.Kind, p.Description)).ToArray();
public static string SettingLabel { get; } = "Device registration (experimental)";
public static string ReLoginNote { get; }
= "Changing this does not convert existing accounts. Remove the account, save or close the Accounts dialog, then re-add the account (or run login-external) to register again.";
/// <summary>
/// Steps that actually persist a fresh device registration. Removing alone is not enough if the
/// Accounts dialog is still open with the removal uncommitted.
/// </summary>
public static string RemoveSaveReAddAccountSteps { get; }
= "Remove the account, save or close the Accounts dialog, then re-add the account.";
public static string ThrottlingWorkaround { get; }
= "If the official Audible app can play this title, try Settings: pick an experimental device registration, then remove the account, save or close the Accounts dialog, and re-add the account. You can also import credentials from audible-cli.";
public static EnumDisplay<DeviceRegistrationKind> Display(DeviceRegistrationKind kind)
=> Options.FirstOrDefault(o => o.Value.Equals(kind)) ?? Options[0];
}
@@ -21,6 +21,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>
@@ -25,6 +25,8 @@ public enum ProcessBookResult
FailedAbort,
LicenseDenied,
LicenseDeniedPossibleOutage,
/// <summary>Audible named CustomerThrottled on the license denial.</summary>
LicenseDeniedThrottled,
/// <summary>ADRM licenserequest failed with Sable acr:null; Widevine may work (see WidevineRecommendation).</summary>
WidevineRecommended,
/// <summary>Volume full on write; queue should stop (see ProcessQueueViewModel queue loop).</summary>
@@ -86,6 +88,7 @@ public class ProcessBookViewModel : ReactiveObject
(ProcessBookResult.LicenseDenied, true) => "License denied (Plus; often temporary)",
(ProcessBookResult.LicenseDenied, false) => "License Denied",
(ProcessBookResult.LicenseDeniedPossibleOutage, _) => "Possible Service Interruption",
(ProcessBookResult.LicenseDeniedThrottled, _) => "License denied (throttled)",
(ProcessBookResult.WidevineRecommended, _) => WidevineRecommendationUserMessage.QueueStatusText,
(ProcessBookResult.DiskFull, _) => "Disk full, queue stopped",
_ => Status.ToString(),
@@ -237,7 +240,12 @@ public class ProcessBookViewModel : ReactiveObject
catch (ContentLicenseDeniedException ldex)
{
Serilog.Log.Logger.Error(ldex, "Content license was denied for {Book}", LibraryBook.LogFriendly());
if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
if (ldex.IsCustomerThrottled)
{
LogInfo($"{procName}: Content license denied because Audible is throttling this account. Wait 24 to 48 hours, then try again. This is not a Libation bug. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDeniedThrottled;
}
else if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
{
LogInfo($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");
result = ProcessBookResult.LicenseDeniedPossibleOutage;
@@ -814,15 +814,20 @@ public class ProcessQueueViewModel : ReactiveObject
await book.LibraryBook.UpdateBookStatusAsync(LiberatedStatus.Error);
}
else if (result == ProcessBookResult.LicenseDeniedPossibleOutage
|| result == ProcessBookResult.LicenseDeniedThrottled
|| (result == ProcessBookResult.LicenseDenied && book.LibraryBook.IsAudiblePlus))
{
bool show;
lock (resultLock) { show = !shownLicenseGuidanceMessage; shownLicenseGuidanceMessage = true; }
if (show)
{
var body = result == ProcessBookResult.LicenseDeniedPossibleOutage
? ContentLicenseDeniedUserMessage.BuildDialogBodyForPossibleOutage(book.LibraryBook.Book.TitleWithSubtitle)
: ContentLicenseDeniedUserMessage.BuildDialogBodyForPlusCatalog(book.LibraryBook.Book.TitleWithSubtitle);
var title = book.LibraryBook.Book.TitleWithSubtitle;
var body = result switch
{
ProcessBookResult.LicenseDeniedPossibleOutage => ContentLicenseDeniedUserMessage.BuildDialogBodyForPossibleOutage(title),
ProcessBookResult.LicenseDeniedThrottled => ContentLicenseDeniedUserMessage.BuildDialogBodyForThrottling(title),
_ => ContentLicenseDeniedUserMessage.BuildDialogBodyForPlusCatalog(title)
};
await MessageBoxBase.Show(
body,
ContentLicenseDeniedUserMessage.DialogCaption,
@@ -108,6 +108,7 @@ public static class StatusImageGenerator
var lamp = new SKPath();
lamp.AddRect(SKRect.Create(LiberateIconGeometry.LampLeft, lampTop, LiberateIconGeometry.LampWidth, LiberateIconGeometry.LampHeight));
//Sitting flush with the top edge keeps the badge out of the stoplight's height, so a Plus
//title's stoplight is drawn at exactly the same size as a purchased one's.
var badgeRadius = LiberateIconGeometry.PlusBadgeDiameter / 2;
@@ -21,6 +21,8 @@ public partial class AccountsDialog : Form
private const string COL_Locale = nameof(Locale);
private const string COL_Marketplaces = nameof(Marketplaces);
private bool _isDirty;
public AccountsDialog()
{
InitializeComponent();
@@ -29,6 +31,7 @@ public partial class AccountsDialog : Form
dataGridView1.CellValueChanged += DataGridView1_CellValueChanged;
dataGridView1.CurrentCellDirtyStateChanged += DataGridView1_CurrentCellDirtyStateChanged;
dataGridView1.UserAddedRow += DataGridView1_UserAddedRow;
populateDropDown();
@@ -36,6 +39,31 @@ public partial class AccountsDialog : Form
this.SetLibationIcon();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
// commit in-progress edits so CellValueChanged can mark dirty before we decide
if (dataGridView1.IsCurrentCellInEditMode)
dataGridView1.EndEdit();
if (dataGridView1.IsCurrentCellDirty)
dataGridView1.CommitEdit(DataGridViewDataErrorContexts.Commit);
if (_isDirty && DialogResult != DialogResult.OK)
{
var result = MessageBox.Show(
this,
"You have unsaved changes. Close without saving?",
"Unsaved Changes",
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button2);
if (result == DialogResult.No)
e.Cancel = true;
}
base.OnFormClosing(e);
}
private void populateDropDown()
=> (dataGridView1.Columns[COL_Locale] as DataGridViewComboBoxColumn)?.DataSource
= Localization.Locales
@@ -90,6 +118,7 @@ public partial class AccountsDialog : Form
{
if (e.RowIndex < 0 || e.ColumnIndex < 0)
return;
_isDirty = true;
var colName = dataGridView1.Columns[e.ColumnIndex].Name;
if (colName is COL_AccountId or COL_Locale)
UpdateExportCellState(dataGridView1.Rows[e.RowIndex]);
@@ -100,10 +129,14 @@ public partial class AccountsDialog : Form
if (!dataGridView1.IsCurrentCellDirty || dataGridView1.CurrentCell is null)
return;
var colName = dataGridView1.Columns[dataGridView1.CurrentCell.ColumnIndex].Name;
if (colName == COL_Locale)
// combo and checkbox do not commit until leave-cell unless we force it here
if (colName is COL_Locale or COL_LibraryScan)
dataGridView1.CommitEdit(DataGridViewDataErrorContexts.Commit);
}
private void DataGridView1_UserAddedRow(object? sender, DataGridViewRowEventArgs e)
=> _isDirty = true;
private static bool AccountRowCanExport(string? accountId, string? localeName)
{
if (string.IsNullOrWhiteSpace(accountId) || string.IsNullOrWhiteSpace(localeName))
@@ -150,7 +183,10 @@ public partial class AccountsDialog : Form
case COL_Delete:
// if final/edit row: do nothing
if (e.RowIndex < dgv.RowCount - 1)
{
dgv.Rows.Remove(row);
_isDirty = true;
}
break;
case COL_Export:
// if final/edit row: do nothing
@@ -222,6 +258,7 @@ public partial class AccountsDialog : Form
var selected = dialog.SelectedAdditionalLocaleNames.ToList();
row.Tag = selected;
row.Cells[COL_Marketplaces].Value = MarketplacesUi.ButtonText(selected.Count + 1);
_isDirty = true;
}
private void saveBtn_Click(object sender, EventArgs e)
@@ -416,7 +453,10 @@ public partial class AccountsDialog : Form
}
if (importResult.Account is { } account)
{
AddAccountToGrid(account);
_isDirty = true;
}
}
catch (Exception ex)
{
@@ -1,5 +1,6 @@
using AudibleApi;
using Dinah.Core;
using LibationFileManager;
using LibationUiBase;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
@@ -53,7 +54,7 @@ public partial class WebLoginDialog : Form
options.IsInPrivateModeEnabled = true;
await webView.EnsureCoreWebView2Async(env, options);
webView.CoreWebView2.Settings.UserAgent = Resources.User_Agent;
webView.CoreWebView2.Settings.UserAgent = Configuration.Instance.GetDeviceRegistrationProfile().UserAgent;
// Load init cookies
foreach (System.Net.Cookie cookie in choiceIn.SignInCookies ?? [])
@@ -1,18 +0,0 @@
using AudibleApi;
using System.Threading.Tasks;
namespace LibationWinForms.Login;
public class WinformLoginCallback : 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();
}
@@ -1,5 +1,6 @@
using AudibleApi;
using AudibleUtilities;
using Dinah.Core;
using LibationFileManager;
using LibationUiBase;
using LibationWinForms.Dialogs.Login;
@@ -11,20 +12,18 @@ namespace LibationWinForms.Login;
public class WinformLoginChoiceEager : ILoginChoiceEager
{
public ILoginCallback LoginCallback { get; } = new WinformLoginCallback();
private Account _account { get; }
private Control Owner { get; }
public WinformLoginChoiceEager(Account account, Control owner)
{
_account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
Owner = Dinah.Core.ArgumentValidator.EnsureNotNull(owner, nameof(owner));
_account = ArgumentValidator.EnsureNotNull(account, nameof(account));
Owner = ArgumentValidator.EnsureNotNull(owner, nameof(owner));
}
public Task<ChoiceOut?> StartAsync(ChoiceIn choiceIn)
public Task<string?> StartAsync(ChoiceIn choiceIn)
=> Owner.Invoke(() => StartAsyncInternal(choiceIn));
private Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn)
private Task<string?> StartAsyncInternal(ChoiceIn choiceIn)
{
if (Configuration.Instance.UseWebView && Environment.OSVersion.Version.Major >= 10)
{
@@ -35,7 +34,7 @@ public class WinformLoginChoiceEager : ILoginChoiceEager
{
using var weblogin = new WebLoginDialog(_account.AccountId, choiceIn);
if (ShowDialog(weblogin))
return Task.FromResult(ChoiceOut.External(weblogin.ResponseUrl));
return Task.FromResult(weblogin.ResponseUrl);
}
}
catch (Exception ex)
@@ -49,7 +48,7 @@ public class WinformLoginChoiceEager : ILoginChoiceEager
using var externalDialog = new LoginExternalDialog(_account, choiceIn.LoginUrl);
return Task.FromResult(
ShowDialog(externalDialog)
? ChoiceOut.External(externalDialog.ResponseUrl)
? externalDialog.ResponseUrl
: null);
}
+32 -8
View File
@@ -99,6 +99,8 @@
autoScanCb = new System.Windows.Forms.CheckBox();
showImportedStatsCb = new System.Windows.Forms.CheckBox();
useWebViewCb = new System.Windows.Forms.CheckBox();
deviceRegistrationLbl = new System.Windows.Forms.Label();
deviceRegistrationCb = new System.Windows.Forms.ComboBox();
tab3DownloadDecrypt = new System.Windows.Forms.TabPage();
saveMetadataToFileCbox = new System.Windows.Forms.CheckBox();
useCoverAsFolderIconCb = new System.Windows.Forms.CheckBox();
@@ -244,20 +246,20 @@
// importEpisodesCb
//
importEpisodesCb.AutoSize = true;
importEpisodesCb.Location = new System.Drawing.Point(6, 81);
importEpisodesCb.Location = new System.Drawing.Point(6, 132);
importEpisodesCb.Name = "importEpisodesCb";
importEpisodesCb.Size = new System.Drawing.Size(146, 19);
importEpisodesCb.TabIndex = 4;
importEpisodesCb.TabIndex = 6;
importEpisodesCb.Text = "[import episodes desc]";
importEpisodesCb.UseVisualStyleBackColor = true;
//
// downloadEpisodesCb
//
downloadEpisodesCb.AutoSize = true;
downloadEpisodesCb.Location = new System.Drawing.Point(6, 131);
downloadEpisodesCb.Location = new System.Drawing.Point(6, 182);
downloadEpisodesCb.Name = "downloadEpisodesCb";
downloadEpisodesCb.Size = new System.Drawing.Size(163, 19);
downloadEpisodesCb.TabIndex = 6;
downloadEpisodesCb.TabIndex = 8;
downloadEpisodesCb.Text = "[download episodes desc]";
downloadEpisodesCb.UseVisualStyleBackColor = true;
//
@@ -709,6 +711,8 @@
tab2ImportLibrary.Controls.Add(autoScanCb);
tab2ImportLibrary.Controls.Add(showImportedStatsCb);
tab2ImportLibrary.Controls.Add(useWebViewCb);
tab2ImportLibrary.Controls.Add(deviceRegistrationLbl);
tab2ImportLibrary.Controls.Add(deviceRegistrationCb);
tab2ImportLibrary.Controls.Add(importEpisodesCb);
tab2ImportLibrary.Controls.Add(downloadEpisodesCb);
tab2ImportLibrary.Location = new System.Drawing.Point(4, 24);
@@ -721,20 +725,20 @@
// importPlusTitlesCb
//
importPlusTitlesCb.AutoSize = true;
importPlusTitlesCb.Location = new System.Drawing.Point(6, 106);
importPlusTitlesCb.Location = new System.Drawing.Point(6, 157);
importPlusTitlesCb.Name = "importPlusTitlesCb";
importPlusTitlesCb.Size = new System.Drawing.Size(199, 19);
importPlusTitlesCb.TabIndex = 5;
importPlusTitlesCb.TabIndex = 7;
importPlusTitlesCb.Text = "[import audible plus books desc]";
importPlusTitlesCb.UseVisualStyleBackColor = true;
//
// autoDownloadEpisodesCb
//
autoDownloadEpisodesCb.AutoSize = true;
autoDownloadEpisodesCb.Location = new System.Drawing.Point(6, 156);
autoDownloadEpisodesCb.Location = new System.Drawing.Point(6, 207);
autoDownloadEpisodesCb.Name = "autoDownloadEpisodesCb";
autoDownloadEpisodesCb.Size = new System.Drawing.Size(190, 19);
autoDownloadEpisodesCb.TabIndex = 7;
autoDownloadEpisodesCb.TabIndex = 9;
autoDownloadEpisodesCb.Text = "[auto download episodes desc]";
autoDownloadEpisodesCb.UseVisualStyleBackColor = true;
//
@@ -768,6 +772,24 @@
useWebViewCb.Text = "[use webview desc]";
useWebViewCb.UseVisualStyleBackColor = true;
//
// deviceRegistrationLbl
//
deviceRegistrationLbl.AutoSize = true;
deviceRegistrationLbl.Location = new System.Drawing.Point(6, 81);
deviceRegistrationLbl.Name = "deviceRegistrationLbl";
deviceRegistrationLbl.Size = new System.Drawing.Size(200, 15);
deviceRegistrationLbl.TabIndex = 4;
deviceRegistrationLbl.Text = "[device registration desc]";
//
// deviceRegistrationCb
//
deviceRegistrationCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
deviceRegistrationCb.FormattingEnabled = true;
deviceRegistrationCb.Location = new System.Drawing.Point(6, 100);
deviceRegistrationCb.Name = "deviceRegistrationCb";
deviceRegistrationCb.Size = new System.Drawing.Size(520, 23);
deviceRegistrationCb.TabIndex = 5;
//
// dailyDownloadLimitGb
//
dailyDownloadLimitGb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
@@ -1938,6 +1960,8 @@
private System.Windows.Forms.CheckBox createCueSheetCbox;
private System.Windows.Forms.CheckBox autoScanCb;
private System.Windows.Forms.CheckBox useWebViewCb;
private System.Windows.Forms.Label deviceRegistrationLbl;
private System.Windows.Forms.ComboBox deviceRegistrationCb;
private System.Windows.Forms.CheckBox checkForUpgradesCbox;
private System.Windows.Forms.CheckBox downloadCoverArtCbox;
private System.Windows.Forms.CheckBox autoDownloadEpisodesCb;
@@ -1,4 +1,7 @@
using LibationFileManager;
using AudibleApi;
using LibationFileManager;
using LibationUiBase;
using System.Linq;
namespace LibationWinForms.Dialogs;
@@ -9,6 +12,7 @@ public partial class SettingsDialog
this.autoScanCb.Text = desc(nameof(config.AutoScan));
this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats));
this.useWebViewCb.Text = desc(nameof(config.UseWebView));
this.deviceRegistrationLbl.Text = DeviceRegistrationSettingsUi.SettingLabel;
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
this.importPlusTitlesCb.Text = desc(nameof(config.ImportPlusTitles));
toolTip.SetToolTip(importPlusTitlesCb, Configuration.ImportPlusTitlesToolTip);
@@ -22,6 +26,12 @@ public partial class SettingsDialog
importPlusTitlesCb.Checked = config.ImportPlusTitles;
downloadEpisodesCb.Checked = config.DownloadEpisodes;
autoDownloadEpisodesCb.Checked = config.AutoDownloadEpisodes;
deviceRegistrationCb.Items.Clear();
deviceRegistrationCb.Items.AddRange(DeviceRegistrationSettingsUi.Options.Cast<object>().ToArray());
deviceRegistrationCb.SelectedItem = DeviceRegistrationSettingsUi.Display(config.DeviceRegistrationKind);
toolTip.SetToolTip(deviceRegistrationLbl, Configuration.GetHelpText(nameof(config.DeviceRegistrationKind)));
toolTip.SetToolTip(deviceRegistrationCb, DeviceRegistrationSettingsUi.ReLoginNote);
}
private void Save_ImportLibrary(Configuration config)
{
@@ -32,5 +42,7 @@ public partial class SettingsDialog
config.DownloadEpisodes = downloadEpisodesCb.Checked;
config.AutoDownloadEpisodes = autoDownloadEpisodesCb.Checked;
config.UseWebView = useWebViewCb.Checked;
config.DeviceRegistrationKind = (deviceRegistrationCb.SelectedItem as EnumDisplay<DeviceRegistrationKind>)?.Value
?? DeviceRegistrationKind.CurrentAndroid;
}
}
@@ -41,8 +41,8 @@
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="10.2.6.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="11.0.0.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4191.47" />
</ItemGroup>
<ItemGroup>
@@ -75,6 +75,10 @@
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
@@ -41,4 +41,8 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -54,4 +54,8 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -1,15 +1,15 @@
using SixLabors.ImageSharp;
using System.IO;
using System.IO;
using SkiaSharp;
namespace WindowsConfigApp;
internal static partial class FolderIcon
{
static readonly IcoEncoder IcoEncoder = new();
public static byte[] ToIcon(this Image img)
public static byte[] ToIcon(this SKBitmap img)
{
using var ms = new MemoryStream();
img.Save(ms, IcoEncoder);
IcoEncoder.Encode(img, ms);
return ms.ToArray();
}
+10 -16
View File
@@ -1,18 +1,12 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SkiaSharp;
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace WindowsConfigApp;
public class IcoEncoder : IImageEncoder
public class IcoEncoder
{
public bool SkipMetadata { get; init; } = true;
public ReadOnlyCollection<int> ExportSizes { get; }
public IcoEncoder() : this(512, 256, 128, 96, 64, 48, 32, 24) { }
public IcoEncoder(params int[] icoSizes)
@@ -21,20 +15,23 @@ public class IcoEncoder : IImageEncoder
ExportSizes = new(icoSizes);
}
public void Encode<TPixel>(Image<TPixel> image, Stream stream) where TPixel : unmanaged, IPixel<TPixel>
public void Encode(SKBitmap image, Stream stream)
{
// https://stackoverflow.com/a/21389253
//Knowing the image size ahead of time removes the
//requirement of the output stream to support seeking.
byte[][] iconPngs = new byte[ExportSizes.Count][];
var samplingOptions = new SKSamplingOptions(SKCubicResampler.CatmullRom);
for (int i = 0; i < ExportSizes.Count; i++)
{
int size = ExportSizes[i];
using var resized = image.Clone(x => x.Resize(size, size, KnownResamplers.Lanczos2));
using var pngMs = new MemoryStream();
resized.SaveAsPng(pngMs);
iconPngs[i] = pngMs.ToArray();
var imageInfo = new SKImageInfo(size, size);
using var resized = image.Resize(imageInfo, samplingOptions);
using var skImage = SKImage.FromBitmap(resized);
using var data = skImage.Encode(SKEncodedImageFormat.Png, 100);
iconPngs[i] = data.ToArray();
}
//Disposing of the BinaryWriter disposes the soutput stream. Let the caller clean up.
@@ -65,7 +62,4 @@ public class IcoEncoder : IImageEncoder
for (int i = 0; i < ExportSizes.Count; i++)
bw.Write(iconPngs[i]);
}
public Task EncodeAsync<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel<TPixel>
=> throw new NotImplementedException();
}
@@ -1,6 +1,5 @@
using Dinah.Core;
using LibationFileManager;
using SixLabors.ImageSharp;
using System;
using System.Diagnostics;
using System.IO;
@@ -14,15 +13,15 @@ internal class WinInterop : IInteropFunctions
public WinInterop(params object[] values) { }
public void SetFolderIcon(string image, string directory)
{
using var img = Image.Load(image);
var icon = img.ToIcon();
using var bmp = SkiaSharp.SKBitmap.Decode(image);
var icon = bmp.ToIcon();
new DirectoryInfo(directory)?.SetIcon(icon, "Music");
}
public void SetFolderIcon(byte[] imageJpegBytes, string directory)
{
using var img = Image.Load(new MemoryStream(imageJpegBytes, writable: false));
var icon = img.ToIcon();
using var bmp = SkiaSharp.SKBitmap.Decode(imageJpegBytes);
var icon = bmp.ToIcon();
new DirectoryInfo(directory)?.SetIcon(icon, "Music");
}
@@ -25,10 +25,6 @@
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\LibationUiBase\LibationUiBase.csproj" />
</ItemGroup>
@@ -39,4 +35,8 @@
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -19,7 +19,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="10.2.6.1" />
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -22,4 +22,8 @@
<ProjectReference Include="..\CrossPlatformClientExe\CrossPlatformClientExe.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -24,4 +24,8 @@
<ProjectReference Include="..\CrossPlatformClientExe\CrossPlatformClientExe.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -16,4 +16,8 @@
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -7,7 +7,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest.TestFramework" Version="4.3.3" />
<PackageReference Include="MSTest.TestFramework" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -7,6 +7,9 @@ using AudibleUtilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Serilog;
using Serilog.Core;
using Serilog.Events;
using System;
using System.Collections.Generic;
using System.IO;
@@ -1161,5 +1164,64 @@ public class SerializedShape : AccountsTestBase
JObject.Parse(loaded.ToJson())["Accounts"]![0]!["MaskedLogEntry"].Should().BeNull();
}
}
[TestClass]
public class AccountAddRemoveLogging
{
[TestMethod]
public void Add_and_Delete_write_masked_account_to_the_log()
{
var sink = new CollectingSink();
var original = Serilog.Log.Logger;
Serilog.Log.Logger = new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger();
try
{
var settings = new AccountsSettings();
var account = settings.Upsert("user@example.com", "us");
settings.Delete(account).Should().BeTrue();
var messages = sink.Events.Select(e => e.RenderMessage()).ToList();
Assert.AreEqual(1, messages.Count(m => m.Contains("Added Audible account", StringComparison.Ordinal)));
Assert.AreEqual(1, messages.Count(m => m.Contains("Removed Audible account", StringComparison.Ordinal)));
Assert.IsTrue(messages.All(m => m.Contains(account.MaskedLogEntry, StringComparison.Ordinal)));
Assert.IsFalse(messages.Any(m => m.Contains("user@example.com", StringComparison.Ordinal)));
}
finally
{
Serilog.Log.Logger = original;
}
}
[TestMethod]
public void Loading_accounts_from_json_does_not_log_an_add()
{
var sink = new CollectingSink();
var original = Serilog.Log.Logger;
Serilog.Log.Logger = new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger();
try
{
var settings = new AccountsSettings();
settings.Add(new Account("user@example.com") { IdentityTokens = new Identity(Localization.Get("us")) });
var json = settings.ToJson();
sink.Events.Clear();
_ = AccountsSettings.FromJson(json);
Assert.AreEqual(0, sink.Events.Count);
}
finally
{
Serilog.Log.Logger = original;
}
}
private class CollectingSink : ILogEventSink
{
public List<LogEvent> Events { get; } = [];
public void Emit(LogEvent logEvent) => Events.Add(logEvent);
}
}
#pragma warning restore CS8981
@@ -0,0 +1,201 @@
using AudibleApi.Common;
using AudibleUtilities;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Linq;
namespace ApiExtendedSetSeriesTests;
/// <summary>
/// Podcast series numbers come from Audible's episode_number, then relationship sort/sequence.
/// A missing episode_number is sometimes sent as a huge sentinel integer (issue #2024), which
/// must not be stored as the series order.
/// </summary>
[TestClass]
public class SetSeries
{
private static Relationship childRel(string asin, long? sort = null, string? sequence = null)
=> new()
{
Asin = asin,
RelationshipToProduct = RelationshipToProduct.Child,
RelationshipType = RelationshipType.Episode,
Sort = sort,
Sequence = sequence
};
private static Relationship parentRel(string asin, long? sort = null, string? sequence = null)
=> new()
{
Asin = asin,
RelationshipToProduct = RelationshipToProduct.Parent,
RelationshipType = RelationshipType.Episode,
Sort = sort,
Sequence = sequence
};
private static Item show(string asin, params Relationship[] childRels)
=> new()
{
Asin = asin,
Title = "My Show",
PurchaseDate = new DateTimeOffset(2026, 8, 1, 0, 0, 0, TimeSpan.Zero),
Relationships = childRels
};
private static Item episode(string asin, string parentAsin, int? episodeNumber, long? sort = null, string? sequence = null, string? catalogSequence = null)
=> new()
{
Asin = asin,
Title = $"Episode {asin}",
EpisodeNumber = episodeNumber,
Relationships = [parentRel(parentAsin, sort, sequence)],
Series = catalogSequence is null ? null : [new Series { Asin = parentAsin, Sequence = catalogSequence, Title = "My Show" }]
};
private static string SequenceOf(Item item) => item.Series!.Single().Sequence!;
[TestMethod]
public void a_real_episode_number_is_the_series_order()
{
var parent = show("SHOW", childRel("EP", sort: 99));
var child = episode("EP", "SHOW", episodeNumber: 406, sort: 99);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_parent_relationship_sort()
{
var parent = show("SHOW", childRel("EP", sort: 406));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_child_relationship_sort()
{
var parent = show("SHOW", childRel("EP"));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue, sort: 406);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_relationship_sequence()
{
var parent = show("SHOW", childRel("EP", sort: int.MaxValue, sequence: "406"));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_episode_number_falls_back_to_catalog_series_sequence()
{
var parent = show("SHOW", childRel("EP", sort: int.MaxValue));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue, sort: int.MaxValue, catalogSequence: "406");
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void integer_max_value_with_no_fallback_is_zero_not_the_sentinel()
{
var parent = show("SHOW", childRel("EP"));
var child = episode("EP", "SHOW", episodeNumber: int.MaxValue);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("0", SequenceOf(child));
}
[TestMethod]
public void null_episode_number_still_uses_parent_sort()
{
var parent = show("SHOW", childRel("EP", sort: 7));
var child = episode("EP", "SHOW", episodeNumber: null);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("7", SequenceOf(child));
}
[TestMethod]
public void a_real_episode_number_wins_over_a_different_sort()
{
var parent = show("SHOW", childRel("EP", sort: 1));
var child = episode("EP", "SHOW", episodeNumber: 5, sort: 1);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("5", SequenceOf(child));
}
[TestMethod]
public void multipart_episodes_with_the_same_number_keep_an_offset()
{
var parent = show("SHOW", childRel("A", sort: 3), childRel("B", sort: 3));
var a = episode("A", "SHOW", episodeNumber: 3);
var b = episode("B", "SHOW", episodeNumber: 3);
ApiExtended.SetSeries(parent, [a, b]);
CollectionAssert.AreEquivalent(new[] { "3", "4" }, new[] { SequenceOf(a), SequenceOf(b) });
}
[TestMethod]
public void a_yyyymmdd_episode_number_is_kept()
{
var parent = show("SHOW", childRel("EP", sort: 1));
var child = episode("EP", "SHOW", episodeNumber: 20260903, sort: 1);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("20260903", SequenceOf(child));
}
[TestMethod]
public void a_unix_timestamp_sort_is_not_used_as_the_series_order()
{
var parent = show("SHOW", childRel("EP", sort: 1_725_400_800));
var child = episode("EP", "SHOW", episodeNumber: null, sequence: "406");
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
[TestMethod]
public void a_nine_digit_episode_number_is_kept()
{
var parent = show("SHOW", childRel("EP"));
var child = episode("EP", "SHOW", episodeNumber: 999_999_999);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("999999999", SequenceOf(child));
}
[TestMethod]
public void a_ten_digit_episode_number_falls_back()
{
var parent = show("SHOW", childRel("EP", sort: 406));
var child = episode("EP", "SHOW", episodeNumber: 1_000_000_000);
ApiExtended.SetSeries(parent, [child]);
Assert.AreEqual("406", SequenceOf(child));
}
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -16,4 +16,8 @@
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -1,7 +1,6 @@
using AudibleApi;
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using AudibleUtilities;
using Dinah.Core.Security;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -163,4 +163,32 @@ public class DownloadFailureClassifierTests
[TestMethod]
public void Classifying_null_is_harmless()
=> Assert.IsNull(DownloadFailureClassifier.Classify(null));
[TestMethod]
public void CustomerThrottled_mixed_with_eligibility_is_still_a_license_denial()
{
// From Log202609: Ownership named CustomerThrottled while Membership/Client/AYCL still failed eligibility.
var ex = Denied(
("Membership", RejectionReason.RequesterEligibility, "Customer is not part of any plans"),
("Ownership", RejectionReason.CustomerThrottled, "Customer id [##############] being throttled"),
("Client", RejectionReason.RequesterEligibility, "does not has access to asin[B005EGKBYK]."),
("AYCL", RejectionReason.ContentEligibility, "Asin: [B005EGKBYK] is not eligible for AYCL"));
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.LicenseDenied, diagnosis.Kind);
Assert.IsTrue(ex.IsCustomerThrottled);
StringAssert.Contains(diagnosis.Reason, "throttled");
}
[TestMethod]
public void An_eligibility_refusal_without_CustomerThrottled_is_not_throttling()
{
var ex = Denied(
("Ownership", RejectionReason.RequesterEligibility, "not owned"),
("AYCL", RejectionReason.ContentEligibility, "Asin is not eligible for AYCL"));
Assert.IsTrue(DownloadFailureClassifier.TryClassify(ex, out var diagnosis));
Assert.AreEqual(DownloadFailureKind.LicenseDenied, diagnosis.Kind);
Assert.IsFalse(ex.IsCustomerThrottled);
}
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -17,4 +17,8 @@
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,24 @@
using AssertionHelper;
using DataLayer;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace FileLiberator.Tests;
/// <summary>
/// A download must speak to the marketplace the book was scanned from, not only the one the account logged
/// into. Issue #2020: a Germany-registered account with UK ticked as an extra marketplace scanned UK titles
/// correctly, then asked <c>api.audible.de</c> for their licenses and got NotFound.
/// </summary>
[TestClass]
public class GetApiStoreLocaleTests
{
[TestMethod]
public void A_book_from_an_extra_marketplace_is_licensed_there_not_at_the_home_store()
=> MockLibraryBook.CreateBook(localeName: "uk", title: "Kill Box")
.StoreLocale().Name.Should().Be("uk");
[TestMethod]
public void A_book_from_the_registered_marketplace_is_still_licensed_there()
=> MockLibraryBook.CreateBook(localeName: "germany", title: "Home Store Title")
.StoreLocale().Name.Should().Be("germany");
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -16,4 +16,8 @@
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,60 @@
using AudibleApi;
using AudibleApi.Common;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Linq;
namespace LibationCli.Tests;
[TestClass]
public class ContentLicenseDeniedCliSummaryTests
{
[TestInitialize]
public void Initialize() => Configuration.CreateMockInstance();
[TestCleanup]
public void Cleanup() => Configuration.RestoreSingletonInstance();
private static ContentLicenseDeniedException Denied(params (string ValidationType, string RejectionReason, string Message)[] reasons)
=> new(
new Uri("https://api.audible.com/1.0/content/B005EGKBYK/licenserequest"),
new ContentLicense
{
Asin = "B005EGKBYK",
StatusCode = "Denied",
LicenseDenialReasons = [.. Array.ConvertAll(reasons, r => new LicenseDenialReason
{
ValidationType = r.ValidationType,
RejectionReason = r.RejectionReason,
Message = r.Message
})]
});
[TestMethod]
public void A_throttled_denial_leads_with_throttling_guidance()
{
var ex = Denied(
("Ownership", RejectionReason.CustomerThrottled, "Customer id [##############] being throttled"),
("AYCL", RejectionReason.ContentEligibility, "Asin: [B005EGKBYK] is not eligible for AYCL"));
var lines = ContentLicenseDeniedCliSummary.Lines(ex).ToList();
StringAssert.Contains(lines[0], "throttled");
StringAssert.Contains(lines[0], "24 to 48 hours");
Assert.IsTrue(lines.Any(l => l.Contains("device-registration", StringComparison.Ordinal)));
Assert.IsTrue(lines.Any(l => l.Contains("audible-cli", StringComparison.Ordinal)));
Assert.IsTrue(lines.Any(l => l.StartsWith("Ownership:", StringComparison.Ordinal)));
}
[TestMethod]
public void An_eligibility_denial_keeps_the_generic_opener()
{
var ex = Denied(("Ownership", RejectionReason.RequesterEligibility, "not owned"));
var lines = ContentLicenseDeniedCliSummary.Lines(ex).ToList();
StringAssert.Contains(lines[0], "download not allowed");
Assert.IsFalse(lines[0].Contains("throttled", StringComparison.Ordinal));
}
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -16,4 +16,8 @@
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
using AudibleApi;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace LibationCli.Tests;
[TestClass]
[DoNotParallelize]
public class LoginExternalOptionsTests
{
[TestInitialize]
public void Initialize() => Configuration.CreateMockInstance();
[TestCleanup]
public void Cleanup() => Configuration.RestoreSingletonInstance();
[TestMethod]
public void Omitted_flag_uses_the_Settings_value()
{
Configuration.Instance.DeviceRegistrationKind = DeviceRegistrationKind.RetailAndroid;
var options = new LoginExternalOptions();
Assert.IsTrue(options.TryResolveRegistrationProfile(out var profile, out var error));
Assert.AreEqual("", error);
Assert.AreEqual(DeviceRegistrationKind.CurrentAndroid, profile.Kind);
}
[TestMethod]
public void Flag_overrides_Settings()
{
Configuration.Instance.DeviceRegistrationKind = DeviceRegistrationKind.CurrentAndroid;
var options = new LoginExternalOptions { DeviceRegistration = "Mkb79IPhone" };
Assert.IsTrue(options.TryResolveRegistrationProfile(out var profile, out var error));
Assert.AreEqual("", error);
Assert.AreEqual(DeviceRegistrationKind.Mkb79IPhone, profile.Kind);
}
[TestMethod]
public void Unknown_flag_fails()
{
var options = new LoginExternalOptions { DeviceRegistration = "WindowsPhone" };
Assert.IsFalse(options.TryResolveRegistrationProfile(out _, out var error));
StringAssert.Contains(error, "WindowsPhone");
StringAssert.Contains(error, "CurrentAndroid");
}
}
@@ -0,0 +1,65 @@
using AssertionHelper;
using AudibleApi;
using FileManager;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
using System;
using System.Linq;
namespace DeviceRegistrationKindConfigurationTests;
[TestClass]
[DoNotParallelize]
public class DeviceRegistrationKindConfigurationTests
{
[TestCleanup]
public void Cleanup()
{
Configuration.RestoreSingletonInstance();
}
[TestMethod]
public void Default_when_missing_is_CurrentAndroid()
{
var config = Configuration.CreateMockInstance();
config.Exists(nameof(Configuration.DeviceRegistrationKind)).Should().BeFalse();
Assert.AreEqual(DeviceRegistrationKind.CurrentAndroid, config.DeviceRegistrationKind);
Assert.AreEqual(DeviceRegistrationKind.CurrentAndroid, config.GetDeviceRegistrationProfile().Kind);
}
[TestMethod]
public void Round_trips_each_kind()
{
var config = Configuration.CreateMockInstance();
//Exclude RetailAndroid from this test because it is not a valid option for the setting
foreach (var kind in Enum.GetValues<DeviceRegistrationKind>().Where(p => p is not DeviceRegistrationKind.RetailAndroid))
{
config.DeviceRegistrationKind = kind;
Assert.AreEqual(kind, config.DeviceRegistrationKind);
Assert.AreEqual(kind, config.CreateEphemeralCopy().DeviceRegistrationKind);
Assert.AreEqual(kind, config.GetDeviceRegistrationProfile().Kind);
}
}
[TestMethod]
public void Unknown_enum_value_throws_InvalidConfigurationValueException()
{
var ex = Assert.ThrowsExactly<InvalidConfigurationValueException>(
() => IJsonBackedDictionary.UpCast<DeviceRegistrationKind>(new JValue("NotARealProfile"), nameof(Configuration.DeviceRegistrationKind)));
StringAssert.Contains(ex.Message, "DeviceRegistrationKind");
StringAssert.Contains(ex.Message, "NotARealProfile");
StringAssert.Contains(ex.Message, "CurrentAndroid");
}
[TestMethod]
public void ValidateEnumSettings_throws_for_invalid_DeviceRegistrationKind()
{
var config = Configuration.CreateMockInstance();
config.SetNonString("NotARealProfile", nameof(Configuration.DeviceRegistrationKind));
Assert.ThrowsExactly<InvalidConfigurationValueException>(config.ValidateEnumSettings);
}
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -16,4 +16,8 @@
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,31 @@
using AssertionHelper;
using LibationFileManager.Templates;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Globalization;
namespace SeriesOrderTests;
/// <summary>
/// Unformatted series numbers must keep the original digits. Parsing them as float used to
/// print 2147483647 as 2.1474836E+09 and collide different values (issue #2024).
/// </summary>
[TestClass]
public class Parse
{
[TestMethod]
[DataRow("1", "1")]
[DataRow("406", "406")]
[DataRow("1-6", "1-6")]
[DataRow("2147483647", "2147483647")]
[DataRow(" 1 6 ", "1 6")]
public void unformatted_keeps_the_original_digits(string order, string expected)
=> SeriesOrder.Parse(order).ToString().Should().Be(expected);
[TestMethod]
public void a_numeric_format_still_applies_to_each_number_part()
=> SeriesOrder.Parse("1-6").ToString("F2", CultureInfo.InvariantCulture).Should().Be("1.00-6.00");
[TestMethod]
public void a_numeric_format_does_not_round_a_large_integer()
=> SeriesOrder.Parse("2147483647").ToString("F0", CultureInfo.InvariantCulture).Should().Be("2147483647");
}
@@ -770,6 +770,8 @@ namespace TemplatesTests
[DataRow("<series#[]>", "1", "1")]
[DataRow("<series#>", "1", "1")]
[DataRow("<series#>", " 1 6 ", "1 6")]
[DataRow("<series#>", "2147483647", "2147483647")]
[DataRow("<series#[F0]>", "2147483647", "2147483647")]
public void SeriesOrder_formatters(string template, string seriesOrder, string expected)
{
var bookDto = GetLibraryBook();
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -16,4 +16,8 @@
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -0,0 +1,56 @@
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace LibationUiBase.Tests;
[TestClass]
public class ContentLicenseDeniedUserMessageTests
{
[TestInitialize]
public void Initialize() => Configuration.CreateMockInstance();
[TestCleanup]
public void Cleanup() => Configuration.RestoreSingletonInstance();
[TestMethod]
public void The_throttling_dialog_names_Audible_throttling_and_says_to_wait()
{
var body = ContentLicenseDeniedUserMessage.BuildDialogBodyForThrottling("Monster Hunter Alpha");
StringAssert.Contains(body, "Monster Hunter Alpha");
StringAssert.Contains(body, "throttled");
StringAssert.Contains(body, "24 to 48 hours");
StringAssert.Contains(body, "not a Libation bug");
StringAssert.Contains(body, "experimental device registration");
StringAssert.Contains(body, "audible-cli");
AssertSuggestsRemoveSaveReAdd(body);
}
[TestMethod]
public void The_outage_dialog_still_talks_about_a_service_interruption()
{
var body = ContentLicenseDeniedUserMessage.BuildDialogBodyForPossibleOutage("Monster Hunter Alpha");
StringAssert.Contains(body, "temporary interruption of service");
Assert.IsFalse(body.Contains("account is being throttled", StringComparison.Ordinal));
AssertSuggestsRemoveSaveReAdd(body);
}
[TestMethod]
public void The_Plus_dialog_still_names_the_Plus_catalog()
{
var body = ContentLicenseDeniedUserMessage.BuildDialogBodyForPlusCatalog("Monster Hunter Alpha");
StringAssert.Contains(body, "Audible Plus catalog");
Assert.IsFalse(body.Contains("account is being throttled", StringComparison.Ordinal));
AssertSuggestsRemoveSaveReAdd(body);
}
private static void AssertSuggestsRemoveSaveReAdd(string body)
{
StringAssert.Contains(body, "remove the account", StringComparison.OrdinalIgnoreCase);
StringAssert.Contains(body, "save or close the Accounts dialog");
StringAssert.Contains(body, "re-add the account");
}
}
@@ -0,0 +1,35 @@
using AudibleApi;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace LibationUiBase.Tests;
[TestClass]
public class DeviceRegistrationSettingsUiTests
{
[TestMethod]
public void Options_cover_every_DeviceRegistrationKind()
{
var kinds = DeviceRegistrationSettingsUi.Options.Select(o => o.Value).ToArray();
// RetailAndroid is not a valid option for the setting, so it is excluded from the assertion.
CollectionAssert.AreEquivalent(Enum.GetValues<DeviceRegistrationKind>().Where(p => p is not DeviceRegistrationKind.RetailAndroid).ToArray(), kinds);
}
[TestMethod]
public void Display_falls_back_to_CurrentAndroid()
{
Assert.AreEqual(DeviceRegistrationKind.CurrentAndroid, DeviceRegistrationSettingsUi.Display((DeviceRegistrationKind)99).Value);
}
[TestMethod]
public void Throttling_workaround_names_experimental_relogin_and_audible_cli()
{
StringAssert.Contains(DeviceRegistrationSettingsUi.ThrottlingWorkaround, "experimental device registration");
StringAssert.Contains(DeviceRegistrationSettingsUi.ThrottlingWorkaround, "audible-cli");
StringAssert.Contains(DeviceRegistrationSettingsUi.ThrottlingWorkaround, "save or close the Accounts dialog");
StringAssert.Contains(DeviceRegistrationSettingsUi.ReLoginNote, "does not convert existing accounts");
StringAssert.Contains(DeviceRegistrationSettingsUi.ReLoginNote, "save or close the Accounts dialog");
StringAssert.Contains(DeviceRegistrationSettingsUi.RemoveSaveReAddAccountSteps, "Remove the account");
StringAssert.Contains(DeviceRegistrationSettingsUi.RemoveSaveReAddAccountSteps, "save or close the Accounts dialog");
StringAssert.Contains(DeviceRegistrationSettingsUi.RemoveSaveReAddAccountSteps, "re-add the account");
}
}
@@ -9,7 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
<PackageReference Include="Google.Protobuf" Version="3.36.1" />
<!-- The Liberate icon tests rasterize with SkiaSharp, whose Linux native isn't
brought in by the SkiaSharp package itself the way Windows' and macOS' are. -->
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" Condition="$([MSBuild]::IsOSPlatform('Linux'))" />
@@ -19,4 +20,8 @@
<ProjectReference Include="..\..\LibationUiBase\LibationUiBase.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
</ItemGroup>
</Project>
@@ -59,6 +59,7 @@ public class TokenStorageSettingsUiTests
{
StringAssert.Contains(TokenStorageSettingsUi.ExportConfirmBody, "password");
StringAssert.Contains(TokenStorageSettingsUi.ExportButtonToolTip, "Docker");
Assert.AreEqual("Export encryption key...", TokenStorageSettingsUi.ExportButtonText);
var temp = TokenStorageSettingsUi.ExportButtonText;
Assert.AreEqual("Export encryption key...", temp);
}
}
+2
View File
@@ -14,6 +14,8 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
- Check for new Libation versions at startup. Enabled by default: each time Libation starts it asks GitHub whether a newer release exists, and offers it to you if there is one. Turn it off if something else keeps Libation up to date, such as a package manager or an AppImage updater. Turning it off only stops the automatic check - Settings > About still has a "Check for Upgrade" button that works either way.
- Device registration (experimental). Which virtual device Libation registers with Amazon when you sign in. The corrected Android registration is the default and supports Widevine; the iPhone/audible-cli alternative does not. Changing it or updating Libation does not convert existing accounts, so affected accounts must be removed and re-added. See [Device registration](./device-registration.md).
- Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
+6
View File
@@ -115,6 +115,12 @@ libationcli login-external -a you@example.com -l us --response-url "https://www.
If the account row already has valid saved tokens, the CLI reports that no browser login is needed and exits without opening the flow.
Optional `--device-registration` picks which virtual device to register as on a **new** sign-in: `CurrentAndroid` (the corrected default) or `Mkb79IPhone` (experimental; no Widevine). It does nothing to an account that is already authenticated; remove the account first. See [Device registration](/docs/advanced/device-registration).
```console
libationcli login-external --account you@example.com --locale us --device-registration Mkb79IPhone
```
Use `libationcli login-external --help` for the exact options on your build.
## List configured accounts (`list-accounts`)
+49
View File
@@ -0,0 +1,49 @@
# Device registration (experimental)
When you sign in, Libation registers a virtual device with Amazon. Audible then ties download licenses to that device. The default is an Android emulator, which is required for [Widevine](/docs/features/audio-file-formats#use-widevine-drm).
Older Libation versions generated an Android device serial that was twice the expected length. Audible began refusing licenses (`License Denied` / `CustomerThrottled`) for some of those registrations even when the same title still played in the official Audible app. Current versions use the corrected Android registration.
Registration data is stored with the account, so updating Libation or changing this setting does **not** repair an account you already signed in. Remove and re-add the affected account (or run `login-external`) to register it again. Try the corrected Android default first. If Audible still refuses licenses, the experimental iPhone/audible-cli profile is available as an alternative; you can also import credentials from [mkb79's audible-cli](https://github.com/mkb79/audible-cli).
## Where to find it
- **Chardonnay:** Settings -> Import library -> **Device registration (experimental)**
- **Classic:** Settings -> Import library -> **Device registration (experimental)**
- **CLI / Docker:** `DeviceRegistrationKind` in `Settings.json`, or `--device-registration` on `login-external`. See [Command Line Interface](/docs/advanced/command-line-interface#log-in-with-an-external-browser-login-external).
## The two profiles
| Setting value | Label in Settings | Widevine | What it registers |
|---------------|-------------------|----------|-------------------|
| `CurrentAndroid` | Android emulator (default) | Yes | The corrected Android Audible app registration |
| `Mkb79IPhone` | iPhone / audible-cli (experimental; no Widevine) | No | The virtual iPhone used by audible-cli |
`RetailAndroid` appeared briefly in Libation 14.1 but is no longer a separate option. Existing `RetailAndroid` values are treated as `CurrentAndroid`.
## How to register an account again
1. Leave **Android emulator (default)** selected unless you specifically want to try the iPhone alternative.
2. Remove the account from Libation. Existing Amazon device records keep the old registration until you sign in again.
3. Add the account and sign in, or run `login-external`.
4. Scan and try the download again.
If the corrected Android registration is still denied, repeat those steps with **iPhone / audible-cli**, or import an audible-cli JSON file with `import-account`. Imported audible-cli credentials already use its iPhone registration, so you do not need to change this setting first.
## Widevine
**Use Widevine DRM** only works when the account was registered with `CurrentAndroid`. The iPhone profile cannot use Widevine. If you need Widevine later, remove the account and sign in again with the Android profile.
## Settings.json (Docker and CLI)
```json
{
"DeviceRegistrationKind": "Mkb79IPhone"
}
```
Supported choices are `CurrentAndroid` and `Mkb79IPhone`. Then remove the account and sign in again. `login-external --device-registration Mkb79IPhone` overrides Settings for that one sign-in. A legacy `RetailAndroid` value behaves as `CurrentAndroid`.
## If it still fails
Wait 24 to 48 hours: Audible also rate-limits heavy Plus use. See [Daily download limit](/docs/features/daily-download-limit) and [Retrying titles Audible refuses](/docs/features/retrying-refused-downloads). If the official app can play the title and a new registration still cannot download it, open a GitHub issue and attach your log.
+1 -1
View File
@@ -62,7 +62,7 @@ Download and decrypt titles while Libation still supports the format Audible del
1. Open **Settings** and enable **Use Widevine DRM**.
2. Enable **Request xHE-AAC Codec**.
3. Re-add your account if Libation prompts you (Widevine requires an Android-style device registration).
3. Re-add your account if Libation prompts you (Widevine requires an Android device registration; the experimental iPhone profile cannot use it). See [Device registration](./device-registration.md).
4. Re-download the title.
See [Audio File Formats](../features/audio-file-formats.md) for codec details and [Supported Media Players](../features/audio-file-formats.md#supported-media-players) if you have trouble playing xHE-AAC.
+4 -3
View File
@@ -264,9 +264,10 @@ Symptoms include a crash on startup that mentions `LibationContext.db` under a p
These errors come from Audible refusing to grant a download license. Common causes:
1. **Temporary Audible outage or Plus throttling** -- wait 24 to 48 hours and try again. See the [FAQ](/docs/frequently-asked-questions).
2. **Title requires Widevine** -- some Plus titles no longer download as AAXC; enable **Use Widevine DRM** in Settings and re-add your account if prompted. See [issue #1580](https://github.com/rmcrackan/Libation/issues/1580).
3. **Spatial / Dolby Atmos requested (older Libation versions)** -- Audible now requires Widevine L1 for many spatial titles. Libation 13.1.3+ no longer offers spatial download. See [Spatial Audio & DRM](/docs/advanced/spatial-audio).
4. **You no longer have rights to the title** -- it was returned, it left the Plus catalog, or the account that owned it is no longer active. Check the title in the Audible app or website.
2. **Old virtual-device registration** -- older Libation versions used an invalid Android device serial length. If the official Audible app can play the title but Libation cannot, remove and re-add the account so it gets the corrected Android registration. If that still fails, try the [experimental iPhone registration](/docs/advanced/device-registration) or import credentials from [audible-cli](https://github.com/mkb79/audible-cli).
3. **Title requires Widevine** -- some Plus titles no longer download as AAXC; enable **Use Widevine DRM** in Settings and re-add your account if prompted. The iPhone registration cannot use Widevine. See [issue #1580](https://github.com/rmcrackan/Libation/issues/1580) and [Device registration](/docs/advanced/device-registration#widevine).
4. **Spatial / Dolby Atmos requested (older Libation versions)** -- Audible now requires Widevine L1 for many spatial titles. Libation 13.1.3+ no longer offers spatial download. See [Spatial Audio & DRM](/docs/advanced/spatial-audio).
5. **You no longer have rights to the title** -- it was returned, it left the Plus catalog, or the account that owned it is no longer active. Check the title in the Audible app or website.
After a refusal Libation waits before asking about that title again, so you see the explanation once rather than on every run. It attempts the title again by itself; to try it sooner, name it (`libationcli liberate <ASIN>`) or mark it **Download Pending** (previously "Not Downloaded"). See [Retrying titles Audible refuses](/docs/features/retrying-refused-downloads).
+1 -1
View File
@@ -18,7 +18,7 @@ Audiobooks can be requested from Audible as "Normal" quality or "High" quality,
### Use Widevine DRM
When this setting is disabled, all audiobooks will be downloaded using Audible's in-house DRM (AAX(C)) in the [AAC-LC](#aac-lc) format.
When this setting is enabled, Libation will request audio files protected by Google's Widevine DRM scheme. This unlocks [Request xHE-AAC Codec](#request-xhe-aac-codec) for higher-quality stereo downloads on titles where Audible delivers them via Widevine L3.
When this setting is enabled, Libation will request audio files protected by Google's Widevine DRM scheme. This unlocks [Request xHE-AAC Codec](#request-xhe-aac-codec) for higher-quality stereo downloads on titles where Audible delivers them via Widevine L3. Widevine requires an Android device registration; the experimental iPhone profile cannot use it. See [Device registration](/docs/advanced/device-registration#widevine).
If you don't enable **Request xHE-AAC Codec**, then enabling **Use Widevine DRM** will have no practical effect in nearly all circumstances. Audiobooks will be downloaded in the same [AAC-LC](#aac-lc) format with the same bitrate and the same number of audio channels. On rare occasions, enabling **Use Widevine DRM** without xHE-AAC will result in audio files with a different bitrate.
+1 -1
View File
@@ -74,4 +74,4 @@ A container that liberates on a schedule combines well with a limit: each run do
## When a license is denied anyway
If Audible refuses a license despite the limit, Libation waits before asking about that title again instead of re-requesting it on every run. See [Retrying titles Audible refuses](/docs/features/retrying-refused-downloads).
If Audible refuses a license despite the limit, Libation waits before asking about that title again instead of re-requesting it on every run. See [Retrying titles Audible refuses](/docs/features/retrying-refused-downloads). If the official Audible app can still play the title, remove and re-add the account to get the corrected Android registration. See [Device registration](/docs/advanced/device-registration) for the experimental iPhone alternative and audible-cli import.
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more