From 28ba62aead001dd0245976a0552eba8f57351f1b Mon Sep 17 00:00:00 2001 From: MBucari Date: Wed, 7 May 2025 23:15:44 -0600 Subject: [PATCH 1/9] Fix dash files not being saved (#1236) --- Source/AaxDecrypter/AudiobookDownloadBase.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Source/AaxDecrypter/AudiobookDownloadBase.cs b/Source/AaxDecrypter/AudiobookDownloadBase.cs index 23d12e4c..392c21f1 100644 --- a/Source/AaxDecrypter/AudiobookDownloadBase.cs +++ b/Source/AaxDecrypter/AudiobookDownloadBase.cs @@ -204,6 +204,9 @@ namespace AaxDecrypter else throw new InvalidOperationException($"Unknown file type: {fileType}"); + if (tempFilePath != aaxPath) + FileUtility.SaferMove(tempFilePath, aaxPath); + OnFileCreated(aaxPath); OnFileCreated(keyPath); } From b11a4887d7ae919428ce7ea522440c1b444dbd7e Mon Sep 17 00:00:00 2001 From: MBucari Date: Wed, 7 May 2025 23:17:39 -0600 Subject: [PATCH 2/9] Pad final chapter to prevent tuncation from incorrect chapter info (#1246) --- .../AaxDecrypter/AaxcDownloadConvertBase.cs | 2 +- Source/FileLiberator/DownloadDecryptBook.cs | 26 +++++++++++++++++++ Source/FileLiberator/DownloadOptions.cs | 26 +++++++++---------- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index f96dfeea..22279818 100644 --- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs +++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs @@ -8,7 +8,7 @@ namespace AaxDecrypter { public event EventHandler RetrievedMetadata; - protected Mp4File AaxFile { get; private set; } + public Mp4File AaxFile { get; private set; } protected Mp4Operation AaxConversion { get; set; } protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index f6a70deb..a5ec6f9e 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -171,6 +171,32 @@ namespace FileLiberator if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options) return; + #region Prevent erroneous truncation due to incorrect chapter info + + //Sometimes the chapter info is not accurate. Since AAXClean trims audio + //files to the chapters start and end, if the last chapter's end time is + //before the end of the audio file, the file will be truncated to match + //the chapter. This is never desirable, so pad the last chapter to match + //the original audio length. + + var fileDuration = converter.AaxFile.Duration; + if (options.Config.StripAudibleBrandAudio) + fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs); + + var durationDelta = fileDuration - options.ChapterInfo.EndOffset; + if (durationDelta.TotalMilliseconds > 0) + { + //only fix chapters which are shorter than the file. Chapters which are longer + //than the file will be truncated to match the file length, which is correct. + var chapters = options.ChapterInfo.Chapters as List; + var lastChapter = chapters[^1]; + + chapters.Remove(lastChapter); + options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta); + } + + #endregion + tags.Title ??= options.LibraryBookDto.TitleWithSubtitle; tags.Album ??= tags.Title; tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 3101aa72..e48ce069 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -31,16 +31,16 @@ namespace FileLiberator public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number; public NAudio.Lame.LameConfig LameConfig { get; init; } public string UserAgent => AudibleApi.Resources.Download_User_Agent; - public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio; - public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged; - public bool CreateCueSheet => config.CreateCueSheet; - public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks; - public long DownloadSpeedBps => config.DownloadSpeedLimit; - public bool RetainEncryptedFile => config.RetainAaxFile; - public bool FixupFile => config.AllowLibationFixup; - public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono; - public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate; - public bool MoveMoovToBeginning => config.MoveMoovToBeginning; + public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio; + public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged; + public bool CreateCueSheet => Config.CreateCueSheet; + public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks; + public long DownloadSpeedBps => Config.DownloadSpeedLimit; + public bool RetainEncryptedFile => Config.RetainAaxFile; + public bool FixupFile => Config.AllowLibationFixup; + public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono; + public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate; + public bool MoveMoovToBeginning => Config.MoveMoovToBeginning; public AAXClean.FileType? InputType { get; init; } public AudibleApi.Common.DrmType DrmType { get; init; } public AudibleApi.Common.ContentMetadata ContentMetadata { get; init; } @@ -59,7 +59,7 @@ namespace FileLiberator { if (DownloadClipsBookmarks) { - var format = config.ClipsBookmarksFileFormat; + var format = Config.ClipsBookmarksFileFormat; var formatExtension = format.ToString().ToLowerInvariant(); var filePath = Path.ChangeExtension(fileName, formatExtension); @@ -84,7 +84,7 @@ namespace FileLiberator return string.Empty; } - private readonly Configuration config; + public Configuration Config { get; } private readonly IDisposable cancellation; public void Dispose() { @@ -94,7 +94,7 @@ namespace FileLiberator private DownloadOptions(Configuration config, LibraryBook libraryBook, [System.Diagnostics.CodeAnalysis.NotNull] string downloadUrl) { - this.config = ArgumentValidator.EnsureNotNull(config, nameof(config)); + Config = ArgumentValidator.EnsureNotNull(config, nameof(config)); LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)); DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl)); // no null/empty check for key/iv. unencrypted files do not have them From 19db226f5ac778e46cf65ecc5f36820d27badff8 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 May 2025 13:13:34 -0600 Subject: [PATCH 3/9] Use Libation settings to decide which DRM is downloaded. --- .../FileLiberator/DownloadOptions.Factory.cs | 98 +++++-------------- 1 file changed, 25 insertions(+), 73 deletions(-) diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 5934328b..bbd05089 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -48,8 +48,6 @@ public partial class DownloadOptions if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); - ContentLicense? contentLic = null, fallback = null; - try { //try to request a widevine content license using the user's spatial audio settings @@ -60,7 +58,7 @@ public partial class DownloadOptions _ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}") }; - contentLic + var contentLic = await api.GetDownloadLicenseAsync( libraryBook.Book.AudibleProductId, dlQuality, @@ -68,81 +66,35 @@ public partial class DownloadOptions DrmType.Widevine, config.RequestSpatial, codecChoice); + + using var client = new HttpClient(); + using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse); + var dash = new MpegDash(mpdResponse.Content.ReadAsStream()); + + if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri)) + throw new InvalidDataException("Failed to get mpeg-dash content download url."); + + contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() }; + + using var session = cdm.OpenSession(); + var challenge = session.GetLicenseChallenge(dash); + var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge); + var keys = session.ParseLicense(licenseMessage); + contentLic.Voucher = new VoucherDtoV10() + { + Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()), + Iv = Convert.ToHexStringLower(keys[0].Key) + }; + return contentLic; } catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license."); - //We failed to get a widevine license, so fall back to AAX(C) - return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); + //We failed to get a widevine content license. Depending on the + //failure reason, users can potentially still download this audiobook + //by disabling the "Use Widevine DRM" feature. + throw; } - - if (!contentLic.ContentMetadata.ContentReference.IsSpatial && contentLic.DrmType != DrmType.Adrm) - { - /* - We got a widevine license and we have a Cdm, but we still need to decide if we WANT the file - being delivered with widevine. This file is not "spatial", so it may be no better than the - audio in the Adrm files. All else being equal, we prefer Adrm files because they have more - build-in metadata and always AAC-LC, which is a codec playable by pretty much every device - in existence. - - Unfortunately, there appears to be no way to determine which codec/quality combination we'll - get until we make the request and see what content gets delivered. For some books, - Widevine/High delivers 44.1 kHz / 128 kbps audio and Adrm/High delivers 22.05 kHz / 64 kbps. - In those cases, the Widevine content size is much larger. Other books will deliver the same - sample rate / bitrate for both Widevine and Adrm, the only difference being codec. Widevine - is usually xHE-AAC, but is sometimes AAC-LC. Adrm is always AAC-LC. - - To decide which file we want, use this simple rule: if files are different codecs and - Widevine is significantly larger, use Widevine. Otherwise use ADRM. - */ - - fallback = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); - - var wvCr = contentLic.ContentMetadata.ContentReference; - var adrmCr = fallback.ContentMetadata.ContentReference; - - if (wvCr.Codec == adrmCr.Codec || - adrmCr.ContentSizeInBytes > wvCr.ContentSizeInBytes || - RelativePercentDifference(adrmCr.ContentSizeInBytes, wvCr.ContentSizeInBytes) < 0.05) - { - contentLic = fallback; - } - } - - if (contentLic.DrmType == DrmType.Widevine) - { - try - { - using var client = new HttpClient(); - using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse); - var dash = new MpegDash(mpdResponse.Content.ReadAsStream()); - - if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri)) - throw new InvalidDataException("Failed to get mpeg-dash content download url."); - - contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() }; - - using var session = cdm.OpenSession(); - var challenge = session.GetLicenseChallenge(dash); - var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge); - var keys = session.ParseLicense(licenseMessage); - contentLic.Voucher = new VoucherDtoV10() - { - Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()), - Iv = Convert.ToHexStringLower(keys[0].Key) - }; - } - catch - { - if (fallback != null) - return fallback; - - //We won't have a fallback if the requested license is for a spatial audio file. - //Throw so that the user is aware that spatial audio exists and that they were not able to download it. - throw; - } - } - return contentLic; } From dfb63d3275a382ebe084c7d6b634a6380bd9f2c3 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 May 2025 13:15:18 -0600 Subject: [PATCH 4/9] Add contributor --- Source/LibationUiBase/LibationContributor.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/LibationUiBase/LibationContributor.cs b/Source/LibationUiBase/LibationContributor.cs index 3ddf77b0..dc1e1c1f 100644 --- a/Source/LibationUiBase/LibationContributor.cs +++ b/Source/LibationUiBase/LibationContributor.cs @@ -41,7 +41,8 @@ public class LibationContributor GitHubUser("muchtall"), GitHubUser("ScubyG"), GitHubUser("patienttruth"), - GitHubUser("stickystyle") + GitHubUser("stickystyle"), + GitHubUser("cherez"), ]); private LibationContributor(string name, LibationContributorType type,Uri link) From 20e792c589c1c342e7af9ea2b0c7686c10c66bc0 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 May 2025 13:36:07 -0600 Subject: [PATCH 5/9] Always change the last chapter's length to coincide with the end of the audio file. --- Source/FileLiberator/DownloadDecryptBook.cs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index a5ec6f9e..3ead2a9f 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -184,16 +184,13 @@ namespace FileLiberator fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs); var durationDelta = fileDuration - options.ChapterInfo.EndOffset; - if (durationDelta.TotalMilliseconds > 0) - { - //only fix chapters which are shorter than the file. Chapters which are longer - //than the file will be truncated to match the file length, which is correct. - var chapters = options.ChapterInfo.Chapters as List; - var lastChapter = chapters[^1]; + //Remove the last chapter and re-add it with the durationDelta that will + //make the chapter's end coincide with the end of the audio file. + var chapters = options.ChapterInfo.Chapters as List; + var lastChapter = chapters[^1]; - chapters.Remove(lastChapter); - options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta); - } + chapters.Remove(lastChapter); + options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta); #endregion From 9366b3baca08438563b75ab92db0cb0b1afe9462 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 May 2025 13:39:59 -0600 Subject: [PATCH 6/9] Default to E-AC-3 spatial audio format. --- Source/FileLiberator/DownloadOptions.Factory.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index bbd05089..d01d9993 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -51,12 +51,7 @@ public partial class DownloadOptions try { //try to request a widevine content license using the user's spatial audio settings - var codecChoice = config.SpatialAudioCodec switch - { - Configuration.SpatialCodec.EC_3 => Ec3Codec, - Configuration.SpatialCodec.AC_4 => Ac4Codec, - _ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}") - }; + var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec; var contentLic = await api.GetDownloadLicenseAsync( From 10c01f4147f49b5351862b5fe153af322deea89d Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 May 2025 16:22:48 -0600 Subject: [PATCH 7/9] Fix occasional error of audio downloads hanging. --- .../AaxDecrypter/AaxcDownloadConvertBase.cs | 20 +++++- Source/AaxDecrypter/AudiobookDownloadBase.cs | 17 +++-- Source/AaxDecrypter/IDownloadOptions.cs | 40 ++++++++--- Source/AaxDecrypter/NetworkFileStream.cs | 10 +-- Source/AudibleUtilities/Widevine/Cdm.cs | 4 +- .../FileLiberator/DownloadOptions.Factory.cs | 68 +++++++++---------- Source/FileLiberator/DownloadOptions.cs | 56 ++++++++++----- 7 files changed, 140 insertions(+), 75 deletions(-) diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index 22279818..8f350cb2 100644 --- a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs +++ b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs @@ -1,5 +1,6 @@ using AAXClean; using System; +using System.Linq; using System.Threading.Tasks; namespace AaxDecrypter @@ -33,20 +34,33 @@ namespace AaxDecrypter { if (DownloadOptions.InputType is FileType.Dash) { + //We may have multiple keys , so use the key whose key ID matches + //the dash files default Key ID. + var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); + var dash = new DashFile(InputFileStream); - dash.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV); + var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID); + + if (kidIndex == -1) + throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}"); + + DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex]; + var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1; + var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2; + + dash.SetDecryptionKey(keyId, key); return dash; } else if (DownloadOptions.InputType is FileType.Aax) { var aax = new AaxFile(InputFileStream); - aax.SetDecryptionKey(DownloadOptions.AudibleKey); + aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1); return aax; } else if (DownloadOptions.InputType is FileType.Aaxc) { var aax = new AaxFile(InputFileStream); - aax.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV); + aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2); return aax; } else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown."); diff --git a/Source/AaxDecrypter/AudiobookDownloadBase.cs b/Source/AaxDecrypter/AudiobookDownloadBase.cs index 392c21f1..355b3eca 100644 --- a/Source/AaxDecrypter/AudiobookDownloadBase.cs +++ b/Source/AaxDecrypter/AudiobookDownloadBase.cs @@ -73,11 +73,16 @@ namespace AaxDecrypter AsyncSteps[$"Cleanup"] = CleanupAsync; (bool success, var elapsed) = await AsyncSteps.RunAsync(); + //Stop the downloader so it doesn't keep running in the background. + if (!success) + nfsPersister.Dispose(); + await progressTask; var speedup = DownloadOptions.RuntimeLength / elapsed; Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); + nfsPersister.Dispose(); return success; async Task reportProgress() @@ -177,7 +182,7 @@ namespace AaxDecrypter FileUtility.SaferDelete(jsonDownloadState); - if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) && + if (DownloadOptions.DecryptionKeys != null && DownloadOptions.RetainEncryptedFile && DownloadOptions.InputType is AAXClean.FileType fileType) { @@ -188,17 +193,21 @@ namespace AaxDecrypter if (fileType is AAXClean.FileType.Aax) { - await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}"); + await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}"); aaxPath = Path.ChangeExtension(tempFilePath, ".aax"); } else if (fileType is AAXClean.FileType.Aaxc) { - await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}"); + await File.WriteAllTextAsync(keyPath, + $"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" + + $"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}"); aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc"); } else if (fileType is AAXClean.FileType.Dash) { - await File.WriteAllTextAsync(keyPath, $"KeyId={DownloadOptions.AudibleKey}{Environment.NewLine}Key={DownloadOptions.AudibleIV}"); + await File.WriteAllTextAsync(keyPath, + $"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" + + $"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}"); aaxPath = Path.ChangeExtension(tempFilePath, ".dash"); } else diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index 35533c82..be630e85 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -2,15 +2,35 @@ using System; using System.Threading.Tasks; +#nullable enable namespace AaxDecrypter { - public interface IDownloadOptions + public class KeyData + { + public byte[] KeyPart1 { get; } + public byte[]? KeyPart2 { get; } + + public KeyData(byte[] keyPart1, byte[]? keyPart2 = null) + { + KeyPart1 = keyPart1; + KeyPart2 = keyPart2; + } + + public KeyData(string keyPart1, string? keyPart2 = null) + { + ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1)); + KeyPart1 = Convert.FromBase64String(keyPart1); + if (keyPart2 != null) + KeyPart2 = Convert.FromBase64String(keyPart2); + } + } + + public interface IDownloadOptions { event EventHandler DownloadSpeedChanged; string DownloadUrl { get; } string UserAgent { get; } - string AudibleKey { get; } - string AudibleIV { get; } + KeyData[]? DecryptionKeys { get; } TimeSpan RuntimeLength { get; } OutputFormat OutputFormat { get; } bool TrimOutputToChapterLength { get; } @@ -21,14 +41,14 @@ namespace AaxDecrypter long DownloadSpeedBps { get; } ChapterInfo ChapterInfo { get; } bool FixupFile { get; } - string AudibleProductId { get; } - string Title { get; } - string Subtitle { get; } - string Publisher { get; } - string Language { get; } - string SeriesName { get; } + string? AudibleProductId { get; } + string? Title { get; } + string? Subtitle { get; } + string? Publisher { get; } + string? Language { get; } + string? SeriesName { get; } float? SeriesNumber { get; } - NAudio.Lame.LameConfig LameConfig { get; } + NAudio.Lame.LameConfig? LameConfig { get; } bool Downsample { get; } bool MatchSourceBitrate { get; } bool MoveMoovToBeginning { get; } diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index 8bad5708..1be60545 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -110,14 +110,16 @@ namespace AaxDecrypter #region Downloader /// Update the . - private void OnUpdate() + private void OnUpdate(bool waitForWrite = false) { try { - if (DateTime.UtcNow > NextUpdateTime) + if (waitForWrite || DateTime.UtcNow > NextUpdateTime) { Updated?.Invoke(this, EventArgs.Empty); //JsonFilePersister Will not allow update intervals shorter than 100 milliseconds + //If an update is called less than 100 ms since the last update, persister will + //sleep the thread until 100 ms has elapsed. NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110); } } @@ -305,7 +307,7 @@ namespace AaxDecrypter finally { _downloadedPiece.Set(); - OnUpdate(); + OnUpdate(waitForWrite: true); } } @@ -402,7 +404,7 @@ namespace AaxDecrypter _cancellationSource?.Dispose(); _readFile.Dispose(); _writeFile.Dispose(); - OnUpdate(); + OnUpdate(waitForWrite: true); } disposed = true; diff --git a/Source/AudibleUtilities/Widevine/Cdm.cs b/Source/AudibleUtilities/Widevine/Cdm.cs index b15c820e..99eecf3e 100644 --- a/Source/AudibleUtilities/Widevine/Cdm.cs +++ b/Source/AudibleUtilities/Widevine/Cdm.cs @@ -55,7 +55,7 @@ public class WidevineKey Type = (KeyType)type; Key = key; } - public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray()).ToLower()}:{Convert.ToHexString(Key).ToLower()}"; + public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}"; } public partial class Cdm @@ -192,7 +192,7 @@ public partial class Cdm id = id.Append(new byte[16 - id.Length]); } - keys[i] = new WidevineKey(new Guid(id), keyContainer.Type, keyBytes); + keys[i] = new WidevineKey(new Guid(id,bigEndian: true), keyContainer.Type, keyBytes); } return keys; } diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index d01d9993..94368534 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -41,12 +41,31 @@ public partial class DownloadOptions return options; } - private static async Task ChooseContent(Api api, LibraryBook libraryBook, Configuration config) + private class LicenseInfo + { + public DrmType DrmType { get; } + public ContentMetadata ContentMetadata { get; set; } + public KeyData[]? DecryptionKeys { get; } + public LicenseInfo(ContentLicense license, IEnumerable? keys = null) + { + DrmType = license.DrmType; + ContentMetadata = license.ContentMetadata; + DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher); + } + + private static KeyData[]? ToKeys(VoucherDtoV10? voucher) + => voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)]; + } + + private static async Task ChooseContent(Api api, LibraryBook libraryBook, Configuration config) { var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) - return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); + { + var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); + return new LicenseInfo(license); + } try { @@ -62,6 +81,9 @@ public partial class DownloadOptions config.RequestSpatial, codecChoice); + if (contentLic.DrmType is not DrmType.Widevine) + return new LicenseInfo(contentLic); + using var client = new HttpClient(); using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse); var dash = new MpegDash(mpdResponse.Content.ReadAsStream()); @@ -75,12 +97,7 @@ public partial class DownloadOptions var challenge = session.GetLicenseChallenge(dash); var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge); var keys = session.ParseLicense(licenseMessage); - contentLic.Voucher = new VoucherDtoV10() - { - Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()), - Iv = Convert.ToHexStringLower(keys[0].Key) - }; - return contentLic; + return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key))); } catch (Exception ex) { @@ -93,41 +110,20 @@ public partial class DownloadOptions } - private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic) + private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { - //If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3. - var outputFormat - = contentLic.DrmType is not DrmType.Adrm and not DrmType.Widevine || - (config.AllowLibationFixup && config.DecryptToLossy && contentLic.ContentMetadata.ContentReference.Codec != "ac-4") - ? OutputFormat.Mp3 - : OutputFormat.M4b; - long chapterStartMs = config.StripAudibleBrandAudio - ? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs + ? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0; - AAXClean.FileType? inputType - = contentLic.DrmType is DrmType.Widevine ? AAXClean.FileType.Dash - : contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 8 && contentLic.Voucher?.Iv == null ? AAXClean.FileType.Aax - : contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 32 && contentLic.Voucher?.Iv.Length == 32 ? AAXClean.FileType.Aaxc - : null; - - var dlOptions = new DownloadOptions(config, libraryBook, contentLic.ContentMetadata.ContentUrl?.OfflineUrl) + var dlOptions = new DownloadOptions(config, libraryBook, licInfo) { - AudibleKey = contentLic.Voucher?.Key, - AudibleIV = contentLic.Voucher?.Iv, - InputType = inputType, - OutputFormat = outputFormat, - DrmType = contentLic.DrmType, - ContentMetadata = contentLic.ContentMetadata, - LameConfig = outputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null, ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)), - RuntimeLength = TimeSpan.FromMilliseconds(contentLic.ContentMetadata.ChapterInfo.RuntimeLengthMs), + RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), }; - dlOptions.LibraryBookDto.Codec = contentLic.ContentMetadata.ContentReference.Codec; - if (TryGetAudioInfo(contentLic.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels)) + if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels)) { dlOptions.LibraryBookDto.BitRate = bitrate; dlOptions.LibraryBookDto.SampleRate = sampleRate; @@ -136,7 +132,7 @@ public partial class DownloadOptions var titleConcat = config.CombineNestedChapterTitles ? ": " : null; var chapters - = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat) + = flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat) .OrderBy(c => c.StartOffsetMs) .ToList(); @@ -152,7 +148,7 @@ public partial class DownloadOptions chapLenMs -= chapterStartMs; if (config.StripAudibleBrandAudio && i == chapters.Count - 1) - chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs; + chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs; dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs)); } diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index e48ce069..3f55abe2 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -9,27 +9,27 @@ using System.IO; using ApplicationServices; using LibationFileManager.Templates; +#nullable enable namespace FileLiberator { public partial class DownloadOptions : IDownloadOptions, IDisposable { - public event EventHandler DownloadSpeedChanged; + public event EventHandler? DownloadSpeedChanged; public LibraryBook LibraryBook { get; } public LibraryBookDto LibraryBookDto { get; } public string DownloadUrl { get; } - public string AudibleKey { get; init; } - public string AudibleIV { get; init; } - public TimeSpan RuntimeLength { get; init; } - public OutputFormat OutputFormat { get; init; } - public ChapterInfo ChapterInfo { get; init; } + public KeyData[]? DecryptionKeys { get; } + public required TimeSpan RuntimeLength { get; init; } + public OutputFormat OutputFormat { get; } + public required ChapterInfo ChapterInfo { get; init; } public string Title => LibraryBook.Book.Title; public string Subtitle => LibraryBook.Book.Subtitle; public string Publisher => LibraryBook.Book.Publisher; public string Language => LibraryBook.Book.Language; - public string AudibleProductId => LibraryBookDto.AudibleProductId; - public string SeriesName => LibraryBookDto.FirstSeries?.Name; + public string? AudibleProductId => LibraryBookDto.AudibleProductId; + public string? SeriesName => LibraryBookDto.FirstSeries?.Name; public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number; - public NAudio.Lame.LameConfig LameConfig { get; init; } + public NAudio.Lame.LameConfig? LameConfig { get; } public string UserAgent => AudibleApi.Resources.Download_User_Agent; public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged; @@ -41,15 +41,15 @@ namespace FileLiberator public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono; public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate; public bool MoveMoovToBeginning => Config.MoveMoovToBeginning; - public AAXClean.FileType? InputType { get; init; } - public AudibleApi.Common.DrmType DrmType { get; init; } - public AudibleApi.Common.ContentMetadata ContentMetadata { get; init; } + public AAXClean.FileType? InputType { get; } + public AudibleApi.Common.DrmType DrmType { get; } + public AudibleApi.Common.ContentMetadata ContentMetadata { get; } public string GetMultipartFileName(MultiConvertFileProperties props) { var baseDir = Path.GetDirectoryName(props.OutputFileName); var extension = Path.GetExtension(props.OutputFileName); - return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension); + return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension); } public string GetMultipartTitle(MultiConvertFileProperties props) @@ -92,14 +92,38 @@ namespace FileLiberator GC.SuppressFinalize(this); } - private DownloadOptions(Configuration config, LibraryBook libraryBook, [System.Diagnostics.CodeAnalysis.NotNull] string downloadUrl) + private DownloadOptions(Configuration config, LibraryBook libraryBook, LicenseInfo licInfo) { Config = ArgumentValidator.EnsureNotNull(config, nameof(config)); LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)); - DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl)); - // no null/empty check for key/iv. unencrypted files do not have them + ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo)); + + if (licInfo.ContentMetadata.ContentUrl.OfflineUrl is not string licUrl) + throw new InvalidDataException("Content license doesn't contain an offline Url"); + + DownloadUrl = licUrl; + DecryptionKeys = licInfo.DecryptionKeys; + DrmType = licInfo.DrmType; + ContentMetadata = licInfo.ContentMetadata; + InputType + = licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash + : licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 8 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax + : licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 32 && licInfo.DecryptionKeys[0].KeyPart2?.Length == 32 ? AAXClean.FileType.Aaxc + : null; + + //If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3. + OutputFormat + = licInfo.DrmType is not AudibleApi.Common.DrmType.Adrm and not AudibleApi.Common.DrmType.Widevine || + (config.AllowLibationFixup && config.DecryptToLossy && licInfo.ContentMetadata.ContentReference.Codec != Ac4Codec) + ? OutputFormat.Mp3 + : OutputFormat.M4b; + + LameConfig = OutputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null; + + // no null/empty check for key/iv. unencrypted files do not have them LibraryBookDto = LibraryBook.ToDto(); + LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec; cancellation = config From f35c82d59dfb63043f3711b6617ab75bcb69245d Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 May 2025 17:32:12 -0600 Subject: [PATCH 8/9] Change ApiExtended to always allow provide login option Previously, only some calls to ApiExtended.CreateAsync() would prompt users to login if necessary. Other calls would only work if the account already had a valid identity, and they would throw exceptions otherwise. Changed ApiExtended so that the UI registers a static ILoginChoiceEager factory delegate that ApiExtended will use in the event that a login is required. --- Source/ApplicationServices/LibraryCommands.cs | 12 ++-- Source/AudibleUtilities/ApiExtended.cs | 68 +++++++++---------- Source/FileLiberator/UtilityExtensions.cs | 8 ++- .../Dialogs/Login/AvaloniaLoginChoiceEager.cs | 4 -- .../ViewModels/MainVM.Import.cs | 2 +- .../ViewModels/MainVM.ScanAuto.cs | 2 +- .../ViewModels/ProductsDisplayViewModel.cs | 2 +- .../Views/MainWindow.axaml.cs | 1 + Source/LibationCli/Options/ScanOptions.cs | 2 +- .../Dialogs/Login/WinformLoginChoiceEager.cs | 8 +-- Source/LibationWinForms/Form1.ScanAuto.cs | 2 +- Source/LibationWinForms/Form1.ScanManual.cs | 2 +- Source/LibationWinForms/Form1.cs | 4 ++ .../GridView/ProductsDisplay.cs | 2 +- 14 files changed, 58 insertions(+), 61 deletions(-) diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 9278b084..7c373d0b 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -32,7 +32,7 @@ namespace ApplicationServices ScanEnd += (_, __) => Scanning = false; } - public static async Task> FindInactiveBooks(Func> apiExtendedfunc, IEnumerable existingLibrary, params Account[] accounts) + public static async Task> FindInactiveBooks(IEnumerable existingLibrary, params Account[] accounts) { logRestart(); @@ -58,7 +58,7 @@ namespace ApplicationServices try { logTime($"pre {nameof(scanAccountsAsync)} all"); - var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); + var libraryItems = await scanAccountsAsync(accounts, libraryOptions); logTime($"post {nameof(scanAccountsAsync)} all"); var totalCount = libraryItems.Count; @@ -101,7 +101,7 @@ namespace ApplicationServices } #region FULL LIBRARY scan and import - public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func> apiExtendedfunc, params Account[]? accounts) + public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts) { logRestart(); @@ -131,7 +131,7 @@ namespace ApplicationServices | LibraryOptions.ResponseGroupOptions.IsFinished, ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215 }; - var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); + var importItems = await scanAccountsAsync(accounts, libraryOptions); logTime($"post {nameof(scanAccountsAsync)} all"); var totalCount = importItems.Count; @@ -262,7 +262,7 @@ namespace ApplicationServices return null; } - private static async Task> scanAccountsAsync(Func> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) + private static async Task> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions) { var tasks = new List>>(); @@ -278,7 +278,7 @@ namespace ApplicationServices try { // get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll) - var apiExtended = await apiExtendedfunc(account); + var apiExtended = await ApiExtended.CreateAsync(account); // add scanAccountAsync as a TASK: do not await tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver)); diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 201e2c8b..12a506fd 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -11,11 +11,13 @@ using Polly; using Polly.Retry; using System.Threading; +#nullable enable namespace AudibleUtilities { /// USE THIS from within Libation. It wraps the call with correct JSONPath public class ApiExtended { + public static Func? LoginChoiceFactory { get; set; } public Api Api { get; private set; } private const int MaxConcurrency = 10; @@ -24,52 +26,46 @@ namespace AudibleUtilities private ApiExtended(Api api) => Api = api; /// Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks. - public static async Task CreateAsync(Account account, ILoginChoiceEager loginChoiceEager) - { - Serilog.Log.Logger.Information("{@DebugInfo}", new - { - LoginType = nameof(ILoginChoiceEager), - Account = account?.MaskedLogEntry ?? "[null]", - LocaleName = account?.Locale?.Name - }); - - var api = await EzApiCreator.GetApiAsync( - loginChoiceEager, - account.Locale, - AudibleApiStorage.AccountsSettingsFile, - account.GetIdentityTokensJsonPath()); - return new ApiExtended(api); - } - - /// Get api from existing tokens. Assumes you have valid login tokens. Else exception public static async Task CreateAsync(Account account) { ArgumentValidator.EnsureNotNull(account, nameof(account)); + ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId)); ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); - Serilog.Log.Logger.Information("{@DebugInfo}", new + try { - AccountMaskedLogEntry = account.MaskedLogEntry - }); + Serilog.Log.Logger.Information("{@DebugInfo}", new + { + AccountMaskedLogEntry = account.MaskedLogEntry + }); - return await CreateAsync(account.AccountId, account.Locale.Name); - } - - /// Get api from existing tokens. Assumes you have valid login tokens. Else exception - public static async Task CreateAsync(string username, string localeName) - { - Serilog.Log.Logger.Information("{@DebugInfo}", new + var api = await EzApiCreator.GetApiAsync( + account.Locale, + AudibleApiStorage.AccountsSettingsFile, + account.GetIdentityTokensJsonPath()); + return new ApiExtended(api); + } + catch { - Username = username.ToMask(), - LocaleName = localeName, - }); + if (LoginChoiceFactory is null) + throw new InvalidOperationException($"The UI module must first set {LoginChoiceFactory} before attempting to create the api"); - var api = await EzApiCreator.GetApiAsync( - Localization.Get(localeName), + Serilog.Log.Logger.Information("{@DebugInfo}", new + { + LoginType = nameof(ILoginChoiceEager), + Account = account.MaskedLogEntry ?? "[null]", + LocaleName = account.Locale?.Name + }); + + var api = await EzApiCreator.GetApiAsync( + LoginChoiceFactory(account), + account.Locale, AudibleApiStorage.AccountsSettingsFile, - AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName)); - return new ApiExtended(api); - } + account.GetIdentityTokensJsonPath()); + + return new ApiExtended(api); + } + } private static AsyncRetryPolicy policy { get; } = Policy.Handle() diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 08f11a50..4a2f6de5 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -20,9 +20,15 @@ namespace FileLiberator account: libraryBook.Account.ToMask() ); + public static Func>? ApiExtendedFunc { get; set; } + public static async Task GetApiAsync(this LibraryBook libraryBook) { - var apiExtended = await ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale); + Account account; + using (var accounts = AudibleApiStorage.GetAccountsSettingsPersister()) + account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale); + + var apiExtended = await ApiExtended.CreateAsync(account); return apiExtended.Api; } diff --git a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs index 52f588f1..b8122f9d 100644 --- a/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs +++ b/Source/LibationAvalonia/Dialogs/Login/AvaloniaLoginChoiceEager.cs @@ -9,10 +9,6 @@ namespace LibationAvalonia.Dialogs.Login { public class AvaloniaLoginChoiceEager : ILoginChoiceEager { - /// Convenience method. Recommended when wiring up Winforms to - public static async Task ApiExtendedFunc(Account account) - => await ApiExtended.CreateAsync(account, new AvaloniaLoginChoiceEager(account)); - public ILoginCallback LoginCallback { get; } private readonly Account _account; diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs index 2a39fee7..213a7cf4 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs @@ -201,7 +201,7 @@ namespace LibationAvalonia.ViewModels { try { - var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts); + var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(accounts); // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop if (Configuration.Instance.ShowImportedStats && newAdded > 0) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs index 23e5b8bb..2ae562c5 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs @@ -27,7 +27,7 @@ namespace LibationAvalonia.ViewModels // in autoScan, new books SHALL NOT show dialog try { - await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts); + await LibraryCommands.ImportAccountAsync(accounts); } catch (OperationCanceledException) { diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 271feb44..13e282f5 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -431,7 +431,7 @@ namespace LibationAvalonia.ViewModels .Select(lbe => lbe.LibraryBook) .Where(lb => !lb.Book.HasLiberated()); - var removedBooks = await LibraryCommands.FindInactiveBooks(AvaloniaLoginChoiceEager.ApiExtendedFunc, lib, accounts); + var removedBooks = await LibraryCommands.FindInactiveBooks(lib, accounts); var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 950bfc5e..22262c5a 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -21,6 +21,7 @@ namespace LibationAvalonia.Views public MainWindow() { DataContext = new MainVM(this); + ApiExtended.LoginChoiceFactory = account => new Dialogs.Login.AvaloniaLoginChoiceEager(account); AudibleApiStorage.LoadError += AudibleApiStorage_LoadError; InitializeComponent(); diff --git a/Source/LibationCli/Options/ScanOptions.cs b/Source/LibationCli/Options/ScanOptions.cs index 43c888dd..532193a2 100644 --- a/Source/LibationCli/Options/ScanOptions.cs +++ b/Source/LibationCli/Options/ScanOptions.cs @@ -32,7 +32,7 @@ namespace LibationCli : $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account."; Console.WriteLine(intro); - var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync((a) => ApiExtended.CreateAsync(a), _accounts); + var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync(_accounts); Console.WriteLine("Scan complete."); Console.WriteLine($"Total processed: {TotalBooksProcessed}"); diff --git a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs index 55d48f36..48724c7e 100644 --- a/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs +++ b/Source/LibationWinForms/Dialogs/Login/WinformLoginChoiceEager.cs @@ -9,17 +9,11 @@ namespace LibationWinForms.Login { public class WinformLoginChoiceEager : WinformLoginBase, ILoginChoiceEager { - /// Convenience method. Recommended when wiring up Winforms to - public static Func> CreateApiExtendedFunc(IWin32Window owner) => a => ApiExtendedFunc(a, owner); - - private static async Task ApiExtendedFunc(Account account, IWin32Window owner) - => await ApiExtended.CreateAsync(account, new WinformLoginChoiceEager(account, owner)); - public ILoginCallback LoginCallback { get; private set; } private Account _account { get; } - private WinformLoginChoiceEager(Account account, IWin32Window owner) : base(owner) + public WinformLoginChoiceEager(Account account, IWin32Window owner) : base(owner) { _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account)); LoginCallback = new WinformLoginCallback(_account, owner); diff --git a/Source/LibationWinForms/Form1.ScanAuto.cs b/Source/LibationWinForms/Form1.ScanAuto.cs index 6b671c6a..2d3636bf 100644 --- a/Source/LibationWinForms/Form1.ScanAuto.cs +++ b/Source/LibationWinForms/Form1.ScanAuto.cs @@ -32,7 +32,7 @@ namespace LibationWinForms // in autoScan, new books SHALL NOT show dialog try { - Task importAsync() => LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), accounts); + Task importAsync() => LibraryCommands.ImportAccountAsync(accounts); if (InvokeRequired) await Invoke(importAsync); else diff --git a/Source/LibationWinForms/Form1.ScanManual.cs b/Source/LibationWinForms/Form1.ScanManual.cs index 83f5149f..851ac9d9 100644 --- a/Source/LibationWinForms/Form1.ScanManual.cs +++ b/Source/LibationWinForms/Form1.ScanManual.cs @@ -74,7 +74,7 @@ namespace LibationWinForms { try { - var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), accounts); + var (totalProcessed, newAdded) = await LibraryCommands.ImportAccountAsync(accounts); // this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop if (Configuration.Instance.ShowImportedStats && newAdded > 0) diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index 876e2ca8..f8413dcb 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -4,8 +4,11 @@ using System.Linq; using System.Threading.Tasks; using System.Windows.Forms; using ApplicationServices; +using AudibleUtilities; using DataLayer; using LibationFileManager; +using LibationWinForms.Login; +using Octokit; namespace LibationWinForms { @@ -56,6 +59,7 @@ namespace LibationWinForms => Invoke(() => productsDisplay.DisplayAsync(fullLibrary)); } Shown += Form1_Shown; + ApiExtended.LoginChoiceFactory = account => new WinformLoginChoiceEager(account, this); } private void Form1_FormClosing(object sender, FormClosingEventArgs e) diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 37f16e24..4844ec6a 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -351,7 +351,7 @@ namespace LibationWinForms.GridView .Select(lbe => lbe.LibraryBook) .Where(lb => !lb.Book.HasLiberated()); - var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), lib, accounts); + var removedBooks = await LibraryCommands.FindInactiveBooks(lib, accounts); var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); From ef67ae9d6a292fe5bd69a7df588888751d9bc162 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 9 May 2025 17:51:48 -0600 Subject: [PATCH 9/9] Ask users to clear the accounts when enabling widevine (#1249) --- .../Controls/Settings/Audio.axaml.cs | 25 ++++++++++++++++--- .../Configuration.PersistentSettings.cs | 2 +- .../Dialogs/SettingsDialog.AudioSettings.cs | 24 +++++++++++++++--- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs index 6f247581..8cb6af48 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs @@ -31,10 +31,29 @@ namespace LibationAvalonia.Controls.Settings if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) { if (VisualRoot is Window parent) - await MessageBox.Show(parent, - "Your must remove account(s) from Libation and then re-add them to enable widwvine content.", + { + var choice = await MessageBox.Show(parent, + "In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" + + "Do you want Libation to clear your current account settings and prompt you to login before the next download?", "Widevine Content Unavailable", - MessageBoxButtons.OK); + MessageBoxButtons.YesNo, + MessageBoxIcon.Question, + MessageBoxDefaultButton.Button2); + + if (choice == DialogResult.Yes) + { + foreach (var account in accounts.AccountsSettings.Accounts.ToArray()) + { + if (account.IdentityTokens.DeviceType != AudibleApi.Resources.DeviceType) + { + accounts.AccountsSettings.Delete(account); + var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name); + acc.AccountName = account.AccountName; + } + } + return; + } + } _viewModel.UseWidevine = false; } diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index 7da1c25c..71aeeb40 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -257,7 +257,7 @@ namespace LibationFileManager } [Description("Use widevine DRM")] - public bool UseWidevine { get => GetNonString(defaultValue: true); set => SetNonString(value); } + public bool UseWidevine { get => GetNonString(defaultValue: false); set => SetNonString(value); } [Description("Request Spatial Audio")] public bool RequestSpatial { get => GetNonString(defaultValue: true); set => SetNonString(value); } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs index c894e5ca..bb954ce1 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs @@ -206,10 +206,28 @@ namespace LibationWinForms.Dialogs if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) { - MessageBox.Show(this, - "Your must remove account(s) from Libation and then re-add them to enable widwvine content.", + var choice = MessageBox.Show(this, + "In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" + + "Do you want Libation to clear your current account settings and prompt you to login before the next download?", "Widevine Content Unavailable", - MessageBoxButtons.OK); + MessageBoxButtons.YesNo, + MessageBoxIcon.Question, + MessageBoxDefaultButton.Button2); + + if (choice == DialogResult.Yes) + { + foreach (var account in accounts.AccountsSettings.Accounts.ToArray()) + { + if (account.IdentityTokens.DeviceType != AudibleApi.Resources.DeviceType) + { + accounts.AccountsSettings.Delete(account); + var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name); + acc.AccountName = account.AccountName; + } + } + + return; + } useWidevineCbox.Checked = false; return;