Merge pull request #1874 from rmcrackan/rmcrackan/1872-widevine

#1872 : Suggest Widevine when ADRM licenserequest fails with Sable acr:null
This commit is contained in:
rmcrackan
2026-06-16 11:20:12 -04:00
committed by GitHub
7 changed files with 197 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
using AudibleApi;
using LibationFileManager;
using System;
namespace FileLiberator;
/// <summary>
/// Detects a rare Audible API failure mode where ADRM licenserequest returns Sable error 000307
/// with acr:null. Some titles (e.g. B08XDZCH78 in the US marketplace) only deliver via Widevine.
/// Keep all detection here so this edge case does not spread through download or UI code.
/// </summary>
public static class WidevineRecommendation
{
/// <summary>
/// Audible error code seen when Sable cannot resolve asset details for ADRM licenserequest.
/// Not diagnostic on its own — also returned for unrelated API issues (e.g. empty response_groups).
/// </summary>
public const string SableAssetErrorCode = "000307";
/// <summary>
/// Substring in the Sable error message when no ADRM content reference exists for the title.
/// Together with <see cref="SableAssetErrorCode"/> on licenserequest, this is the best signal we have.
/// </summary>
public const string SableAcrNullMarker = "acr:null";
/// <summary>
/// True when <paramref name="ex"/> matches the rare ADRM licenserequest / Sable acr:null pattern.
/// </summary>
public static bool IsAdrmLicenseUnavailableError(ApiErrorException ex)
{
ArgumentNullException.ThrowIfNull(ex);
if (ex.RequestUri is not { } requestUri
|| !requestUri.Contains("/licenserequest", StringComparison.OrdinalIgnoreCase))
return false;
if (ex.JsonMessage is not { } json)
return false;
return json.Contains(SableAssetErrorCode, StringComparison.Ordinal)
&& json.Contains(SableAcrNullMarker, StringComparison.Ordinal);
}
/// <summary>
/// True when Libation should suggest enabling Widevine for this failure.
/// </summary>
public static bool ShouldRecommendWidevine(ApiErrorException ex, Configuration config)
{
ArgumentNullException.ThrowIfNull(ex);
ArgumentNullException.ThrowIfNull(config);
return !config.UseWidevine && IsAdrmLicenseUnavailableError(ex);
}
/// <summary>One line for logs and CLI when <see cref="ShouldRecommendWidevine"/> applies.</summary>
public static string BuildLogSummary(string bookTitleWithSubtitle)
=> $"{bookTitleWithSubtitle}: Audible returned no ADRM license (Sable acr:null). "
+ "Some rare titles only download with Widevine — try Settings > Audio File Options > Use Widevine DRM.";
}

View File

@@ -131,6 +131,11 @@ public abstract class ProcessableOptionsBase : OptionsBase
Serilog.Log.Logger.Error(errorMessage);
}
}
catch (ApiErrorException ex) when (WidevineRecommendation.ShouldRecommendWidevine(ex, Configuration.Instance))
{
Console.Error.WriteLine(WidevineRecommendation.BuildLogSummary(libraryBook.Book.TitleWithSubtitle));
Serilog.Log.Logger.Error(ex, "ADRM license unavailable (Sable acr:null) {@DebugInfo}", new { Book = libraryBook.LogFriendly() });
}
catch (ContentLicenseDeniedException clEx)
{
foreach (var line in ContentLicenseDeniedCliSummary.Lines(clEx))

View File

@@ -25,6 +25,8 @@ public enum ProcessBookResult
FailedAbort,
LicenseDenied,
LicenseDeniedPossibleOutage,
/// <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>
DiskFull
}
@@ -72,6 +74,7 @@ public class ProcessBookViewModel : ReactiveObject
(ProcessBookResult.LicenseDenied, true) => "License denied (Plus; often temporary)",
(ProcessBookResult.LicenseDenied, false) => "License Denied",
(ProcessBookResult.LicenseDeniedPossibleOutage, _) => "Possible Service Interruption",
(ProcessBookResult.WidevineRecommended, _) => WidevineRecommendationUserMessage.QueueStatusText,
(ProcessBookResult.DiskFull, _) => "Disk full, queue stopped",
_ => Status.ToString(),
};
@@ -209,6 +212,12 @@ public class ProcessBookViewModel : ReactiveObject
}
}
}
catch (ApiErrorException ex) when (WidevineRecommendation.ShouldRecommendWidevine(ex, Configuration))
{
Serilog.Log.Logger.Error(ex, "ADRM license unavailable (Sable acr:null) for {Book}", LibraryBook.LogFriendly());
LogInfo($"{procName}: {WidevineRecommendationUserMessage.BuildLogSummary(LibraryBook.Book.TitleWithSubtitle)}");
result = ProcessBookResult.WidevineRecommended;
}
catch (ContentLicenseDeniedException ldex)
{
Serilog.Log.Logger.Error(ldex, "Content license was denied for {Book}", LibraryBook.LogFriendly());

View File

@@ -79,6 +79,7 @@ public class ProcessQueueViewModel : ReactiveObject
or ProcessBookResult.FailedSkip
or ProcessBookResult.FailedRetry
or ProcessBookResult.ValidationFail
or ProcessBookResult.WidevineRecommended
or ProcessBookResult.DiskFull);
var completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
@@ -353,6 +354,7 @@ public class ProcessQueueViewModel : ReactiveObject
ProgressBarVisible = true;
var startingTime = DateTime.Now;
bool shownLicenseGuidanceMessage = false;
bool shownWidevineGuidanceMessage = false;
bool shownDiskFullMessage = false;
using var counterTimer = new System.Threading.Timer(_ => RunningTime = timeToStr(DateTime.Now - startingTime), null, 0, 500);
@@ -406,6 +408,15 @@ public class ProcessQueueViewModel : ReactiveObject
MessageBoxIcon.Asterisk);
shownLicenseGuidanceMessage = true;
}
else if (!shownWidevineGuidanceMessage && result == ProcessBookResult.WidevineRecommended)
{
await MessageBoxBase.Show(
WidevineRecommendationUserMessage.BuildDialogBody(nextBook.LibraryBook.Book.TitleWithSubtitle),
WidevineRecommendationUserMessage.DialogCaption,
MessageBoxButtons.OK,
MessageBoxIcon.Asterisk);
shownWidevineGuidanceMessage = true;
}
ProcessEnd?.Invoke(this, nextBook);
}
Serilog.Log.Logger.Information("Completed processing queue");

