Merge pull request #1250 from Mbucari/master

Bug fixes and a change to license request logic
This commit is contained in:
rmcrackan 2025-05-09 21:08:19 -04:00 committed by GitHub
commit 7f1b357c52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 304 additions and 231 deletions

View File

@ -1,5 +1,6 @@
using AAXClean; using AAXClean;
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace AaxDecrypter namespace AaxDecrypter
@ -8,7 +9,7 @@ namespace AaxDecrypter
{ {
public event EventHandler<AppleTags> RetrievedMetadata; public event EventHandler<AppleTags> RetrievedMetadata;
protected Mp4File AaxFile { get; private set; } public Mp4File AaxFile { get; private set; }
protected Mp4Operation AaxConversion { get; set; } protected Mp4Operation AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
@ -33,20 +34,33 @@ namespace AaxDecrypter
{ {
if (DownloadOptions.InputType is FileType.Dash) 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); 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; return dash;
} }
else if (DownloadOptions.InputType is FileType.Aax) else if (DownloadOptions.InputType is FileType.Aax)
{ {
var aax = new AaxFile(InputFileStream); var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.AudibleKey); aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1);
return aax; return aax;
} }
else if (DownloadOptions.InputType is FileType.Aaxc) else if (DownloadOptions.InputType is FileType.Aaxc)
{ {
var aax = new AaxFile(InputFileStream); var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV); aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2);
return aax; return aax;
} }
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown."); else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");

View File

@ -73,11 +73,16 @@ namespace AaxDecrypter
AsyncSteps[$"Cleanup"] = CleanupAsync; AsyncSteps[$"Cleanup"] = CleanupAsync;
(bool success, var elapsed) = await AsyncSteps.RunAsync(); (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; await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed; var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
nfsPersister.Dispose();
return success; return success;
async Task reportProgress() async Task reportProgress()
@ -177,7 +182,7 @@ namespace AaxDecrypter
FileUtility.SaferDelete(jsonDownloadState); FileUtility.SaferDelete(jsonDownloadState);
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) && if (DownloadOptions.DecryptionKeys != null &&
DownloadOptions.RetainEncryptedFile && DownloadOptions.RetainEncryptedFile &&
DownloadOptions.InputType is AAXClean.FileType fileType) DownloadOptions.InputType is AAXClean.FileType fileType)
{ {
@ -188,22 +193,29 @@ namespace AaxDecrypter
if (fileType is AAXClean.FileType.Aax) 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"); aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
} }
else if (fileType is AAXClean.FileType.Aaxc) 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"); aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
} }
else if (fileType is AAXClean.FileType.Dash) 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"); aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
} }
else else
throw new InvalidOperationException($"Unknown file type: {fileType}"); throw new InvalidOperationException($"Unknown file type: {fileType}");
if (tempFilePath != aaxPath)
FileUtility.SaferMove(tempFilePath, aaxPath);
OnFileCreated(aaxPath); OnFileCreated(aaxPath);
OnFileCreated(keyPath); OnFileCreated(keyPath);
} }

View File

@ -2,15 +2,35 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter namespace AaxDecrypter
{ {
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 public interface IDownloadOptions
{ {
event EventHandler<long> DownloadSpeedChanged; event EventHandler<long> DownloadSpeedChanged;
string DownloadUrl { get; } string DownloadUrl { get; }
string UserAgent { get; } string UserAgent { get; }
string AudibleKey { get; } KeyData[]? DecryptionKeys { get; }
string AudibleIV { get; }
TimeSpan RuntimeLength { get; } TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; } OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; } bool TrimOutputToChapterLength { get; }
@ -21,14 +41,14 @@ namespace AaxDecrypter
long DownloadSpeedBps { get; } long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; } ChapterInfo ChapterInfo { get; }
bool FixupFile { get; } bool FixupFile { get; }
string AudibleProductId { get; } string? AudibleProductId { get; }
string Title { get; } string? Title { get; }
string Subtitle { get; } string? Subtitle { get; }
string Publisher { get; } string? Publisher { get; }
string Language { get; } string? Language { get; }
string SeriesName { get; } string? SeriesName { get; }
float? SeriesNumber { get; } float? SeriesNumber { get; }
NAudio.Lame.LameConfig LameConfig { get; } NAudio.Lame.LameConfig? LameConfig { get; }
bool Downsample { get; } bool Downsample { get; }
bool MatchSourceBitrate { get; } bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; } bool MoveMoovToBeginning { get; }

