diff --git a/Source/AaxDecrypter/AaxcDownloadConvertBase.cs b/Source/AaxDecrypter/AaxcDownloadConvertBase.cs index f96dfeea..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 @@ -8,7 +9,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) @@ -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 23d12e4c..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,22 +193,29 @@ 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 throw new InvalidOperationException($"Unknown file type: {fileType}"); + if (tempFilePath != aaxPath) + FileUtility.SaferMove(tempFilePath, aaxPath); + OnFileCreated(aaxPath); OnFileCreated(keyPath); } 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/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/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/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index f6a70deb..3ead2a9f 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -171,6 +171,29 @@ 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; + //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); + + #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.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 5934328b..94368534 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -41,26 +41,38 @@ 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); - - ContentLicense? contentLic = null, fallback = null; + { + var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); + return new LicenseInfo(license); + } 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; - contentLic + var contentLic = await api.GetDownloadLicenseAsync( libraryBook.Book.AudibleProductId, dlQuality, @@ -68,119 +80,50 @@ public partial class DownloadOptions DrmType.Widevine, 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()); + + 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); + return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key))); } 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; } - 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; @@ -189,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(); @@ -205,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 3101aa72..3f55abe2 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -9,47 +9,47 @@ 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; - 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; } + 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; } + 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) @@ -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() { @@ -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) { - 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 + 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 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/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/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/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/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) 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/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; 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();