Merge pull request #1250 from Mbucari/master
Bug fixes and a change to license request logic
This commit is contained in:
commit
7f1b357c52
@ -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.");
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,15 +2,35 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
namespace AaxDecrypter
|
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<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; }
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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,51 +26,45 @@ 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));
|
||||||
|
|
||||||
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);
|
var api = await EzApiCreator.GetApiAsync(
|
||||||
}
|
account.Locale,
|
||||||
|
AudibleApiStorage.AccountsSettingsFile,
|
||||||
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
|
account.GetIdentityTokensJsonPath());
|
||||||
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
|
return new ApiExtended(api);
|
||||||
{
|
}
|
||||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
catch
|
||||||
{
|
{
|
||||||
Username = username.ToMask(),
|
if (LoginChoiceFactory is null)
|
||||||
LocaleName = localeName,
|
throw new InvalidOperationException($"The UI module must first set {LoginChoiceFactory} before attempting to create the api");
|
||||||
});
|
|
||||||
|
|
||||||
var api = await EzApiCreator.GetApiAsync(
|
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||||
Localization.Get(localeName),
|
{
|
||||||
|
LoginType = nameof(ILoginChoiceEager),
|
||||||
|
Account = account.MaskedLogEntry ?? "[null]",
|
||||||
|
LocaleName = account.Locale?.Name
|
||||||
|
});
|
||||||
|
|
||||||
|
var api = await EzApiCreator.GetApiAsync(
|
||||||
|
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; }
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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,119 +80,50 @@ public partial class DownloadOptions
|
|||||||
DrmType.Widevine,
|
DrmType.Widevine,
|
||||||
config.RequestSpatial,
|
config.RequestSpatial,
|
||||||
codecChoice);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
||||||
//We failed to get a widevine license, so fall back to AAX(C)
|
//We failed to get a widevine content license. Depending on the
|
||||||
return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
//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
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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}");
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user