mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 19:57:17 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa829df265 | ||
|
|
e84a0a121a | ||
|
|
087c107685 | ||
|
|
af7a1e8f69 | ||
|
|
cbbd8b7955 | ||
|
|
edc737df4a | ||
|
|
8050e11a41 | ||
|
|
bdf55009b5 | ||
|
|
a5a28c9d62 | ||
|
|
70909a40dc | ||
|
|
a922cfdf40 | ||
|
|
bc806bada0 | ||
|
|
36207bbab3 | ||
|
|
f4dee94785 | ||
|
|
3d563a2dcc | ||
|
|
67b476eaa0 | ||
|
|
a0adf75b53 | ||
|
|
014beaf71f | ||
|
|
74a028acaa | ||
|
|
66bf8b9de9 | ||
|
|
635780a4cf | ||
|
|
82fb235de9 | ||
|
|
336a01db01 | ||
|
|
a9df4add33 | ||
|
|
129486ce89 | ||
|
|
3e7191adc7 | ||
|
|
4cd784787e | ||
|
|
5503a9d768 | ||
|
|
094e207c0b | ||
|
|
09be09bd1b | ||
|
|
19a867488b |
No files matched your search
@@ -124,6 +124,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
|
||||
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
|
||||
{ text: "Device registration", link: "/docs/advanced/device-registration" },
|
||||
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
|
||||
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
|
||||
],
|
||||
|
||||
@@ -21,4 +21,8 @@
|
||||
<ProjectReference Include="..\FileManager\FileManager.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -179,13 +179,17 @@ public class NetworkFileStream : Stream, IUpdatable
|
||||
DownloadTask = Task.Run(() => DownloadLoopInternal(client, response), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
// avoid infinite retry loops if the server consistently fails for a particular title
|
||||
private const int MAX_TLS_RETRIES = 5;
|
||||
|
||||
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
|
||||
{
|
||||
try
|
||||
{
|
||||
long startPosition = WritePosition;
|
||||
var startPosition = WritePosition;
|
||||
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
var tlsRetryCount = 0;
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -206,8 +210,22 @@ public class NetworkFileStream : Stream, IUpdatable
|
||||
blockResponse = await RequestNextByteRangeAsync(client);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException e)
|
||||
when (e.InnerException is System.ComponentModel.Win32Exception { NativeErrorCode: -2146893008 }
|
||||
&& WritePosition != startPosition
|
||||
&& WritePosition < ContentLength && !IsCancelled
|
||||
&& ++tlsRetryCount <= MAX_TLS_RETRIES)
|
||||
{
|
||||
Serilog.Log.Logger.Warning($"TLS decryption failure at position 0x{WritePosition:X10}. Reconnecting and resuming download.");
|
||||
|
||||
_writeFile.Position = startPosition = WritePosition;
|
||||
blockResponse.Dispose();
|
||||
blockResponse = await RequestNextByteRangeAsync(client);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -218,7 +218,7 @@ public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDown
|
||||
= Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId))
|
||||
?? AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration);
|
||||
|
||||
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension);
|
||||
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension, returnFirstExisting: Configuration.OverwriteExisting);
|
||||
}
|
||||
|
||||
private static string? getdownloadUrl(LibraryBook libraryBook)
|
||||
|
||||
@@ -26,4 +26,8 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,4 +1,5 @@
|
||||
using AudibleUtilities;
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
@@ -21,18 +22,23 @@ public static class UtilityExtensions
|
||||
account: libraryBook.Account.ToMask()
|
||||
);
|
||||
|
||||
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
|
||||
|
||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||
{
|
||||
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale)
|
||||
?? throw new InvalidCredentialException($"No account found for '{libraryBook.Account}' and locale '{libraryBook.Book.Locale}'");
|
||||
|
||||
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: true, storeLocale: libraryBook.StoreLocale());
|
||||
return apiExtended.Api;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marketplace a download or license request must speak to: the one the book was scanned from, which is not
|
||||
/// always the account's home store. See issue #2020.
|
||||
/// </summary>
|
||||
internal static Locale StoreLocale(this LibraryBook libraryBook)
|
||||
=> Localization.Get(libraryBook.Book.Locale);
|
||||
|
||||
public static bool SupportsWidevine(this AudibleApi.Api api)
|
||||
{
|
||||
//TODO: Expose Api's identity maintainer directly instead of using reflection.
|
||||
|
||||
@@ -6,8 +6,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.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">
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
public partial class MainVM : ViewModelBase
|
||||
{
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public MainVM()
|
||||
{
|
||||
|
||||
@@ -77,7 +77,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
item.IsChecked = false;
|
||||
}
|
||||
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public async Task RestoreCheckedAsync()
|
||||
{
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -53,6 +53,10 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<!-- Remove obj folder -->
|
||||
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user