View File

@ -110,14 +110,16 @@ namespace AaxDecrypter
#region Downloader #region Downloader
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary> /// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
private void OnUpdate() private void OnUpdate(bool waitForWrite = false)
{ {
try try
{ {
if (DateTime.UtcNow > NextUpdateTime) if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
{ {
Updated?.Invoke(this, EventArgs.Empty); Updated?.Invoke(this, EventArgs.Empty);
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds //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); NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
} }
} }
@ -305,7 +307,7 @@ namespace AaxDecrypter
finally finally
{ {
_downloadedPiece.Set(); _downloadedPiece.Set();
OnUpdate(); OnUpdate(waitForWrite: true);
} }
} }
@ -402,7 +404,7 @@ namespace AaxDecrypter
_cancellationSource?.Dispose(); _cancellationSource?.Dispose();
_readFile.Dispose(); _readFile.Dispose();
_writeFile.Dispose(); _writeFile.Dispose();
OnUpdate(); OnUpdate(waitForWrite: true);
} }
disposed = true; disposed = true;

View File

@ -32,7 +32,7 @@ namespace ApplicationServices
ScanEnd += (_, __) => Scanning = false; ScanEnd += (_, __) => Scanning = false;
} }
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts) public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
{ {
logRestart(); logRestart();
@ -58,7 +58,7 @@ namespace ApplicationServices
try try
{ {
logTime($"pre {nameof(scanAccountsAsync)} all"); logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all"); logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count; var totalCount = libraryItems.Count;
@ -101,7 +101,7 @@ namespace ApplicationServices
} }
#region FULL LIBRARY scan and import #region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[]? accounts) public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
{ {
logRestart(); logRestart();
@ -131,7 +131,7 @@ namespace ApplicationServices
| LibraryOptions.ResponseGroupOptions.IsFinished, | LibraryOptions.ResponseGroupOptions.IsFinished,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215 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"); logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count; var totalCount = importItems.Count;
@ -262,7 +262,7 @@ namespace ApplicationServices
return null; return null;
} }
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
{ {
var tasks = new List<Task<List<ImportItem>>>(); var tasks = new List<Task<List<ImportItem>>>();
@ -278,7 +278,7 @@ namespace ApplicationServices
try try
{ {
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll) // 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 // add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver)); tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));

View File

@ -11,11 +11,13 @@ using Polly;
using Polly.Retry; using Polly.Retry;
using System.Threading; using System.Threading;
#nullable enable
namespace AudibleUtilities namespace AudibleUtilities
{ {
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary> /// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public class ApiExtended public class ApiExtended
{ {
public static Func<Account, ILoginChoiceEager>? LoginChoiceFactory { get; set; }
public Api Api { get; private set; } public Api Api { get; private set; }
private const int MaxConcurrency = 10; private const int MaxConcurrency = 10;
@ -24,52 +26,46 @@ namespace AudibleUtilities
private ApiExtended(Api api) => Api = api; private ApiExtended(Api api) => Api = api;
/// <summary>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.</summary> /// <summary>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.</summary>
public static async Task<ApiExtended> 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);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(Account account) public static async Task<ApiExtended> CreateAsync(Account account)
{ {
ArgumentValidator.EnsureNotNull(account, nameof(account)); ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
try
{
Serilog.Log.Logger.Information("{@DebugInfo}", new Serilog.Log.Logger.Information("{@DebugInfo}", new
{ {
AccountMaskedLogEntry = account.MaskedLogEntry AccountMaskedLogEntry = account.MaskedLogEntry
}); });
return await CreateAsync(account.AccountId, account.Locale.Name); var api = await EzApiCreator.GetApiAsync(
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
} }
catch
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
{ {
if (LoginChoiceFactory is null)
throw new InvalidOperationException($"The UI module must first set {LoginChoiceFactory} before attempting to create the api");
Serilog.Log.Logger.Information("{@DebugInfo}", new Serilog.Log.Logger.Information("{@DebugInfo}", new
{ {
Username = username.ToMask(), LoginType = nameof(ILoginChoiceEager),
LocaleName = localeName, Account = account.MaskedLogEntry ?? "[null]",
LocaleName = account.Locale?.Name
}); });
var api = await EzApiCreator.GetApiAsync( var api = await EzApiCreator.GetApiAsync(
Localization.Get(localeName), LoginChoiceFactory(account),
account.Locale,
AudibleApiStorage.AccountsSettingsFile, AudibleApiStorage.AccountsSettingsFile,
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName)); account.GetIdentityTokensJsonPath());
return new ApiExtended(api); return new ApiExtended(api);
} }
}
private static AsyncRetryPolicy policy { get; } private static AsyncRetryPolicy policy { get; }
= Policy.Handle<Exception>() = Policy.Handle<Exception>()

View File

@ -55,7 +55,7 @@ public class WidevineKey
Type = (KeyType)type; Type = (KeyType)type;
Key = key; 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 public partial class Cdm
@ -192,7 +192,7 @@ public partial class Cdm
id = id.Append(new byte[16 - id.Length]); 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; return keys;
} }

View File

@ -171,6 +171,29 @@ namespace FileLiberator
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options) if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
return; 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<AAXClean.Chapter>;
var lastChapter = chapters[^1];
chapters.Remove(lastChapter);
options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta);
#endregion
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle; tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
tags.Album ??= tags.Title; tags.Album ??= tags.Title;
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));

View File

@ -41,26 +41,38 @@ public partial class DownloadOptions
return options; return options;
} }
private static async Task<ContentLicense> 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<KeyData>? 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<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
{ {
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) 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);
ContentLicense? contentLic = null, fallback = null; return new LicenseInfo(license);
}
try try
{ {
//try to request a widevine content license using the user's spatial audio settings //try to request a widevine content license using the user's spatial audio settings
var codecChoice = config.SpatialAudioCodec switch var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec;
{
Configuration.SpatialCodec.EC_3 => Ec3Codec,
Configuration.SpatialCodec.AC_4 => Ac4Codec,
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
};
contentLic var contentLic
= await api.GetDownloadLicenseAsync( = await api.GetDownloadLicenseAsync(
libraryBook.Book.AudibleProductId, libraryBook.Book.AudibleProductId,
dlQuality, dlQuality,
@ -68,51 +80,10 @@ public partial class DownloadOptions
DrmType.Widevine, DrmType.Widevine,
config.RequestSpatial, config.RequestSpatial,
codecChoice); codecChoice);
}
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);
}
if (!contentLic.ContentMetadata.ContentReference.IsSpatial && contentLic.DrmType != DrmType.Adrm) if (contentLic.DrmType is not DrmType.Widevine)
{ return new LicenseInfo(contentLic);
/*
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 client = new HttpClient();
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse); using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream()); var dash = new MpegDash(mpdResponse.Content.ReadAsStream());
@ -126,61 +97,33 @@ public partial class DownloadOptions
var challenge = session.GetLicenseChallenge(dash); var challenge = session.GetLicenseChallenge(dash);
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge); var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
var keys = session.ParseLicense(licenseMessage); var keys = session.ParseLicense(licenseMessage);
contentLic.Voucher = new VoucherDtoV10() return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key)));
{
Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()),
Iv = Convert.ToHexStringLower(keys[0].Key)
};
} }
catch catch (Exception ex)
{ {
if (fallback != null) Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
return fallback; //We failed to get a widevine content license. Depending on the
//failure reason, users can potentially still download this audiobook
//We won't have a fallback if the requested license is for a spatial audio file. //by disabling the "Use Widevine DRM" feature.
//Throw so that the user is aware that spatial audio exists and that they were not able to download it.
throw; 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 long chapterStartMs
= config.StripAudibleBrandAudio = config.StripAudibleBrandAudio
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs ? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0; : 0;
AAXClean.FileType? inputType var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
= 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)
{ {
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)), 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(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
if (TryGetAudioInfo(contentLic.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
{ {
dlOptions.LibraryBookDto.BitRate = bitrate; dlOptions.LibraryBookDto.BitRate = bitrate;
dlOptions.LibraryBookDto.SampleRate = sampleRate; dlOptions.LibraryBookDto.SampleRate = sampleRate;
@ -189,7 +132,7 @@ public partial class DownloadOptions
var titleConcat = config.CombineNestedChapterTitles ? ": " : null; var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters var chapters
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat) = flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
.OrderBy(c => c.StartOffsetMs) .OrderBy(c => c.StartOffsetMs)
.ToList(); .ToList();
@ -205,7 +148,7 @@ public partial class DownloadOptions
chapLenMs -= chapterStartMs; chapLenMs -= chapterStartMs;
if (config.StripAudibleBrandAudio && i == chapters.Count - 1) 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)); dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
} }

View File

@ -9,47 +9,47 @@ using System.IO;
using ApplicationServices; using ApplicationServices;
using LibationFileManager.Templates; using LibationFileManager.Templates;
#nullable enable
namespace FileLiberator namespace FileLiberator
{ {
public partial class DownloadOptions : IDownloadOptions, IDisposable public partial class DownloadOptions : IDownloadOptions, IDisposable
{ {
public event EventHandler<long> DownloadSpeedChanged; public event EventHandler<long>? DownloadSpeedChanged;
public LibraryBook LibraryBook { get; } public LibraryBook LibraryBook { get; }
public LibraryBookDto LibraryBookDto { get; } public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; } public string DownloadUrl { get; }
public string AudibleKey { get; init; } public KeyData[]? DecryptionKeys { get; }
public string AudibleIV { get; init; } public required TimeSpan RuntimeLength { get; init; }
public TimeSpan RuntimeLength { get; init; } public OutputFormat OutputFormat { get; }
public OutputFormat OutputFormat { get; init; } public required ChapterInfo ChapterInfo { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public string Title => LibraryBook.Book.Title; public string Title => LibraryBook.Book.Title;
public string Subtitle => LibraryBook.Book.Subtitle; public string Subtitle => LibraryBook.Book.Subtitle;
public string Publisher => LibraryBook.Book.Publisher; public string Publisher => LibraryBook.Book.Publisher;
public string Language => LibraryBook.Book.Language; public string Language => LibraryBook.Book.Language;
public string AudibleProductId => LibraryBookDto.AudibleProductId; public string? AudibleProductId => LibraryBookDto.AudibleProductId;
public string SeriesName => LibraryBookDto.FirstSeries?.Name; public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number; 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 string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio; public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio;
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
public bool CreateCueSheet => config.CreateCueSheet; public bool CreateCueSheet => Config.CreateCueSheet;
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks; public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks;
public long DownloadSpeedBps => config.DownloadSpeedLimit; public long DownloadSpeedBps => Config.DownloadSpeedLimit;
public bool RetainEncryptedFile => config.RetainAaxFile; public bool RetainEncryptedFile => Config.RetainAaxFile;
public bool FixupFile => config.AllowLibationFixup; public bool FixupFile => Config.AllowLibationFixup;
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono; public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate; public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
public bool MoveMoovToBeginning => config.MoveMoovToBeginning; public bool MoveMoovToBeginning => Config.MoveMoovToBeginning;
public AAXClean.FileType? InputType { get; init; } public AAXClean.FileType? InputType { get; }
public AudibleApi.Common.DrmType DrmType { get; init; } public AudibleApi.Common.DrmType DrmType { get; }
public AudibleApi.Common.ContentMetadata ContentMetadata { get; init; } public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
public string GetMultipartFileName(MultiConvertFileProperties props) public string GetMultipartFileName(MultiConvertFileProperties props)
{ {
var baseDir = Path.GetDirectoryName(props.OutputFileName); var baseDir = Path.GetDirectoryName(props.OutputFileName);
var extension = Path.GetExtension(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) public string GetMultipartTitle(MultiConvertFileProperties props)
@ -59,7 +59,7 @@ namespace FileLiberator
{ {
if (DownloadClipsBookmarks) if (DownloadClipsBookmarks)
{ {
var format = config.ClipsBookmarksFileFormat; var format = Config.ClipsBookmarksFileFormat;
var formatExtension = format.ToString().ToLowerInvariant(); var formatExtension = format.ToString().ToLowerInvariant();
var filePath = Path.ChangeExtension(fileName, formatExtension); var filePath = Path.ChangeExtension(fileName, formatExtension);
@ -84,7 +84,7 @@ namespace FileLiberator
return string.Empty; return string.Empty;
} }
private readonly Configuration config; public Configuration Config { get; }
private readonly IDisposable cancellation; private readonly IDisposable cancellation;
public void Dispose() public void Dispose()
{ {
@ -92,14 +92,38 @@ namespace FileLiberator
GC.SuppressFinalize(this); 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)); 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 = LibraryBook.ToDto();
LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec;
cancellation = cancellation =
config config

View File

@ -20,9 +20,15 @@ namespace FileLiberator
account: libraryBook.Account.ToMask() account: libraryBook.Account.ToMask()
); );
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook) public static async Task<AudibleApi.Api> 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; return apiExtended.Api;
} }

View File

@ -31,10 +31,29 @@ namespace LibationAvalonia.Controls.Settings
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
{ {
if (VisualRoot is Window parent) 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", "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; _viewModel.UseWidevine = false;
} }

View File

@ -9,10 +9,6 @@ namespace LibationAvalonia.Dialogs.Login
{ {
public class AvaloniaLoginChoiceEager : ILoginChoiceEager public class AvaloniaLoginChoiceEager : ILoginChoiceEager
{ {
/// <summary>Convenience method. Recommended when wiring up Winforms to <see cref="ApplicationServices.LibraryCommands.ImportAccountAsync"/></summary>
public static async Task<ApiExtended> ApiExtendedFunc(Account account)
=> await ApiExtended.CreateAsync(account, new AvaloniaLoginChoiceEager(account));
public ILoginCallback LoginCallback { get; } public ILoginCallback LoginCallback { get; }
private readonly Account _account; private readonly Account _account;

View File

@ -201,7 +201,7 @@ namespace LibationAvalonia.ViewModels
{ {
try 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 // 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) if (Configuration.Instance.ShowImportedStats && newAdded > 0)

View File

@ -27,7 +27,7 @@ namespace LibationAvalonia.ViewModels
// in autoScan, new books SHALL NOT show dialog // in autoScan, new books SHALL NOT show dialog
try try
{ {
await LibraryCommands.ImportAccountAsync(LibationAvalonia.Dialogs.Login.AvaloniaLoginChoiceEager.ApiExtendedFunc, accounts); await LibraryCommands.ImportAccountAsync(accounts);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

View File

@ -431,7 +431,7 @@ namespace LibationAvalonia.ViewModels
.Select(lbe => lbe.LibraryBook) .Select(lbe => lbe.LibraryBook)
.Where(lb => !lb.Book.HasLiberated()); .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(); var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();

View File

@ -21,6 +21,7 @@ namespace LibationAvalonia.Views
public MainWindow() public MainWindow()
{ {
DataContext = new MainVM(this); DataContext = new MainVM(this);
ApiExtended.LoginChoiceFactory = account => new Dialogs.Login.AvaloniaLoginChoiceEager(account);
AudibleApiStorage.LoadError += AudibleApiStorage_LoadError; AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
InitializeComponent(); InitializeComponent();

View File

@ -32,7 +32,7 @@ namespace LibationCli
: $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account."; : $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account.";
Console.WriteLine(intro); 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("Scan complete.");
Console.WriteLine($"Total processed: {TotalBooksProcessed}"); Console.WriteLine($"Total processed: {TotalBooksProcessed}");

View File

@ -257,7 +257,7 @@ namespace LibationFileManager
} }
[Description("Use widevine DRM")] [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")] [Description("Request Spatial Audio")]
public bool RequestSpatial { get => GetNonString(defaultValue: true); set => SetNonString(value); } public bool RequestSpatial { get => GetNonString(defaultValue: true); set => SetNonString(value); }

View File

@ -41,7 +41,8 @@ public class LibationContributor
GitHubUser("muchtall"), GitHubUser("muchtall"),
GitHubUser("ScubyG"), GitHubUser("ScubyG"),
GitHubUser("patienttruth"), GitHubUser("patienttruth"),
GitHubUser("stickystyle") GitHubUser("stickystyle"),
GitHubUser("cherez"),
]); ]);
private LibationContributor(string name, LibationContributorType type,Uri link) private LibationContributor(string name, LibationContributorType type,Uri link)

View File

@ -9,17 +9,11 @@ namespace LibationWinForms.Login
{ {
public class WinformLoginChoiceEager : WinformLoginBase, ILoginChoiceEager public class WinformLoginChoiceEager : WinformLoginBase, ILoginChoiceEager
{ {
/// <summary>Convenience method. Recommended when wiring up Winforms to <see cref="ApplicationServices.LibraryCommands.ImportAccountAsync"/></summary>
public static Func<Account, Task<ApiExtended>> CreateApiExtendedFunc(IWin32Window owner) => a => ApiExtendedFunc(a, owner);
private static async Task<ApiExtended> ApiExtendedFunc(Account account, IWin32Window owner)
=> await ApiExtended.CreateAsync(account, new WinformLoginChoiceEager(account, owner));
public ILoginCallback LoginCallback { get; private set; } public ILoginCallback LoginCallback { get; private set; }
private Account _account { get; } 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)); _account = Dinah.Core.ArgumentValidator.EnsureNotNull(account, nameof(account));
LoginCallback = new WinformLoginCallback(_account, owner); LoginCallback = new WinformLoginCallback(_account, owner);

View File

@ -206,10 +206,28 @@ namespace LibationWinForms.Dialogs
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
{ {
MessageBox.Show(this, var choice = MessageBox.Show(this,
"Your must remove account(s) from Libation and then re-add them to enable widwvine content.", "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", "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; useWidevineCbox.Checked = false;
return; return;

View File

@ -32,7 +32,7 @@ namespace LibationWinForms
// in autoScan, new books SHALL NOT show dialog // in autoScan, new books SHALL NOT show dialog
try try
{ {
Task importAsync() => LibraryCommands.ImportAccountAsync(Login.WinformLoginChoiceEager.CreateApiExtendedFunc(this), accounts); Task importAsync() => LibraryCommands.ImportAccountAsync(accounts);
if (InvokeRequired) if (InvokeRequired)
await Invoke(importAsync); await Invoke(importAsync);
else else

View File

@ -74,7 +74,7 @@ namespace LibationWinForms
{ {
try 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 // 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) if (Configuration.Instance.ShowImportedStats && newAdded > 0)

View File

@ -4,8 +4,11 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using ApplicationServices; using ApplicationServices;
using AudibleUtilities;
using DataLayer; using DataLayer;
using LibationFileManager; using LibationFileManager;
using LibationWinForms.Login;
using Octokit;
namespace LibationWinForms namespace LibationWinForms
{ {
@ -56,6 +59,7 @@ namespace LibationWinForms
=> Invoke(() => productsDisplay.DisplayAsync(fullLibrary)); => Invoke(() => productsDisplay.DisplayAsync(fullLibrary));
} }
Shown += Form1_Shown; Shown += Form1_Shown;
ApiExtended.LoginChoiceFactory = account => new WinformLoginChoiceEager(account, this);
} }
private void Form1_FormClosing(object sender, FormClosingEventArgs e) private void Form1_FormClosing(object sender, FormClosingEventArgs e)

View File

@ -351,7 +351,7 @@ namespace LibationWinForms.GridView
.Select(lbe => lbe.LibraryBook) .Select(lbe => lbe.LibraryBook)
.Where(lb => !lb.Book.HasLiberated()); .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(); var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();