diff --git a/Source/FileLiberator/WidevineRecommendation.cs b/Source/FileLiberator/WidevineRecommendation.cs new file mode 100644 index 00000000..fda84346 --- /dev/null +++ b/Source/FileLiberator/WidevineRecommendation.cs @@ -0,0 +1,59 @@ +using AudibleApi; +using LibationFileManager; +using System; + +namespace FileLiberator; + +/// +/// 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. +/// +public static class WidevineRecommendation +{ + /// + /// 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). + /// + public const string SableAssetErrorCode = "000307"; + + /// + /// Substring in the Sable error message when no ADRM content reference exists for the title. + /// Together with on licenserequest, this is the best signal we have. + /// + public const string SableAcrNullMarker = "acr:null"; + + /// + /// True when matches the rare ADRM licenserequest / Sable acr:null pattern. + /// + 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); + } + + /// + /// True when Libation should suggest enabling Widevine for this failure. + /// + public static bool ShouldRecommendWidevine(ApiErrorException ex, Configuration config) + { + ArgumentNullException.ThrowIfNull(ex); + ArgumentNullException.ThrowIfNull(config); + + return !config.UseWidevine && IsAdrmLicenseUnavailableError(ex); + } + + /// One line for logs and CLI when applies. + 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."; +} diff --git a/Source/LibationCli/Options/_ProcessableOptionsBase.cs b/Source/LibationCli/Options/_ProcessableOptionsBase.cs index a55fddf5..f0413033 100644 --- a/Source/LibationCli/Options/_ProcessableOptionsBase.cs +++ b/Source/LibationCli/Options/_ProcessableOptionsBase.cs @@ -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)) diff --git a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs index 6516a1cc..5fd38edb 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessBookViewModel.cs @@ -25,6 +25,8 @@ public enum ProcessBookResult FailedAbort, LicenseDenied, LicenseDeniedPossibleOutage, + /// ADRM licenserequest failed with Sable acr:null; Widevine may work (see WidevineRecommendation). + WidevineRecommended, /// Volume full on write; queue should stop (see ProcessQueueViewModel queue loop). 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()); diff --git a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs index ac894b8c..4d58ee8c 100644 --- a/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs +++ b/Source/LibationUiBase/ProcessQueue/ProcessQueueViewModel.cs @@ -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"); diff --git a/Source/LibationUiBase/WidevineRecommendationUserMessage.cs b/Source/LibationUiBase/WidevineRecommendationUserMessage.cs new file mode 100644 index 00000000..7e71bccb --- /dev/null +++ b/Source/LibationUiBase/WidevineRecommendationUserMessage.cs @@ -0,0 +1,30 @@ +using FileLiberator; + +namespace LibationUiBase; + +/// +/// User-facing copy when ADRM licenserequest fails with Sable acr:null (rare; Widevine may work). +/// Shared by WinForms, Avalonia, and the process queue. +/// +public static class WidevineRecommendationUserMessage +{ + public const string DialogCaption = "Download license unavailable"; + + public const string QueueStatusText = "License unavailable; try Widevine DRM"; + + /// One line for the per-book process log in the queue UI. + public static string BuildLogSummary(string bookTitleWithSubtitle) + => WidevineRecommendation.BuildLogSummary(bookTitleWithSubtitle); + + /// Shown once per queue run when the pattern is detected and Widevine is off. + 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. + """; +} diff --git a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj index 20c0dd27..2fe6326d 100644 --- a/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj +++ b/Source/_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj @@ -13,6 +13,7 @@ + diff --git a/Source/_Tests/FileLiberator.Tests/WidevineRecommendationTests.cs b/Source/_Tests/FileLiberator.Tests/WidevineRecommendationTests.cs new file mode 100644 index 00000000..5a8d9752 --- /dev/null +++ b/Source/_Tests/FileLiberator.Tests/WidevineRecommendationTests.cs @@ -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)); + } +}