View File

@@ -0,0 +1,30 @@
using FileLiberator;
namespace LibationUiBase;
/// <summary>
/// User-facing copy when ADRM licenserequest fails with Sable acr:null (rare; Widevine may work).
/// Shared by WinForms, Avalonia, and the process queue.
/// </summary>
public static class WidevineRecommendationUserMessage
{
public const string DialogCaption = "Download license unavailable";
public const string QueueStatusText = "License unavailable; try Widevine DRM";
/// <summary>One line for the per-book process log in the queue UI.</summary>
public static string BuildLogSummary(string bookTitleWithSubtitle)
=> WidevineRecommendation.BuildLogSummary(bookTitleWithSubtitle);
/// <summary>Shown once per queue run when the pattern is detected and Widevine is off.</summary>
public static string BuildDialogBody(string bookTitleWithSubtitle)
=> $"""
Libation could not get a download license for {bookTitleWithSubtitle} using the standard (ADRM) format.
This is uncommon. A small number of titles — including some Audible Plus catalog books — only deliver when Widevine DRM is enabled. Enabling it has fixed this for other users with the same error.
Try Settings > Audio File Options > Use Widevine DRM, then log into your accounts again when prompted and liberate this title again.
If it still fails, open an issue on Libation's GitHub and include your logs.
""";
}

View File

@@ -13,6 +13,7 @@
<ItemGroup>
<ProjectReference Include="..\..\FileLiberator\FileLiberator.csproj" />
<ProjectReference Include="..\..\LibationFileManager\LibationFileManager.csproj" />
<ProjectReference Include="..\AssertionHelper\AssertionHelper.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,82 @@
using AudibleApi;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
namespace FileLiberator.Tests;
[TestClass]
public class WidevineRecommendationTests
{
private const string B08XDZCH78_JSON = """
{"error_code":"000307","message":"Unable to retrieve asset details from Sable(AssetInfos), for marketplaceId:AF2M0KC94RCEA, asin:B08XDZCH78, acr:null, skuLite:BK_ACX0_239890, version:LATEST, aaaClientId:urn:cdo:AudibleApiExternalRouterService:Prod:Default"}
""";
[TestCleanup]
public void RestoreConfiguration() => Configuration.RestoreSingletonInstance();
[TestMethod]
public void IsAdrmLicenseUnavailableError_matches_known_Sable_acr_null_licenserequest()
{
var ex = new ApiErrorException(
"https://api.audible.com/1.0/content/B08XDZCH78/licenserequest",
JObject.Parse(B08XDZCH78_JSON),
WidevineRecommendation.SableAssetErrorCode);
Assert.IsTrue(WidevineRecommendation.IsAdrmLicenseUnavailableError(ex));
}
[TestMethod]
public void IsAdrmLicenseUnavailableError_rejects_000307_without_acr_null()
{
var ex = new ApiErrorException(
"https://api.audible.com/1.0/content/B00/licenserequest",
new JObject
{
{ "error_code", WidevineRecommendation.SableAssetErrorCode },
{ "message", "No response groups populated." }
},
WidevineRecommendation.SableAssetErrorCode);
Assert.IsFalse(WidevineRecommendation.IsAdrmLicenseUnavailableError(ex));
}
[TestMethod]
public void IsAdrmLicenseUnavailableError_requires_licenserequest_uri()
{
var ex = new ApiErrorException(
"https://api.audible.com/1.0/content/B08XDZCH78/metadata",
JObject.Parse(B08XDZCH78_JSON),
WidevineRecommendation.SableAssetErrorCode);
Assert.IsFalse(WidevineRecommendation.IsAdrmLicenseUnavailableError(ex));
}
[TestMethod]
public void ShouldRecommendWidevine_when_pattern_matches_and_widevine_disabled()
{
var ex = new ApiErrorException(
"https://api.audible.com/1.0/content/B08XDZCH78/licenserequest",
JObject.Parse(B08XDZCH78_JSON),
WidevineRecommendation.SableAssetErrorCode);
var config = Configuration.CreateMockInstance();
config.UseWidevine = false;
Assert.IsTrue(WidevineRecommendation.ShouldRecommendWidevine(ex, config));
}
[TestMethod]
public void ShouldRecommendWidevine_false_when_widevine_already_enabled()
{
var ex = new ApiErrorException(
"https://api.audible.com/1.0/content/B08XDZCH78/licenserequest",
JObject.Parse(B08XDZCH78_JSON),
WidevineRecommendation.SableAssetErrorCode);
var config = Configuration.CreateMockInstance();
config.UseWidevine = true;
Assert.IsFalse(WidevineRecommendation.ShouldRecommendWidevine(ex, config));
}
}