Compare commits

..
16 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
70 changed files with 905 additions and 131 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" },
],
+1 -1
View File
@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>14.0.2</Version>
<Version>14.2.0</Version>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
@@ -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 -7
View File
@@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq;
using Polly;
using Polly.Retry;
using System.Diagnostics;
using System.Globalization;
using System.Threading.Channels;
namespace AudibleUtilities;
@@ -103,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.
@@ -439,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();
}
@@ -472,5 +474,69 @@ public class ApiExtended
};
}
}
/// <summary>
/// Nine digits. Big enough for YYYYMMDD-style numbering; too small for unix timestamps,
/// Integer.MAX_VALUE, and the other sentinel integers Audible has sent as episode order (issue #2024).
/// </summary>
private const long MaxPlausibleSeriesOrder = 1_000_000_000;
private static bool IsPlausibleSeriesOrder(long n)
=> n >= 0 && n < MaxPlausibleSeriesOrder;
/// <summary>
/// Audible sometimes serializes a missing episode_number as a sentinel integer (Integer.MAX_VALUE
/// was the one in #2024) instead of omitting the field. Treat any implausibly large value the same way.
/// </summary>
private static int? UsableEpisodeNumber(Item child)
=> child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null;
/// <summary>
/// When episode_number is missing or implausibly large, use relationship sort/sequence
/// or the catalog series sequence. Prefer the parent's child relationship (the historical source),
/// then the child's parent relationship, then any series sequence Audible already attached.
/// </summary>
private static string FallbackSeriesSequence(Item parent, Item child)
{
var fromParent = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin);
if (UsableRelationshipOrder(fromParent) is string parentOrder)
return parentOrder;
var fromChild = child.Relationships?.FirstOrDefault(r => r.Asin == parent.Asin);
if (UsableRelationshipOrder(fromChild) is string childOrder)
return childOrder;
var catalogSequence = child.Series?.FirstOrDefault(s => s.Asin == parent.Asin)?.Sequence;
if (IsUsableOrderString(catalogSequence))
return catalogSequence!;
return "0";
}
private static string? UsableRelationshipOrder(Relationship? relationship)
{
if (relationship is null)
return null;
if (IsUsableSort(relationship.Sort))
return relationship.Sort!.Value.ToString();
if (IsUsableOrderString(relationship.Sequence))
return relationship.Sequence;
return null;
}
private static bool IsUsableSort(long? sort)
=> sort is long n && IsPlausibleSeriesOrder(n);
/// <summary>
/// Bare integers that are too large are sentinels or timestamps. Mixed forms like "1-6" or "2.1"
/// are real series orders and are left alone.
/// </summary>
private static bool IsUsableOrderString(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value == "-1")
return false;
return !long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)
|| IsPlausibleSeriesOrder(n);
}
#endregion
}
@@ -7,8 +7,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="13.1.1.1" />
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
<PackageReference Include="Google.Protobuf" Version="3.36.1">
<PrivateAssets>all</PrivateAssets>
<PublicAssets>runtime</PublicAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
+1 -1
View File
@@ -13,7 +13,7 @@
<ItemGroup>
<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.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>
@@ -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)
+1 -1
View File
@@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="11.0.0.1" />
<PackageReference Include="Polly" Version="8.6.6" />
<PackageReference Include="Polly" Version="8.7.0" />
</ItemGroup>
<ItemGroup>
@@ -70,11 +70,10 @@
<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" />
@@ -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}">
@@ -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;
@@ -180,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,13 +72,13 @@
</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>
@@ -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>
@@ -16,6 +16,8 @@ internal static class ContentLicenseDeniedCliSummary
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))
@@ -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)
{
@@ -105,6 +120,28 @@ internal class LoginExternalOptions : OptionsBase
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
{
private readonly string? _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); }
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="13.1.1.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" />
@@ -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;
}
@@ -22,6 +22,8 @@ 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();
@@ -35,7 +37,8 @@ public static class ContentLicenseDeniedUserMessage
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.
""" + AppendSuggestion();
""" + 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)
@@ -47,6 +50,8 @@ 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>
@@ -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];
}
@@ -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;
@@ -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 ?? [])
+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;
}
}
@@ -42,7 +42,7 @@
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="11.0.0.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3912.50" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4191.47" />
</ItemGroup>
<ItemGroup>
@@ -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>
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest.TestFramework" Version="4.3.3" />
<PackageReference Include="MSTest.TestFramework" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -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>
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -42,6 +42,8 @@ public class ContentLicenseDeniedCliSummaryTests
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)));
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" Version="4.3.3" />
<PackageReference Include="MSTest" Version="4.4.0" />
</ItemGroup>
<ItemGroup>
@@ -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>
@@ -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>
@@ -21,6 +21,9 @@ public class ContentLicenseDeniedUserMessageTests
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]
@@ -30,6 +33,7 @@ public class ContentLicenseDeniedUserMessageTests
StringAssert.Contains(body, "temporary interruption of service");
Assert.IsFalse(body.Contains("account is being throttled", StringComparison.Ordinal));
AssertSuggestsRemoveSaveReAdd(body);
}
[TestMethod]
@@ -39,5 +43,14 @@ public class ContentLicenseDeniedUserMessageTests
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'))" />
+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.
+1 -1
View File
@@ -16,7 +16,7 @@ Audible throttles license requests. Downloading too many titles at once leads it
**Above 3 concurrent downloads, license denials start appearing.** The exact threshold is Audible's and is not published, so 3 is the conservative choice rather than a measured maximum. The ceiling of 10 exists to stop the setting from being turned into a reliable way to get your downloads refused.
If you are seeing license denials, lowering this number is the first thing to try. See [Retrying titles Audible refuses](/docs/features/retrying-refused-downloads) for what Libation does with a title once it has been refused.
If you are seeing license denials, lowering this number is the first thing to try. See [Retrying titles Audible refuses](/docs/features/retrying-refused-downloads) for what Libation does with a title once it has been refused. If the official app can still play the title, also see [Device registration](/docs/advanced/device-registration).
## Your machine may run fewer than you asked for
@@ -15,6 +15,11 @@ Without this, a title Audible had just refused was requested again on the very n
refused licenses every run, forever: pointless traffic to Audible, which itself risks throttling, and a
console and log full of the same warning for the same titles.
If the official Audible app can play a title that Libation cannot download, the account may still have the
invalid Android registration created by an older Libation version. Remove and re-add the account to get the
corrected registration. See [Device registration](/docs/advanced/device-registration) for details and the
experimental iPhone alternative.
## How long Libation waits
The wait starts short and doubles with each refusal in a row. Nothing is ever permanent: every kind of
+10
View File
@@ -67,6 +67,16 @@ You likely copied an `AccountsSettings.json` that has **encrypted** tokens (`"Is
Full steps: [Troubleshooting - Failed to decrypt ExistingAccessToken](/docs/advanced/troubleshoot#failed-to-decrypt-existingaccesstoken-docker-finds-no-new-books) and [Docker Troubleshooting](/docs/installation/docker#troubleshooting).
## Downloads fail with "Content license denied" but the Audible app still plays the title
That is Audible refusing a download license, not a Libation decrypt bug. Wait 24 to 48 hours if you just downloaded many Plus titles.
If the official app can play the title and waiting does not help, **remove and re-add the account** (or run `login-external`). Older Libation versions registered Android devices with an invalid serial length, and existing accounts keep that old registration after an update. Current versions use the corrected Android registration by default.
If registering again with Android does not help, try the experimental iPhone/audible-cli option described under [Device registration](/docs/advanced/device-registration), or import credentials from [audible-cli](https://github.com/mkb79/audible-cli).
See [Troubleshooting](/docs/advanced/troubleshoot#download-fails-with-drm-license-response-not-ok-or-content-license-denied).
## Docker log says Failed to encrypt identity field / Saving as plaintext
That means encryption was preferred but could not run (usually no master key in the container). Libation re-saves those fields as plaintext and **continues** - this is expected and not a crash. Supply a master key if you want encryption at rest, or switch token storage to plaintext to quiet the Errors.
+1
View File
@@ -32,6 +32,7 @@ Learn about Libation's powerful features:
- **[Advanced Topics](/docs/advanced/advanced)** - Deep dives and configuration details
- **[Command Line Interface](/docs/advanced/command-line-interface)** - CLI usage and commands
- **[Device registration](/docs/advanced/device-registration)** - Re-register affected accounts and choose the experimental iPhone alternative
- **[Troubleshooting](/docs/advanced/troubleshoot)** - Common errors and solutions
- **[Spatial Audio & DRM](/docs/advanced/spatial-audio)** - Why Dolby Atmos download is not available and what still works
+1
View File
@@ -52,6 +52,7 @@ If you run Libation on a server or in Docker and do not want to copy `AccountsSe
- `login-external` — Browser-based sign-in: the CLI prints an Audible login URL; you open it in a normal browser, sign in, then paste the final URL from the address bar back into the terminal. Example:
`LibationCli login-external --account you@example.com --locale us`
If standard input is not a TTY (for example in some automation), pass the final URL with `--response-url "https://..."` instead of pasting interactively.
Optional `--device-registration CurrentAndroid|Mkb79IPhone` applies only to a new sign-in; remove the account first. Android is the corrected default and supports Widevine; iPhone is experimental and does not. See [Device registration](/docs/advanced/device-registration).
- `list-accounts` — List configured accounts and whether each has valid stored credentials (and scan-on/off). Example:
`LibationCli list-accounts` or `LibationCli list-accounts --bare` for tab-separated output.
+1
View File
@@ -82,6 +82,7 @@ Learn about Libation's powerful features:
- **[Advanced Topics](/docs/advanced/advanced)** - Deep dives and configuration details
- **[Command Line Interface](/docs/advanced/command-line-interface)** - CLI usage and commands
- **[Device registration](/docs/advanced/device-registration)** - Re-register affected accounts and choose the experimental iPhone alternative
- **[Troubleshooting](/docs/advanced/troubleshoot)** - Common errors and solutions
- **[Spatial Audio & DRM](/docs/advanced/spatial-audio)** - Why Dolby Atmos download is not available and what still works