Add spatial audio support
This commit is contained in:
parent
bffaea6026
commit
ece48eb6d7
32 changed files with 15993 additions and 351 deletions
|
|
@ -8,7 +8,7 @@ namespace AaxDecrypter
|
|||
{
|
||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||
|
||||
protected AaxFile AaxFile { get; private set; }
|
||||
protected Mp4File AaxFile { get; private set; }
|
||||
protected Mp4Operation AaxConversion { get; set; }
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
|
|
@ -29,14 +29,34 @@ namespace AaxDecrypter
|
|||
FinalizeDownload();
|
||||
}
|
||||
|
||||
private Mp4File Open()
|
||||
{
|
||||
if (DownloadOptions.InputType is FileType.Dash)
|
||||
{
|
||||
var dash = new DashFile(InputFileStream);
|
||||
dash.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
return dash;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aax)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||
return aax;
|
||||
}
|
||||
else if (DownloadOptions.InputType is FileType.Aaxc)
|
||||
{
|
||||
var aax = new AaxFile(InputFileStream);
|
||||
aax.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
return aax;
|
||||
}
|
||||
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
AaxFile = new AaxFile(InputFileStream);
|
||||
AaxFile = Open();
|
||||
|
||||
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null)
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
|
||||
else
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||
|
||||
if (DownloadOptions.StripUnabridged)
|
||||
{
|
||||
|
|
@ -87,8 +107,6 @@ namespace AaxDecrypter
|
|||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
|
||||
|
||||
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ namespace AaxDecrypter
|
|||
public bool IsCanceled { get; protected set; }
|
||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||
protected string OutputFileName { get; }
|
||||
protected IDownloadOptions DownloadOptions { get; }
|
||||
public IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||
private bool downloadFinished;
|
||||
|
|
@ -178,19 +178,33 @@ namespace AaxDecrypter
|
|||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
|
||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||
DownloadOptions.RetainEncryptedFile)
|
||||
DownloadOptions.RetainEncryptedFile &&
|
||||
DownloadOptions.InputType is AAXClean.FileType fileType)
|
||||
{
|
||||
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
//Write aax decryption key
|
||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||
string keyPath = Path.ChangeExtension(tempFilePath, ".key");
|
||||
FileUtility.SaferDelete(keyPath);
|
||||
string aaxPath;
|
||||
|
||||
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
|
||||
if (fileType is AAXClean.FileType.Aax)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
||||
else
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Aaxc)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
|
||||
}
|
||||
else if (fileType is AAXClean.FileType.Dash)
|
||||
{
|
||||
await File.WriteAllTextAsync(keyPath, $"KeyId={DownloadOptions.AudibleKey}{Environment.NewLine}Key={DownloadOptions.AudibleIV}");
|
||||
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
|
||||
}
|
||||
else
|
||||
throw new InvalidOperationException($"Unknown file type: {fileType}");
|
||||
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
|
|
@ -217,6 +231,7 @@ namespace AaxDecrypter
|
|||
}
|
||||
catch
|
||||
{
|
||||
nfsp?.Target?.Dispose();
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
return nfsp = newNetworkFilePersister();
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ public class AverageSpeed
|
|||
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
|
||||
|
||||
/// <param name="slowWindow">Total moving average time window</param>
|
||||
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||
/// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
|
||||
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
|
||||
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
|
||||
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
|
||||
|
|
@ -119,7 +119,7 @@ public class AverageSpeed
|
|||
/// <summary>Add a new position to the moving average</summary>
|
||||
public void AddPosition(double position)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var now = DateTime.UtcNow;
|
||||
if (start == default)
|
||||
start = now;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,5 +35,6 @@ namespace AaxDecrypter
|
|||
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||
public FileType? InputType { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,18 +55,23 @@ namespace AaxDecrypter
|
|||
private CancellationTokenSource _cancellationSource { get; } = new();
|
||||
private EventWaitHandle _downloadedPiece { get; set; }
|
||||
|
||||
private DateTime NextUpdateTime { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constants
|
||||
|
||||
//Download buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 32 * 1024;
|
||||
//Size of each range request. Android app uses 64MB chunks.
|
||||
private const int RANGE_REQUEST_SZ = 64 * 1024 * 1024;
|
||||
|
||||
//Download memory buffer size
|
||||
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
|
||||
|
||||
//NetworkFileStream will flush all data in _writeFile to disk after every
|
||||
//DATA_FLUSH_SZ bytes are written to the file stream.
|
||||
private const int DATA_FLUSH_SZ = 1024 * 1024;
|
||||
|
||||
//Number of times per second the download rate is checkd and throttled
|
||||
//Number of times per second the download rate is checked and throttled
|
||||
private const int THROTTLE_FREQUENCY = 8;
|
||||
|
||||
//Minimum throttle rate. The minimum amount of data that can be throttled
|
||||
|
|
@ -110,10 +115,14 @@ namespace AaxDecrypter
|
|||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||
private void OnUpdate()
|
||||
{
|
||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||
try
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
if (DateTime.UtcNow > NextUpdateTime)
|
||||
{
|
||||
Updated?.Invoke(this, EventArgs.Empty);
|
||||
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
|
||||
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
@ -135,7 +144,6 @@ namespace AaxDecrypter
|
|||
throw new InvalidOperationException("Cannot change Uri after download has started.");
|
||||
|
||||
Uri = uriToSameFile;
|
||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||
}
|
||||
|
||||
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
|
||||
|
|
@ -151,39 +159,82 @@ namespace AaxDecrypter
|
|||
if (ContentLength != 0 && WritePosition > ContentLength)
|
||||
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
//Initiate connection with the first request block and
|
||||
//get the total content length before returning.
|
||||
using var client = new HttpClient();
|
||||
var response = await RequestNextByteRangeAsync(client);
|
||||
|
||||
if (ContentLength != 0 && ContentLength != response.FileSize)
|
||||
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
|
||||
|
||||
ContentLength = response.FileSize;
|
||||
|
||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
//Hand off the open request to the downloader to download and write data to file.
|
||||
DownloadTask = Task.Run(() => DownloadLoopInternal(response), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
private async Task DownloadLoopInternal(BlockResponse initialResponse)
|
||||
{
|
||||
await DownloadToFile(initialResponse);
|
||||
initialResponse.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
while (WritePosition < ContentLength && !IsCancelled)
|
||||
{
|
||||
using var response = await RequestNextByteRangeAsync(client);
|
||||
await DownloadToFile(response);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeFile.Close();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
|
||||
|
||||
foreach (var header in RequestHeaders)
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
|
||||
var response = await new HttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||
request.Headers.Add("Range", $"bytes={WritePosition}-{WritePosition + RANGE_REQUEST_SZ - 1}");
|
||||
|
||||
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
|
||||
|
||||
//Content length is the length of the range request, and it is only equal
|
||||
//to the complete file length if requesting Range: bytes=0-
|
||||
if (WritePosition == 0)
|
||||
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
|
||||
var totalSize = response.Content.Headers.ContentRange?.Length ??
|
||||
throw new WebException("The response did not contain a total content length.");
|
||||
|
||||
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
var rangeSize = response.Content.Headers.ContentLength ??
|
||||
throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
|
||||
|
||||
//Download the file in the background.
|
||||
return new BlockResponse(response, rangeSize, totalSize);
|
||||
}
|
||||
|
||||
DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
||||
private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
|
||||
{
|
||||
public void Dispose() => Response?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||
private async Task DownloadFile(Stream networkStream)
|
||||
private async Task DownloadToFile(BlockResponse block)
|
||||
{
|
||||
var endPosition = WritePosition + block.BlockSize;
|
||||
var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
|
||||
|
||||
var downloadPosition = WritePosition;
|
||||
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
var buff = new byte[DOWNLOAD_BUFF_SZ];
|
||||
|
||||
try
|
||||
{
|
||||
DateTime startTime = DateTime.Now;
|
||||
DateTime startTime = DateTime.UtcNow;
|
||||
long bytesReadSinceThrottle = 0;
|
||||
int bytesRead;
|
||||
do
|
||||
|
|
@ -218,14 +269,15 @@ namespace AaxDecrypter
|
|||
|
||||
#endregion
|
||||
|
||||
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
|
||||
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
|
||||
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
|
||||
if (!IsCancelled && WritePosition < ContentLength)
|
||||
if (!IsCancelled && WritePosition < endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
|
||||
if (WritePosition > ContentLength)
|
||||
if (WritePosition > endPosition)
|
||||
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
|
|
@ -235,7 +287,6 @@ namespace AaxDecrypter
|
|||
finally
|
||||
{
|
||||
networkStream.Close();
|
||||
_writeFile.Close();
|
||||
_downloadedPiece.Set();
|
||||
OnUpdate();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,22 @@ namespace AudibleUtilities
|
|||
update_no_validate();
|
||||
}
|
||||
}
|
||||
|
||||
private string _cdm;
|
||||
[JsonProperty]
|
||||
public string Cdm
|
||||
{
|
||||
get => _cdm;
|
||||
set
|
||||
{
|
||||
if (value is null)
|
||||
return;
|
||||
|
||||
_cdm = value;
|
||||
update_no_validate();
|
||||
}
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
|
||||
#endregion
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="9.3.2.1" />
|
||||
<PackageReference Include="AudibleApi" Version="9.4.0.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.30.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
@ -20,4 +21,9 @@
|
|||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Widevine\Cdm.*.cs">
|
||||
<DependentUpon>Cdm.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
189
Source/AudibleUtilities/Widevine/Cdm.Api.cs
Normal file
189
Source/AudibleUtilities/Widevine/Cdm.Api.cs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
using AudibleApi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net.Http;
|
||||
using AudibleApi.Cryptography;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Dinah.Core.Net.Http;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
public partial class Cdm
|
||||
{
|
||||
/// <summary>
|
||||
/// Get a <see cref="Cdm"/> from <see cref="AccountsSettings"/> or from the API.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Cdm"/> if successful, otherwise <see cref="null"/></returns>
|
||||
public static async Task<Cdm?> GetCdmAsync()
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
//Check if there are any Android accounts. If not, we can't use Widevine.
|
||||
if (!persister.Target.Accounts.Any(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrEmpty(persister.Target.Cdm))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cdm = Convert.FromBase64String(persister.Target.Cdm);
|
||||
return new Cdm(new Device(cdm));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error loading CDM from account settings.");
|
||||
persister.Target.Cdm = string.Empty;
|
||||
//Clear the stored Cdm and try getting a fresh one from the server.
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(persister.Target.Cdm))
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
if (await GetCdmUris(client) is not Uri[] uris)
|
||||
return null;
|
||||
|
||||
//try to get a CDM file for any account that's registered as an android device.
|
||||
//CDMs are not account-specific, so it doesn't matter which account we're successful with.
|
||||
foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestMessage = CreateApiRequest(account);
|
||||
|
||||
await TestApiRequest(client, new JsonObject { { "body", requestMessage.ToString() } });
|
||||
|
||||
//Try all CDM URIs until a CDM has been retrieved successfully
|
||||
foreach (var uri in uris)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resp = await client.PostAsync(uri, ((HttpBody)requestMessage).Content);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var message = await resp.Content.ReadAsStringAsync();
|
||||
throw new ApiErrorException(uri, null, message);
|
||||
}
|
||||
|
||||
var cdmBts = await resp.Content.ReadAsByteArrayAsync();
|
||||
var device = new Device(cdmBts);
|
||||
persister.Target.Cdm = Convert.ToBase64String(cdmBts);
|
||||
return new Cdm(device);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error getting a CDM from URI: " + uri);
|
||||
//try the next URI
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error getting a CDM for account: " + account.MaskedLogEntry);
|
||||
//try the next Account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a list of CDM API URIs from the main Gitgub repository's .cdmurls.json file.
|
||||
/// </summary>
|
||||
/// <returns>If successful, an array of URIs to try. Otherwise null</returns>
|
||||
private static async Task<Uri[]?> GetCdmUris(HttpClient httpClient)
|
||||
{
|
||||
const string CdmUrlListFile = "https://raw.githubusercontent.com/rmcrackan/Libation/refs/heads/master/.cdmurls.json";
|
||||
|
||||
try
|
||||
{
|
||||
var fileContents = await httpClient.GetStringAsync(CdmUrlListFile);
|
||||
var releaseIndex = JObject.Parse(fileContents);
|
||||
var urlArray = releaseIndex["CdmUrls"] as JArray;
|
||||
if (urlArray is null)
|
||||
throw new System.IO.InvalidDataException("CDM url list not found in JSON: " + fileContents);
|
||||
|
||||
var uris = urlArray.Select(u => u.Value<string>()).OfType<string>().Select(u => new Uri(u)).ToArray();
|
||||
|
||||
if (uris.Length == 0)
|
||||
throw new System.IO.InvalidDataException("No CDM url found in JSON: " + fileContents);
|
||||
|
||||
return uris;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error getting CDM URLs");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static readonly string[] TLDs = ["com", "co.uk", "com.au", "com.br", "ca", "fr", "de", "in", "it", "co.jp", "es"];
|
||||
|
||||
//Ensure that the request can be made successfully before sending it to the API
|
||||
//The API uses System.Text.Json, so perform test with same.
|
||||
private static async Task TestApiRequest(HttpClient client, JsonObject input)
|
||||
{
|
||||
if (input["body"]?.GetValue<string>() is not string body
|
||||
|| JsonNode.Parse(body) is not JsonNode bodyJson)
|
||||
throw new Exception("Api request doesn't contain a body");
|
||||
|
||||
if (bodyJson?["Url"]?.GetValue<string>() is not string url
|
||||
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
throw new Exception("Api request doesn't contain a url");
|
||||
|
||||
if (!TLDs.Select(tld => "api.audible." + tld).Contains(uri.Host.ToLower()))
|
||||
throw new Exception($"Unknown Audible Api domain: {uri.Host}");
|
||||
|
||||
if (bodyJson?["Headers"] is not JsonObject headers)
|
||||
throw new Exception($"Api request doesn't contain any headers");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
|
||||
Dictionary<string, string>? headersDict = null;
|
||||
try
|
||||
{
|
||||
headersDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(headers);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new Exception("Failed to read Audible Api headers.", ex);
|
||||
}
|
||||
|
||||
if (headersDict is null)
|
||||
throw new Exception("Failed to read Audible Api headers.");
|
||||
|
||||
foreach (var kvp in headersDict)
|
||||
request.Headers.Add(kvp.Key, kvp.Value);
|
||||
|
||||
using var resp = await client.SendAsync(request);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a request body to send to the API
|
||||
/// </summary>
|
||||
/// <param name="account">An authenticated account</param>
|
||||
private static JObject CreateApiRequest(Account account)
|
||||
{
|
||||
const string ACCOUNT_INFO_PATH = "/1.0/account/information";
|
||||
|
||||
var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH);
|
||||
message.SignRequest(
|
||||
DateTime.UtcNow,
|
||||
account.IdentityTokens.AdpToken,
|
||||
account.IdentityTokens.PrivateKey);
|
||||
|
||||
return new JObject
|
||||
{
|
||||
{ "Url", new Uri(account.Locale.AudibleApiUri(), ACCOUNT_INFO_PATH) },
|
||||
{ "Headers", JObject.FromObject(message.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Single())) }
|
||||
};
|
||||
}
|
||||
}
|
||||
300
Source/AudibleUtilities/Widevine/Cdm.cs
Normal file
300
Source/AudibleUtilities/Widevine/Cdm.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
using Google.Protobuf;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
public enum KeyType
|
||||
{
|
||||
/// <summary>
|
||||
/// Exactly one key of this type must appear.
|
||||
/// </summary>
|
||||
Signing = 1,
|
||||
/// <summary>
|
||||
/// Content key.
|
||||
/// </summary>
|
||||
Content = 2,
|
||||
/// <summary>
|
||||
/// Key control block for license renewals. No key.
|
||||
/// </summary>
|
||||
KeyControl = 3,
|
||||
/// <summary>
|
||||
/// wrapped keys for auxiliary crypto operations.
|
||||
/// </summary>
|
||||
OperatorSession = 4,
|
||||
/// <summary>
|
||||
/// Entitlement keys.
|
||||
/// </summary>
|
||||
Entitlement = 5,
|
||||
/// <summary>
|
||||
/// Partner-specific content key.
|
||||
/// </summary>
|
||||
OemContent = 6,
|
||||
}
|
||||
|
||||
public interface ISession : IDisposable
|
||||
{
|
||||
string? GetLicenseChallenge(MpegDash dash);
|
||||
WidevineKey[] ParseLicense(string licenseMessage);
|
||||
}
|
||||
|
||||
public class WidevineKey
|
||||
{
|
||||
public Guid Kid { get; }
|
||||
public KeyType Type { get; }
|
||||
public byte[] Key { get; }
|
||||
internal WidevineKey(Guid kid, License.Types.KeyContainer.Types.KeyType type, byte[] key)
|
||||
{
|
||||
Kid = kid;
|
||||
Type = (KeyType)type;
|
||||
Key = key;
|
||||
}
|
||||
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray()).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
|
||||
}
|
||||
|
||||
public partial class Cdm
|
||||
{
|
||||
public static Guid WidevineContentProtection { get; } = new("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
|
||||
private const int MAX_NUM_OF_SESSIONS = 16;
|
||||
internal Device Device { get; }
|
||||
|
||||
private ConcurrentDictionary<Guid, Session> Sessions { get; } = new(-1, MAX_NUM_OF_SESSIONS);
|
||||
|
||||
internal Cdm(Device device)
|
||||
{
|
||||
Device = device;
|
||||
}
|
||||
|
||||
public ISession OpenSession()
|
||||
{
|
||||
if (Sessions.Count == MAX_NUM_OF_SESSIONS)
|
||||
throw new Exception("Too Many Sessions");
|
||||
|
||||
var session = new Session(Sessions.Count + 1, this);
|
||||
|
||||
var ddd = Sessions.TryAdd(session.Id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
#region Session
|
||||
|
||||
internal class Session : ISession
|
||||
{
|
||||
public Guid Id { get; } = Guid.NewGuid();
|
||||
private int SessionNumber { get; }
|
||||
private Cdm Cdm { get; }
|
||||
private byte[]? EncryptionContext { get; set; }
|
||||
private byte[]? AuthenticationContext { get; set; }
|
||||
|
||||
public Session(int number, Cdm cdm)
|
||||
{
|
||||
SessionNumber = number;
|
||||
Cdm = cdm;
|
||||
}
|
||||
|
||||
private string GetRequestId()
|
||||
=> $"{RandomUint():x8}00000000{Convert.ToHexString(BitConverter.GetBytes((long)SessionNumber)).ToLowerInvariant()}";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Cdm.Sessions.ContainsKey(Id))
|
||||
Cdm.Sessions.TryRemove(Id, out var session);
|
||||
}
|
||||
|
||||
public string? GetLicenseChallenge(MpegDash dash)
|
||||
{
|
||||
if (!dash.TryGetPssh(Cdm.WidevineContentProtection, out var pssh))
|
||||
return null;
|
||||
|
||||
var licRequest = new LicenseRequest
|
||||
{
|
||||
ClientId = Cdm.Device.ClientId,
|
||||
ContentId = new()
|
||||
{
|
||||
WidevinePsshData = new()
|
||||
{
|
||||
LicenseType = LicenseType.Offline,
|
||||
RequestId = ByteString.CopyFrom(GetRequestId(), Encoding.ASCII)
|
||||
}
|
||||
},
|
||||
Type = LicenseRequest.Types.RequestType.New,
|
||||
RequestTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
ProtocolVersion = ProtocolVersion.Version21,
|
||||
KeyControlNonce = RandomUint()
|
||||
};
|
||||
|
||||
licRequest.ContentId.WidevinePsshData.PsshData.Add(ByteString.CopyFrom(pssh.InitData));
|
||||
|
||||
var licRequestBts = licRequest.ToByteArray();
|
||||
EncryptionContext = CreateContext("ENCRYPTION", 128, licRequestBts);
|
||||
AuthenticationContext = CreateContext("AUTHENTICATION", 512, licRequestBts);
|
||||
|
||||
var signedMessage = new SignedMessage
|
||||
{
|
||||
Type = SignedMessage.Types.MessageType.LicenseRequest,
|
||||
Msg = ByteString.CopyFrom(licRequestBts),
|
||||
Signature = ByteString.CopyFrom(Cdm.Device.SignMessage(licRequestBts))
|
||||
};
|
||||
|
||||
return Convert.ToBase64String(signedMessage.ToByteArray());
|
||||
}
|
||||
|
||||
public WidevineKey[] ParseLicense(string licenseMessage)
|
||||
{
|
||||
if (EncryptionContext is null || AuthenticationContext is null)
|
||||
throw new InvalidOperationException($"{nameof(GetLicenseChallenge)}() must be called before calling {nameof(ParseLicense)}()");
|
||||
|
||||
var signedMessage = SignedMessage.Parser.ParseFrom(Convert.FromBase64String(licenseMessage));
|
||||
if (signedMessage.Type != SignedMessage.Types.MessageType.License)
|
||||
throw new InvalidDataException("Invalid license");
|
||||
|
||||
var sessionKey = Cdm.Device.DecryptSessionKey(signedMessage.SessionKey.ToByteArray());
|
||||
|
||||
if (!VerifySignature(signedMessage, AuthenticationContext, sessionKey))
|
||||
throw new InvalidDataException("Message signature is invalid");
|
||||
|
||||
var license = License.Parser.ParseFrom(signedMessage.Msg);
|
||||
var keyToTheKeys = DeriveKey(sessionKey, EncryptionContext, 1);
|
||||
|
||||
return DecryptKeys(keyToTheKeys, license.Key);
|
||||
}
|
||||
|
||||
private static WidevineKey[] DecryptKeys(byte[] keyToTheKeys, IList<License.Types.KeyContainer> licenseKeys)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = keyToTheKeys;
|
||||
var keys = new WidevineKey[licenseKeys.Count];
|
||||
|
||||
for (int i = 0; i < licenseKeys.Count; i++)
|
||||
{
|
||||
var keyContainer = licenseKeys[i];
|
||||
|
||||
var keyBytes = aes.DecryptCbc(keyContainer.Key.ToByteArray(), keyContainer.Iv.ToByteArray(), PaddingMode.PKCS7);
|
||||
var id = keyContainer.Id.ToByteArray();
|
||||
|
||||
if (id.Length > 16)
|
||||
{
|
||||
var tryB64 = new byte[id.Length * 3 / 4];
|
||||
if (Convert.TryFromBase64String(Encoding.ASCII.GetString(id), tryB64, out int bytesWritten))
|
||||
{
|
||||
id = tryB64;
|
||||
}
|
||||
Array.Resize(ref id, 16);
|
||||
}
|
||||
else if (id.Length < 16)
|
||||
{
|
||||
id = id.Append(new byte[16 - id.Length]);
|
||||
}
|
||||
|
||||
keys[i] = new WidevineKey(new Guid(id), keyContainer.Type, keyBytes);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static bool VerifySignature(SignedMessage signedMessage, byte[] authContext, byte[] sessionKey)
|
||||
{
|
||||
var mac_key_server = DeriveKey(sessionKey, authContext, 1).Append(DeriveKey(sessionKey, authContext, 2));
|
||||
|
||||
var hmacData = (signedMessage.OemcryptoCoreMessage?.ToByteArray() ?? []).Append(signedMessage.Msg?.ToByteArray() ?? []);
|
||||
|
||||
var computed_signature = HMACSHA256.HashData(mac_key_server, hmacData);
|
||||
|
||||
return computed_signature.SequenceEqual(signedMessage.Signature);
|
||||
}
|
||||
|
||||
private static byte[] DeriveKey(byte[] session_key, byte[] context, int counter)
|
||||
{
|
||||
var data = new byte[context.Length + 1];
|
||||
Array.Copy(context, 0, data, 1, context.Length);
|
||||
data[0] = (byte)counter;
|
||||
|
||||
return AESCMAC(session_key, data);
|
||||
}
|
||||
|
||||
private static byte[] AESCMAC(byte[] key, byte[] data)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = key;
|
||||
|
||||
// SubKey generation
|
||||
// step 1, AES-128 with key K is applied to an all-zero input block.
|
||||
byte[] subKey = aes.EncryptCbc(new byte[16], new byte[16], PaddingMode.None);
|
||||
|
||||
nextSubKey();
|
||||
|
||||
// MAC computing
|
||||
if ((data.Length == 0) || (data.Length % 16 != 0))
|
||||
{
|
||||
// If the size of the input message block is not equal to a positive
|
||||
// multiple of the block size (namely, 128 bits), the last block shall
|
||||
// be padded with 10^i
|
||||
nextSubKey();
|
||||
var padLen = 16 - data.Length % 16;
|
||||
Array.Resize(ref data, data.Length + padLen);
|
||||
data[^padLen] = 0x80;
|
||||
}
|
||||
|
||||
// the last block shall be exclusive-OR'ed with K1 before processing
|
||||
for (int j = 0; j < subKey.Length; j++)
|
||||
data[data.Length - 16 + j] ^= subKey[j];
|
||||
|
||||
// The result of the previous process will be the input of the last encryption.
|
||||
byte[] encResult = aes.EncryptCbc(data, new byte[16], PaddingMode.None);
|
||||
|
||||
byte[] HashValue = new byte[16];
|
||||
Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length);
|
||||
|
||||
return HashValue;
|
||||
|
||||
void nextSubKey()
|
||||
{
|
||||
const byte const_Rb = 0x87;
|
||||
if (Rol(subKey) != 0)
|
||||
subKey[15] ^= const_Rb;
|
||||
|
||||
static int Rol(byte[] b)
|
||||
{
|
||||
int carry = 0;
|
||||
|
||||
for (int i = b.Length - 1; i >= 0; i--)
|
||||
{
|
||||
ushort u = (ushort)(b[i] << 1);
|
||||
b[i] = (byte)((u & 0xff) + carry);
|
||||
carry = (u & 0xff00) >> 8;
|
||||
}
|
||||
return carry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateContext(string label, int keySize, byte[] licRequestBts)
|
||||
{
|
||||
var contextSize = label.Length + 1 + licRequestBts.Length + sizeof(int);
|
||||
|
||||
var context = new byte[contextSize];
|
||||
var numChars = Encoding.ASCII.GetBytes(label.AsSpan(), context);
|
||||
Array.Copy(licRequestBts, 0, context, numChars + 1, licRequestBts.Length);
|
||||
|
||||
var numBts = BitConverter.GetBytes(keySize);
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(numBts);
|
||||
Array.Copy(numBts, 0, context, context.Length - sizeof(int), sizeof(int));
|
||||
return context;
|
||||
}
|
||||
|
||||
private static uint RandomUint()
|
||||
{
|
||||
var bts = new byte[4];
|
||||
new Random().NextBytes(bts);
|
||||
return BitConverter.ToUInt32(bts, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
73
Source/AudibleUtilities/Widevine/Device.cs
Normal file
73
Source/AudibleUtilities/Widevine/Device.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
internal enum DeviceTypes : byte
|
||||
{
|
||||
Unknown = 0,
|
||||
Chrome = 1,
|
||||
Android = 2
|
||||
}
|
||||
|
||||
internal class Device
|
||||
{
|
||||
public DeviceTypes Type { get; }
|
||||
public int FileVersion { get; }
|
||||
public int SecurityLevel { get; }
|
||||
public int Flags { get; }
|
||||
|
||||
public RSA CdmKey { get; }
|
||||
internal ClientIdentification ClientId { get; }
|
||||
|
||||
public Device(Span<byte> fileData)
|
||||
{
|
||||
if (fileData.Length < 7 || fileData[0] != 'W' || fileData[1] != 'V' || fileData[2] != 'D')
|
||||
throw new InvalidDataException();
|
||||
|
||||
FileVersion = fileData[3];
|
||||
Type = (DeviceTypes)fileData[4];
|
||||
SecurityLevel = fileData[5];
|
||||
Flags = fileData[6];
|
||||
|
||||
if (FileVersion != 2)
|
||||
throw new InvalidDataException($"Unknown CDM File Version: '{FileVersion}'");
|
||||
if (Type != DeviceTypes.Android)
|
||||
throw new InvalidDataException($"Unknown CDM Type: '{Type}'");
|
||||
if (SecurityLevel != 3)
|
||||
throw new InvalidDataException($"Unknown CDM Security Level: '{SecurityLevel}'");
|
||||
|
||||
var privateKeyLength = (fileData[7] << 8) | fileData[8];
|
||||
|
||||
if (privateKeyLength <= 0 || fileData.Length < 9 + privateKeyLength + 2)
|
||||
throw new InvalidDataException($"Invalid private key length: '{privateKeyLength}'");
|
||||
|
||||
var clientIdLength = (fileData[9 + privateKeyLength] << 8) | fileData[10 + privateKeyLength];
|
||||
|
||||
if (clientIdLength <= 0 || fileData.Length < 11 + privateKeyLength + clientIdLength)
|
||||
throw new InvalidDataException($"Invalid client id length: '{clientIdLength}'");
|
||||
|
||||
ClientId = ClientIdentification.Parser.ParseFrom(fileData.Slice(11 + privateKeyLength));
|
||||
CdmKey = RSA.Create();
|
||||
CdmKey.ImportRSAPrivateKey(fileData.Slice(9, privateKeyLength), out _);
|
||||
}
|
||||
|
||||
public byte[] SignMessage(byte[] message)
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
var digestion = sha1.ComputeHash(message);
|
||||
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||
}
|
||||
|
||||
public bool VerifyMessage(byte[] message, byte[] signature)
|
||||
{
|
||||
using var sha1 = SHA1.Create();
|
||||
var digestion = sha1.ComputeHash(message);
|
||||
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
|
||||
}
|
||||
|
||||
public byte[] DecryptSessionKey(byte[] sessionKey)
|
||||
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
|
||||
}
|
||||
15
Source/AudibleUtilities/Widevine/Extensions.cs
Normal file
15
Source/AudibleUtilities/Widevine/Extensions.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
using System;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
internal static class Extensions
|
||||
{
|
||||
public static T[] Append<T>(this T[] message, T[] appendData)
|
||||
{
|
||||
var origLength = message.Length;
|
||||
Array.Resize(ref message, origLength + appendData.Length);
|
||||
Array.Copy(appendData, 0, message, origLength, appendData.Length);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
14552
Source/AudibleUtilities/Widevine/LicenseProtocol.cs
Normal file
14552
Source/AudibleUtilities/Widevine/LicenseProtocol.cs
Normal file
File diff suppressed because it is too large
Load diff
70
Source/AudibleUtilities/Widevine/MpegDash.cs
Normal file
70
Source/AudibleUtilities/Widevine/MpegDash.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using Mpeg4Lib.Boxes;
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Xml;
|
||||
using System.Xml.Linq;
|
||||
using System.Xml.XPath;
|
||||
|
||||
#nullable enable
|
||||
namespace AudibleUtilities.Widevine;
|
||||
|
||||
public class MpegDash
|
||||
{
|
||||
private const string MpegDashNamespace = "urn:mpeg:dash:schema:mpd:2011";
|
||||
private const string CencNamespace = "urn:mpeg:cenc:2013";
|
||||
private const string UuidPreamble = "urn:uuid:";
|
||||
private XElement DashMpd { get; }
|
||||
private static XmlNamespaceManager NamespaceManager { get; } = new(new NameTable());
|
||||
static MpegDash()
|
||||
{
|
||||
NamespaceManager.AddNamespace("dash", MpegDashNamespace);
|
||||
NamespaceManager.AddNamespace("cenc", CencNamespace);
|
||||
}
|
||||
|
||||
public MpegDash(Stream contents)
|
||||
{
|
||||
DashMpd = XElement.Load(contents);
|
||||
}
|
||||
|
||||
public bool TryGetUri(Uri baseUri, [NotNullWhen(true)] out Uri? fileUri)
|
||||
{
|
||||
foreach (var baseUrl in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:Representation/dash:BaseURL", NamespaceManager))
|
||||
{
|
||||
try
|
||||
{
|
||||
fileUri = new Uri(baseUri, baseUrl.Value);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
fileUri = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
fileUri = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetPssh(Guid protectionSystemId, [NotNullWhen(true)] out PsshBox? pssh)
|
||||
{
|
||||
foreach (var psshEle in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:ContentProtection/cenc:pssh", NamespaceManager))
|
||||
{
|
||||
if (psshEle?.Value?.Trim() is string psshStr
|
||||
&& psshEle.Parent?.Attribute(XName.Get("schemeIdUri")) is XAttribute scheme
|
||||
&& scheme.Value is string uuid
|
||||
&& uuid.Equals(UuidPreamble + protectionSystemId.ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Span<byte> buffer = new byte[psshStr.Length * 3 / 4];
|
||||
if (Convert.TryFromBase64String(psshStr, buffer, out var written))
|
||||
{
|
||||
using var ms = new MemoryStream(buffer.Slice(0, written).ToArray());
|
||||
pssh = BoxFactory.CreateBox(ms, null) as PsshBox;
|
||||
return pssh is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
pssh = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,34 +15,6 @@ namespace FileLiberator
|
|||
public event EventHandler<byte[]> CoverImageDiscovered;
|
||||
public abstract Task CancelAsync();
|
||||
|
||||
protected LameConfig GetLameOptions(Configuration config)
|
||||
{
|
||||
LameConfig lameConfig = new()
|
||||
{
|
||||
Mode = MPEGMode.Mono,
|
||||
Quality = config.LameEncoderQuality,
|
||||
OutputSampleRate = (int)config.MaxSampleRate
|
||||
};
|
||||
|
||||
if (config.LameTargetBitrate)
|
||||
{
|
||||
if (config.LameConstantBitrate)
|
||||
lameConfig.BitRate = config.LameBitrate;
|
||||
else
|
||||
{
|
||||
lameConfig.ABRRateKbps = config.LameBitrate;
|
||||
lameConfig.VBR = VBRMode.ABR;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lameConfig.VBR = VBRMode.Default;
|
||||
lameConfig.VBRQuality = config.LameVBRQuality;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
return lameConfig;
|
||||
}
|
||||
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
|
||||
protected void OnTitleDiscovered(object _, string title)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -44,13 +44,17 @@ namespace FileLiberator
|
|||
|
||||
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||
|
||||
//AAXClean.Codecs only supports decoding AAC and E-AC-3 audio.
|
||||
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
|
||||
continue;
|
||||
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
|
||||
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
|
||||
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
|
||||
|
||||
var config = Configuration.Instance;
|
||||
var lameConfig = GetLameOptions(config);
|
||||
var lameConfig = DownloadOptions.GetLameOptions(config);
|
||||
var chapters = m4bBook.GetChaptersFromMetadata();
|
||||
//Finishing configuring lame encoder.
|
||||
AaxDecrypter.MpegUtil.ConfigureLameOptions(
|
||||
|
|
|
|||
|
|
@ -122,15 +122,13 @@ namespace FileLiberator
|
|||
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
|
||||
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||
|
||||
using var dlOptions = await DownloadOptions.InitiateDownloadAsync(api, libraryBook, config);
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
if (contentLic.DrmType != DrmType.Adrm)
|
||||
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||
else
|
||||
{
|
||||
|
|
@ -140,7 +138,7 @@ namespace FileLiberator
|
|||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
|
||||
converter.RetrievedMetadata += Converter_RetrievedMetadata;
|
||||
|
||||
abDownloader = converter;
|
||||
}
|
||||
|
|
@ -161,191 +159,37 @@ namespace FileLiberator
|
|||
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
|
||||
|
||||
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
|
||||
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
|
||||
|
||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
||||
OnFileCreated(libraryBook, metadataFile);
|
||||
}
|
||||
File.WriteAllText(metadataFile, item.SourceJson.ToString());
|
||||
OnFileCreated(libraryBook, metadataFile);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
|
||||
{
|
||||
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
||||
private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags)
|
||||
{
|
||||
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
|
||||
return;
|
||||
|
||||
var outputFormat
|
||||
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
||||
? OutputFormat.Mp3
|
||||
: OutputFormat.M4b;
|
||||
|
||||
long chapterStartMs
|
||||
= config.StripAudibleBrandAudio
|
||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||
: 0;
|
||||
|
||||
//Set the requested AudioFormat for use in file naming templates
|
||||
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
|
||||
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
||||
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
|
||||
tags.Album ??= tags.Title;
|
||||
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
|
||||
tags.AlbumArtists ??= tags.Artist;
|
||||
tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
|
||||
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
|
||||
tags.Comment ??= options.LibraryBook.Book.Description;
|
||||
tags.LongDescription ??= tags.Comment;
|
||||
tags.Publisher ??= options.LibraryBook.Book.Publisher;
|
||||
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
|
||||
tags.Asin = options.LibraryBook.Book.AudibleProductId;
|
||||
tags.Acr = options.ContentMetadata.ContentReference.Acr;
|
||||
tags.Version = options.ContentMetadata.ContentReference.Version;
|
||||
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
|
||||
{
|
||||
AudibleKey = contentLic?.Voucher?.Key,
|
||||
AudibleIV = contentLic?.Voucher?.Iv,
|
||||
OutputFormat = outputFormat,
|
||||
LameConfig = GetLameOptions(config),
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
||||
};
|
||||
|
||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||
var chapters
|
||||
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||
.OrderBy(c => c.StartOffsetMs)
|
||||
.ToList();
|
||||
|
||||
if (config.MergeOpeningAndEndCredits)
|
||||
combineCredits(chapters);
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
var chapter = chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
|
||||
if (i == 0)
|
||||
chapLenMs -= chapterStartMs;
|
||||
|
||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
|
||||
return dlOptions;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||
|
||||
Audible may deliver chapters like this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:12 Book 1
|
||||
00:12 - 00:14 | Part 1
|
||||
00:14 - 01:40 | | Chapter 1
|
||||
01:40 - 03:20 | | Chapter 2
|
||||
03:20 - 03:22 | Part 2
|
||||
03:22 - 05:00 | | Chapter 3
|
||||
05:00 - 06:40 | | Chapter 4
|
||||
06:40 - 06:42 Book 2
|
||||
06:42 - 06:44 | Part 3
|
||||
06:44 - 08:20 | | Chapter 5
|
||||
08:20 - 10:00 | | Chapter 6
|
||||
10:00 - 10:02 | Part 4
|
||||
10:02 - 11:40 | | Chapter 7
|
||||
11:40 - 13:20 | | Chapter 8
|
||||
13:20 - 13:30 End Credits
|
||||
|
||||
And flattenChapters will combine them into this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||
13:20 - 13:40 End Credits
|
||||
|
||||
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
||||
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
||||
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
||||
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:25 Book 1
|
||||
00:25 - 00:27 | Part 1
|
||||
00:27 - 01:40 | | Chapter 1
|
||||
01:40 - 03:20 | | Chapter 2
|
||||
03:20 - 03:22 | Part 2
|
||||
03:22 - 05:00 | | Chapter 3
|
||||
05:00 - 06:40 | | Chapter 4
|
||||
06:40 - 06:42 Book 2
|
||||
06:42 - 07:02 | Part 3
|
||||
07:02 - 08:20 | | Chapter 5
|
||||
08:20 - 10:00 | | Chapter 6
|
||||
10:00 - 10:02 | Part 4
|
||||
10:02 - 11:40 | | Chapter 7
|
||||
11:40 - 13:20 | | Chapter 8
|
||||
13:20 - 13:30 End Credits
|
||||
|
||||
then flattenChapters will combine them into this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:25 Book 1
|
||||
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||
06:40 - 07:02 Book 2: Part 3
|
||||
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||
13:20 - 13:40 End Credits
|
||||
|
||||
*/
|
||||
|
||||
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string titleConcat = ": ")
|
||||
{
|
||||
List<Chapter> chaps = new();
|
||||
|
||||
foreach (var c in chapters)
|
||||
{
|
||||
if (c.Chapters is null)
|
||||
chaps.Add(c);
|
||||
else if (titleConcat is null)
|
||||
{
|
||||
chaps.Add(c);
|
||||
chaps.AddRange(flattenChapters(c.Chapters));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c.LengthMs < 10000)
|
||||
{
|
||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||
c.Chapters[0].LengthMs += c.LengthMs;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
|
||||
var children = flattenChapters(c.Chapters);
|
||||
|
||||
foreach (var child in children)
|
||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||
|
||||
chaps.AddRange(children);
|
||||
}
|
||||
}
|
||||
return chaps;
|
||||
}
|
||||
|
||||
public static void combineCredits(IList<Chapter> chapters)
|
||||
{
|
||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||
{
|
||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||
chapters.RemoveAt(0);
|
||||
}
|
||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||
{
|
||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||
chapters.Remove(chapters[^1]);
|
||||
tags.Year ??= pubDate.Year.ToString();
|
||||
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
362
Source/FileLiberator/DownloadOptions.Factory.cs
Normal file
362
Source/FileLiberator/DownloadOptions.Factory.cs
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
using AaxDecrypter;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using AudibleUtilities.Widevine;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace FileLiberator;
|
||||
|
||||
public partial class DownloadOptions
|
||||
{
|
||||
private const string Ec3Codec = "ec+3";
|
||||
private const string Ac4Codec = "ac-4";
|
||||
|
||||
/// <summary>
|
||||
/// Initiate an audiobook download from the audible api.
|
||||
/// </summary>
|
||||
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, LibraryBook libraryBook, Configuration config)
|
||||
{
|
||||
var license = await ChooseContent(api, libraryBook, config);
|
||||
var options = BuildDownloadOptions(libraryBook, config, license);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private static async Task<ContentLicense> ChooseContent(Api api, LibraryBook libraryBook, Configuration config)
|
||||
{
|
||||
var cdm = await Cdm.GetCdmAsync();
|
||||
|
||||
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
|
||||
|
||||
ContentLicense? contentLic = null;
|
||||
ContentLicense? fallback = null;
|
||||
|
||||
if (cdm is null)
|
||||
{
|
||||
//Doesn't matter what the user chose. We can't get a CDM so we must fall back to AAX(C)
|
||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
}
|
||||
else
|
||||
{
|
||||
var spatial = config.FileDownloadQuality is Configuration.DownloadQuality.Spatial;
|
||||
try
|
||||
{
|
||||
var codecChoice = config.SpatialAudioCodec switch
|
||||
{
|
||||
Configuration.SpatialCodec.EC_3 => Ec3Codec,
|
||||
Configuration.SpatialCodec.AC_4 => Ac4Codec,
|
||||
_ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}")
|
||||
};
|
||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality, ChapterTitlesType.Tree, DrmType.Widevine, spatial, codecChoice);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
|
||||
}
|
||||
|
||||
if (contentLic is null)
|
||||
{
|
||||
//We failed to get a widevine license, so fall back to AAX(C)
|
||||
contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
|
||||
}
|
||||
else 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 && cdm is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient();
|
||||
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)
|
||||
{
|
||||
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
|
||||
var outputFormat
|
||||
= contentLic.DrmType is not DrmType.Adrm and not DrmType.Widevine ||
|
||||
(config.AllowLibationFixup && config.DecryptToLossy && contentLic.ContentMetadata.ContentReference.Codec != "ac-4")
|
||||
? OutputFormat.Mp3
|
||||
: OutputFormat.M4b;
|
||||
|
||||
long chapterStartMs
|
||||
= config.StripAudibleBrandAudio
|
||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||
: 0;
|
||||
|
||||
AAXClean.FileType? inputType
|
||||
= contentLic.DrmType is DrmType.Widevine ? AAXClean.FileType.Dash
|
||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 8 && contentLic.Voucher?.Iv == null ? AAXClean.FileType.Aax
|
||||
: contentLic.DrmType is DrmType.Adrm && contentLic.Voucher?.Key.Length == 32 && contentLic.Voucher?.Iv.Length == 32 ? AAXClean.FileType.Aaxc
|
||||
: null;
|
||||
|
||||
//Set the requested AudioFormat for use in file naming templates
|
||||
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
|
||||
|
||||
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)),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic.ContentMetadata.ChapterInfo.RuntimeLengthMs),
|
||||
};
|
||||
|
||||
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
|
||||
var chapters
|
||||
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
|
||||
.OrderBy(c => c.StartOffsetMs)
|
||||
.ToList();
|
||||
|
||||
if (config.MergeOpeningAndEndCredits)
|
||||
combineCredits(chapters);
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
var chapter = chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
|
||||
if (i == 0)
|
||||
chapLenMs -= chapterStartMs;
|
||||
|
||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
|
||||
return dlOptions;
|
||||
}
|
||||
|
||||
public static LameConfig GetLameOptions(Configuration config)
|
||||
{
|
||||
LameConfig lameConfig = new()
|
||||
{
|
||||
Mode = MPEGMode.Mono,
|
||||
Quality = config.LameEncoderQuality,
|
||||
OutputSampleRate = (int)config.MaxSampleRate
|
||||
};
|
||||
|
||||
if (config.LameTargetBitrate)
|
||||
{
|
||||
if (config.LameConstantBitrate)
|
||||
lameConfig.BitRate = config.LameBitrate;
|
||||
else
|
||||
{
|
||||
lameConfig.ABRRateKbps = config.LameBitrate;
|
||||
lameConfig.VBR = VBRMode.ABR;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lameConfig.VBR = VBRMode.Default;
|
||||
lameConfig.VBRQuality = config.LameVBRQuality;
|
||||
lameConfig.WriteVBRTag = true;
|
||||
}
|
||||
return lameConfig;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||
|
||||
Audible may deliver chapters like this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:12 Book 1
|
||||
00:12 - 00:14 | Part 1
|
||||
00:14 - 01:40 | | Chapter 1
|
||||
01:40 - 03:20 | | Chapter 2
|
||||
03:20 - 03:22 | Part 2
|
||||
03:22 - 05:00 | | Chapter 3
|
||||
05:00 - 06:40 | | Chapter 4
|
||||
06:40 - 06:42 Book 2
|
||||
06:42 - 06:44 | Part 3
|
||||
06:44 - 08:20 | | Chapter 5
|
||||
08:20 - 10:00 | | Chapter 6
|
||||
10:00 - 10:02 | Part 4
|
||||
10:02 - 11:40 | | Chapter 7
|
||||
11:40 - 13:20 | | Chapter 8
|
||||
13:20 - 13:30 End Credits
|
||||
|
||||
And flattenChapters will combine them into this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 01:40 Book 1: Part 1: Chapter 1
|
||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||
06:40 - 08:20 Book 2: Part 3: Chapter 5
|
||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||
13:20 - 13:40 End Credits
|
||||
|
||||
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
|
||||
chapter. A duration longer than a few seconds implies that the chapter contains more than just
|
||||
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
|
||||
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:25 Book 1
|
||||
00:25 - 00:27 | Part 1
|
||||
00:27 - 01:40 | | Chapter 1
|
||||
01:40 - 03:20 | | Chapter 2
|
||||
03:20 - 03:22 | Part 2
|
||||
03:22 - 05:00 | | Chapter 3
|
||||
05:00 - 06:40 | | Chapter 4
|
||||
06:40 - 06:42 Book 2
|
||||
06:42 - 07:02 | Part 3
|
||||
07:02 - 08:20 | | Chapter 5
|
||||
08:20 - 10:00 | | Chapter 6
|
||||
10:00 - 10:02 | Part 4
|
||||
10:02 - 11:40 | | Chapter 7
|
||||
11:40 - 13:20 | | Chapter 8
|
||||
13:20 - 13:30 End Credits
|
||||
|
||||
then flattenChapters will combine them into this:
|
||||
|
||||
00:00 - 00:10 Opening Credits
|
||||
00:10 - 00:25 Book 1
|
||||
00:25 - 01:40 Book 1: Part 1: Chapter 1
|
||||
01:40 - 03:20 Book 1: Part 1: Chapter 2
|
||||
03:20 - 05:00 Book 1: Part 2: Chapter 3
|
||||
05:00 - 06:40 Book 1: Part 2: Chapter 4
|
||||
06:40 - 07:02 Book 2: Part 3
|
||||
07:02 - 08:20 Book 2: Part 3: Chapter 5
|
||||
08:20 - 10:00 Book 2: Part 3: Chapter 6
|
||||
10:00 - 11:40 Book 2: Part 4: Chapter 7
|
||||
11:40 - 13:20 Book 2: Part 4: Chapter 8
|
||||
13:20 - 13:40 End Credits
|
||||
|
||||
*/
|
||||
|
||||
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string? titleConcat = ": ")
|
||||
{
|
||||
List<Chapter> chaps = new();
|
||||
|
||||
foreach (var c in chapters)
|
||||
{
|
||||
if (c.Chapters is null)
|
||||
chaps.Add(c);
|
||||
else if (titleConcat is null)
|
||||
{
|
||||
chaps.Add(c);
|
||||
chaps.AddRange(flattenChapters(c.Chapters));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (c.LengthMs < 10000)
|
||||
{
|
||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||
c.Chapters[0].LengthMs += c.LengthMs;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
|
||||
var children = flattenChapters(c.Chapters);
|
||||
|
||||
foreach (var child in children)
|
||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||
|
||||
chaps.AddRange(children);
|
||||
}
|
||||
}
|
||||
return chaps;
|
||||
}
|
||||
|
||||
public static void combineCredits(IList<Chapter> chapters)
|
||||
{
|
||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||
{
|
||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||
chapters.RemoveAt(0);
|
||||
}
|
||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||
{
|
||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||
chapters.Remove(chapters[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
static double RelativePercentDifference(long num1, long num2)
|
||||
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ using LibationFileManager.Templates;
|
|||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadOptions : IDownloadOptions, IDisposable
|
||||
public partial class DownloadOptions : IDownloadOptions, IDisposable
|
||||
{
|
||||
public event EventHandler<long> DownloadSpeedChanged;
|
||||
public LibraryBook LibraryBook { get; }
|
||||
|
|
@ -41,6 +41,9 @@ namespace FileLiberator
|
|||
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
|
||||
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
|
||||
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
||||
public AAXClean.FileType? InputType { get; init; }
|
||||
public AudibleApi.Common.DrmType DrmType { get; init; }
|
||||
public AudibleApi.Common.ContentMetadata ContentMetadata { get; init; }
|
||||
|
||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||
{
|
||||
|
|
@ -83,9 +86,13 @@ namespace FileLiberator
|
|||
|
||||
private readonly Configuration config;
|
||||
private readonly IDisposable cancellation;
|
||||
public void Dispose() => cancellation?.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
cancellation?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
|
||||
private DownloadOptions(Configuration config, LibraryBook libraryBook, [System.Diagnostics.CodeAnalysis.NotNull] string downloadUrl)
|
||||
{
|
||||
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
|
|
|
|||
|
|
@ -19,5 +19,10 @@
|
|||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="DownloadOptions.*.cs">
|
||||
<DependentUpon>DownloadOptions.cs</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -43,10 +43,26 @@
|
|||
<controls:WheelComboBox
|
||||
Margin="5,0,0,0"
|
||||
Grid.Column="1"
|
||||
SelectionChanged="Quality_SelectionChanged"
|
||||
ItemsSource="{CompiledBinding DownloadQualities}"
|
||||
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,5,0,0"
|
||||
IsEnabled="{CompiledBinding SpatialSelected}"
|
||||
ToolTip.Tip="{CompiledBinding SpatialAudioCodecTip}">
|
||||
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{CompiledBinding SpatialAudioCodecText}" />
|
||||
|
||||
<controls:WheelComboBox
|
||||
Margin="5,0,0,0"
|
||||
Grid.Column="1"
|
||||
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
|
||||
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
|
||||
</Grid>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />
|
||||
</CheckBox>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
using AudibleUtilities;
|
||||
using Avalonia.Controls;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationFileManager.Templates;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Controls.Settings
|
||||
|
|
@ -20,6 +22,25 @@ namespace LibationAvalonia.Controls.Settings
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
public async void Quality_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_viewModel.SpatialSelected)
|
||||
{
|
||||
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
|
||||
{
|
||||
await MessageBox.Show(VisualRoot as Window,
|
||||
"Your must remove account(s) from Libation and then re-add them to enable spatial audiobook downloads.",
|
||||
"Spatial Audio Unavailable",
|
||||
MessageBoxButtons.OK);
|
||||
|
||||
_viewModel.FileDownloadQuality = _viewModel.DownloadQualities[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (_viewModel is null) return;
|
||||
|
|
|
|||
|
|
@ -22,13 +22,13 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
private int _lameBitrate;
|
||||
private int _lameVBRQuality;
|
||||
private string _chapterTitleTemplate;
|
||||
public EnumDiaplay<SampleRate> SelectedSampleRate { get; set; }
|
||||
public EnumDisplay<SampleRate> SelectedSampleRate { get; set; }
|
||||
public NAudio.Lame.EncoderQuality SelectedEncoderQuality { get; set; }
|
||||
|
||||
public AvaloniaList<EnumDiaplay<SampleRate>> SampleRates { get; }
|
||||
public AvaloniaList<EnumDisplay<SampleRate>> SampleRates { get; }
|
||||
= new(Enum.GetValues<SampleRate>()
|
||||
.Where(r => r >= SampleRate.Hz_8000 && r <= SampleRate.Hz_48000)
|
||||
.Select(v => new EnumDiaplay<SampleRate>(v, $"{(int)v} Hz")));
|
||||
.Select(v => new EnumDisplay<SampleRate>(v, $"{(int)v} Hz")));
|
||||
|
||||
public AvaloniaList<NAudio.Lame.EncoderQuality> EncoderQualities { get; }
|
||||
= new(
|
||||
|
|
@ -48,7 +48,6 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
DownloadCoverArt = config.DownloadCoverArt;
|
||||
RetainAaxFile = config.RetainAaxFile;
|
||||
DownloadClipsBookmarks = config.DownloadClipsBookmarks;
|
||||
FileDownloadQuality = config.FileDownloadQuality;
|
||||
ClipBookmarkFormat = config.ClipsBookmarksFileFormat;
|
||||
SplitFilesByChapter = config.SplitFilesByChapter;
|
||||
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
|
||||
|
|
@ -64,6 +63,8 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
LameBitrate = config.LameBitrate;
|
||||
LameVBRQuality = config.LameVBRQuality;
|
||||
|
||||
SpatialAudioCodec = SpatialAudioCodecs.SingleOrDefault(s => s.Value == config.SpatialAudioCodec) ?? SpatialAudioCodecs[0];
|
||||
FileDownloadQuality = DownloadQualities.SingleOrDefault(s => s.Value == config.FileDownloadQuality) ?? DownloadQualities[0];
|
||||
SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate) ?? SampleRates[0];
|
||||
SelectedEncoderQuality = config.LameEncoderQuality;
|
||||
}
|
||||
|
|
@ -76,7 +77,6 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
config.DownloadCoverArt = DownloadCoverArt;
|
||||
config.RetainAaxFile = RetainAaxFile;
|
||||
config.DownloadClipsBookmarks = DownloadClipsBookmarks;
|
||||
config.FileDownloadQuality = FileDownloadQuality;
|
||||
config.ClipsBookmarksFileFormat = ClipBookmarkFormat;
|
||||
config.SplitFilesByChapter = SplitFilesByChapter;
|
||||
config.MergeOpeningAndEndCredits = MergeOpeningAndEndCredits;
|
||||
|
|
@ -94,11 +94,23 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
|
||||
config.LameEncoderQuality = SelectedEncoderQuality;
|
||||
config.MaxSampleRate = SelectedSampleRate?.Value ?? config.MaxSampleRate;
|
||||
config.FileDownloadQuality = FileDownloadQuality?.Value ?? config.FileDownloadQuality;
|
||||
config.SpatialAudioCodec = SpatialAudioCodec?.Value ?? config.SpatialAudioCodec;
|
||||
}
|
||||
|
||||
public AvaloniaList<Configuration.DownloadQuality> DownloadQualities { get; } = new(Enum<Configuration.DownloadQuality>.GetValues());
|
||||
public AvaloniaList<EnumDisplay<Configuration.DownloadQuality>> DownloadQualities { get; } = new([
|
||||
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Normal),
|
||||
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.High),
|
||||
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Spatial, "Spatial (if available)"),
|
||||
]);
|
||||
public AvaloniaList<EnumDisplay<Configuration.SpatialCodec>> SpatialAudioCodecs { get; } = new([
|
||||
new EnumDisplay<Configuration.SpatialCodec>(Configuration.SpatialCodec.EC_3, "Dolby Digital Plus (E-AC-3)"),
|
||||
new EnumDisplay<Configuration.SpatialCodec>(Configuration.SpatialCodec.AC_4, "Dolby AC-4")
|
||||
]);
|
||||
public AvaloniaList<Configuration.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
|
||||
public string FileDownloadQualityText { get; } = Configuration.GetDescription(nameof(Configuration.FileDownloadQuality));
|
||||
public string SpatialAudioCodecText { get; } = Configuration.GetDescription(nameof(Configuration.SpatialAudioCodec));
|
||||
public string SpatialAudioCodecTip { get; } = Configuration.GetHelpText(nameof(Configuration.SpatialAudioCodec));
|
||||
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
|
||||
public string CombineNestedChapterTitlesText { get; } = Configuration.GetDescription(nameof(Configuration.CombineNestedChapterTitles));
|
||||
public string CombineNestedChapterTitlesTip => Configuration.GetHelpText(nameof(CombineNestedChapterTitles));
|
||||
|
|
@ -120,7 +132,21 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
public bool RetainAaxFile { get; set; }
|
||||
public string RetainAaxFileTip => Configuration.GetHelpText(nameof(RetainAaxFile));
|
||||
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
|
||||
public Configuration.DownloadQuality FileDownloadQuality { get; set; }
|
||||
|
||||
public bool SpatialSelected { get; private set; }
|
||||
|
||||
private EnumDisplay<Configuration.DownloadQuality>? _fileDownloadQuality;
|
||||
public EnumDisplay<Configuration.DownloadQuality> FileDownloadQuality
|
||||
{
|
||||
get => _fileDownloadQuality ?? DownloadQualities[0];
|
||||
set
|
||||
{
|
||||
SpatialSelected = value?.Value == Configuration.DownloadQuality.Spatial;
|
||||
this.RaiseAndSetIfChanged(ref _fileDownloadQuality, value);
|
||||
this.RaisePropertyChanged(nameof(SpatialSelected));
|
||||
}
|
||||
}
|
||||
public EnumDisplay<Configuration.SpatialCodec> SpatialAudioCodec { get; set; }
|
||||
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
|
||||
public bool MergeOpeningAndEndCredits { get; set; }
|
||||
public string MergeOpeningAndEndCreditsTip => Configuration.GetHelpText(nameof(MergeOpeningAndEndCredits));
|
||||
|
|
|
|||
|
|
@ -72,9 +72,9 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
public string OverwriteExistingText { get; } = Configuration.GetDescription(nameof(Configuration.OverwriteExisting));
|
||||
public string CreationTimeText { get; } = Configuration.GetDescription(nameof(Configuration.CreationTime));
|
||||
public string LastWriteTimeText { get; } = Configuration.GetDescription(nameof(Configuration.LastWriteTime));
|
||||
public EnumDiaplay<Configuration.DateTimeSource>[] DateTimeSources { get; }
|
||||
public EnumDisplay<Configuration.DateTimeSource>[] DateTimeSources { get; }
|
||||
= Enum.GetValues<Configuration.DateTimeSource>()
|
||||
.Select(v => new EnumDiaplay<Configuration.DateTimeSource>(v))
|
||||
.Select(v => new EnumDisplay<Configuration.DateTimeSource>(v))
|
||||
.ToArray();
|
||||
public Serilog.Events.LogEventLevel[] LoggingLevels { get; } = Enum.GetValues<Serilog.Events.LogEventLevel>();
|
||||
public string GridScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridScaleFactor));
|
||||
|
|
@ -87,8 +87,8 @@ namespace LibationAvalonia.ViewModels.Settings
|
|||
public bool OverwriteExisting { get; set; }
|
||||
public float GridScaleFactor { get; set; }
|
||||
public float GridFontScaleFactor { get; set; }
|
||||
public EnumDiaplay<Configuration.DateTimeSource> CreationTime { get; set; }
|
||||
public EnumDiaplay<Configuration.DateTimeSource> LastWriteTime { get; set; }
|
||||
public EnumDisplay<Configuration.DateTimeSource> CreationTime { get; set; }
|
||||
public EnumDisplay<Configuration.DateTimeSource> LastWriteTime { get; set; }
|
||||
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }
|
||||
|
||||
public string ThemeVariant
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ namespace LibationFileManager
|
|||
from the decrypted audiobook. This does not require
|
||||
re-encoding.
|
||||
""" },
|
||||
{nameof(SpatialAudioCodec), """
|
||||
The Dolby Digital Plus (E-AC-3) codec is more widely
|
||||
supported than the AC-4 codec, but E-AC-3 files are
|
||||
much larger than AC-4 files.
|
||||
|
||||
AC-4 cannot be converted to MP3.
|
||||
""" },
|
||||
}
|
||||
.AsReadOnly();
|
||||
|
||||
|
|
|
|||
|
|
@ -246,9 +246,20 @@ namespace LibationFileManager
|
|||
public enum DownloadQuality
|
||||
{
|
||||
High,
|
||||
Normal
|
||||
Normal,
|
||||
Spatial
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public enum SpatialCodec
|
||||
{
|
||||
EC_3,
|
||||
AC_4
|
||||
}
|
||||
|
||||
[Description("Spatial audio codec:")]
|
||||
public SpatialCodec SpatialAudioCodec { get => GetNonString(defaultValue: SpatialCodec.EC_3); set => SetNonString(value); }
|
||||
|
||||
[Description("Audio quality to request from Audible:")]
|
||||
public DownloadQuality FileDownloadQuality { get => GetNonString(defaultValue: DownloadQuality.High); set => SetNonString(value); }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
using Dinah.Core;
|
||||
using System;
|
||||
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public record EnumDiaplay<T> where T : Enum
|
||||
{
|
||||
public T Value { get; }
|
||||
public string Description { get; }
|
||||
public EnumDiaplay(T value, string description = null)
|
||||
{
|
||||
Value = value;
|
||||
Description = description ?? value.GetDescription() ?? value.ToString();
|
||||
}
|
||||
public override string ToString() => Description;
|
||||
}
|
||||
}
|
||||
21
Source/LibationUiBase/EnumDisplay[T].cs
Normal file
21
Source/LibationUiBase/EnumDisplay[T].cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using Dinah.Core;
|
||||
using System;
|
||||
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public class EnumDisplay<T> where T : Enum
|
||||
{
|
||||
public T Value { get; }
|
||||
public string Description { get; }
|
||||
public EnumDisplay(T value, string description = null)
|
||||
{
|
||||
Value = value;
|
||||
Description = description ?? value.GetDescription() ?? value.ToString();
|
||||
}
|
||||
public override string ToString() => Description;
|
||||
|
||||
public override bool Equals(object obj)
|
||||
=> (obj is EnumDisplay<T> other && other.Value.Equals(Value)) || (obj is T value && value.Equals(Value));
|
||||
public override int GetHashCode() => Value.GetHashCode();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ using LibationFileManager;
|
|||
using System.Linq;
|
||||
using LibationUiBase;
|
||||
using LibationFileManager.Templates;
|
||||
using AudibleUtilities;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
|
|
@ -21,6 +23,7 @@ namespace LibationWinForms.Dialogs
|
|||
this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio));
|
||||
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
|
||||
this.moveMoovAtomCbox.Text = desc(nameof(config.MoveMoovToBeginning));
|
||||
this.spatialCodecLbl.Text = desc(nameof(config.SpatialAudioCodec));
|
||||
|
||||
toolTip.SetToolTip(combineNestedChapterTitlesCbox, Configuration.GetHelpText(nameof(config.CombineNestedChapterTitles)));
|
||||
toolTip.SetToolTip(allowLibationFixupCbox, Configuration.GetHelpText(nameof(config.AllowLibationFixup)));
|
||||
|
|
@ -31,41 +34,49 @@ namespace LibationWinForms.Dialogs
|
|||
toolTip.SetToolTip(mergeOpeningEndCreditsCbox, Configuration.GetHelpText(nameof(config.MergeOpeningAndEndCredits)));
|
||||
toolTip.SetToolTip(retainAaxFileCbox, Configuration.GetHelpText(nameof(config.RetainAaxFile)));
|
||||
toolTip.SetToolTip(stripAudibleBrandingCbox, Configuration.GetHelpText(nameof(config.StripAudibleBrandAudio)));
|
||||
toolTip.SetToolTip(spatialCodecLbl, Configuration.GetHelpText(nameof(config.SpatialAudioCodec)));
|
||||
toolTip.SetToolTip(spatialAudioCodecCb, Configuration.GetHelpText(nameof(config.SpatialAudioCodec)));
|
||||
|
||||
fileDownloadQualityCb.Items.AddRange(
|
||||
new object[]
|
||||
{
|
||||
Configuration.DownloadQuality.Normal,
|
||||
Configuration.DownloadQuality.High
|
||||
});
|
||||
[
|
||||
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Normal),
|
||||
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.High),
|
||||
new EnumDisplay<Configuration.DownloadQuality>(Configuration.DownloadQuality.Spatial, "Spatial (if available)"),
|
||||
]);
|
||||
|
||||
spatialAudioCodecCb.Items.AddRange(
|
||||
[
|
||||
new EnumDisplay<Configuration.SpatialCodec>(Configuration.SpatialCodec.EC_3, "Dolby Digital Plus (E-AC-3)"),
|
||||
new EnumDisplay<Configuration.SpatialCodec>(Configuration.SpatialCodec.AC_4, "Dolby AC-4")
|
||||
]);
|
||||
|
||||
clipsBookmarksFormatCb.Items.AddRange(
|
||||
new object[]
|
||||
{
|
||||
[
|
||||
Configuration.ClipBookmarkFormat.CSV,
|
||||
Configuration.ClipBookmarkFormat.Xlsx,
|
||||
Configuration.ClipBookmarkFormat.Json
|
||||
});
|
||||
]);
|
||||
|
||||
maxSampleRateCb.Items.AddRange(
|
||||
Enum.GetValues<AAXClean.SampleRate>()
|
||||
.Where(r => r >= AAXClean.SampleRate.Hz_8000 && r <= AAXClean.SampleRate.Hz_48000)
|
||||
.Select(v => new EnumDiaplay<AAXClean.SampleRate>(v, $"{(int)v} Hz"))
|
||||
.Select(v => new EnumDisplay<AAXClean.SampleRate>(v, $"{(int)v} Hz"))
|
||||
.ToArray());
|
||||
|
||||
encoderQualityCb.Items.AddRange(
|
||||
new object[]
|
||||
{
|
||||
[
|
||||
NAudio.Lame.EncoderQuality.High,
|
||||
NAudio.Lame.EncoderQuality.Standard,
|
||||
NAudio.Lame.EncoderQuality.Fast,
|
||||
});
|
||||
]);
|
||||
|
||||
allowLibationFixupCbox.Checked = config.AllowLibationFixup;
|
||||
createCueSheetCbox.Checked = config.CreateCueSheet;
|
||||
downloadCoverArtCbox.Checked = config.DownloadCoverArt;
|
||||
downloadClipsBookmarksCbox.Checked = config.DownloadClipsBookmarks;
|
||||
fileDownloadQualityCb.SelectedItem = config.FileDownloadQuality;
|
||||
spatialAudioCodecCb.SelectedItem = config.SpatialAudioCodec;
|
||||
|
||||
clipsBookmarksFormatCb.SelectedItem = config.ClipsBookmarksFileFormat;
|
||||
retainAaxFileCbox.Checked = config.RetainAaxFile;
|
||||
combineNestedChapterTitlesCbox.Checked = config.CombineNestedChapterTitles;
|
||||
|
|
@ -80,11 +91,7 @@ namespace LibationWinForms.Dialogs
|
|||
lameTargetBitrateRb.Checked = config.LameTargetBitrate;
|
||||
lameTargetQualityRb.Checked = !config.LameTargetBitrate;
|
||||
|
||||
maxSampleRateCb.SelectedItem
|
||||
= maxSampleRateCb.Items
|
||||
.Cast<EnumDiaplay<AAXClean.SampleRate>>()
|
||||
.SingleOrDefault(v => v.Value == config.MaxSampleRate)
|
||||
?? maxSampleRateCb.Items[0];
|
||||
maxSampleRateCb.SelectedItem = config.MaxSampleRate;
|
||||
|
||||
encoderQualityCb.SelectedItem = config.LameEncoderQuality;
|
||||
lameDownsampleMonoCbox.Checked = config.LameDownsampleMono;
|
||||
|
|
@ -110,7 +117,8 @@ namespace LibationWinForms.Dialogs
|
|||
config.CreateCueSheet = createCueSheetCbox.Checked;
|
||||
config.DownloadCoverArt = downloadCoverArtCbox.Checked;
|
||||
config.DownloadClipsBookmarks = downloadClipsBookmarksCbox.Checked;
|
||||
config.FileDownloadQuality = (Configuration.DownloadQuality)fileDownloadQualityCb.SelectedItem;
|
||||
config.FileDownloadQuality = ((EnumDisplay<Configuration.DownloadQuality>)fileDownloadQualityCb.SelectedItem).Value;
|
||||
config.SpatialAudioCodec = ((EnumDisplay<Configuration.SpatialCodec>)spatialAudioCodecCb.SelectedItem).Value;
|
||||
config.ClipsBookmarksFileFormat = (Configuration.ClipBookmarkFormat)clipsBookmarksFormatCb.SelectedItem;
|
||||
config.RetainAaxFile = retainAaxFileCbox.Checked;
|
||||
config.CombineNestedChapterTitles = combineNestedChapterTitlesCbox.Checked;
|
||||
|
|
@ -121,7 +129,7 @@ namespace LibationWinForms.Dialogs
|
|||
config.DecryptToLossy = convertLossyRb.Checked;
|
||||
config.MoveMoovToBeginning = moveMoovAtomCbox.Checked;
|
||||
config.LameTargetBitrate = lameTargetBitrateRb.Checked;
|
||||
config.MaxSampleRate = ((EnumDiaplay<AAXClean.SampleRate>)maxSampleRateCb.SelectedItem).Value;
|
||||
config.MaxSampleRate = ((EnumDisplay<AAXClean.SampleRate>)maxSampleRateCb.SelectedItem).Value;
|
||||
config.LameEncoderQuality = (NAudio.Lame.EncoderQuality)encoderQualityCb.SelectedItem;
|
||||
config.LameDownsampleMono = lameDownsampleMonoCbox.Checked;
|
||||
config.LameBitrate = lameBitrateTb.Value;
|
||||
|
|
@ -181,5 +189,28 @@ namespace LibationWinForms.Dialogs
|
|||
stripAudibleBrandingCbox.Checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void fileDownloadQualityCb_SelectedIndexChanged(object sender, EventArgs e)
|
||||
{
|
||||
var selectedSpatial = fileDownloadQualityCb.SelectedItem.Equals(Configuration.DownloadQuality.Spatial);
|
||||
|
||||
if (selectedSpatial)
|
||||
{
|
||||
using var accounts = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType))
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"Your must remove account(s) from Libation and then re-add them to enable spatial audiobook downloads.",
|
||||
"Spatial Audio Unavailable",
|
||||
MessageBoxButtons.OK);
|
||||
|
||||
fileDownloadQualityCb.SelectedItem = Configuration.DownloadQuality.High;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
spatialCodecLbl.Enabled = spatialAudioCodecCb.Enabled = selectedSpatial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@
|
|||
folderTemplateTb = new System.Windows.Forms.TextBox();
|
||||
folderTemplateLbl = new System.Windows.Forms.Label();
|
||||
tab4AudioFileOptions = new System.Windows.Forms.TabPage();
|
||||
spatialAudioCodecCb = new System.Windows.Forms.ComboBox();
|
||||
spatialCodecLbl = new System.Windows.Forms.Label();
|
||||
moveMoovAtomCbox = new System.Windows.Forms.CheckBox();
|
||||
fileDownloadQualityCb = new System.Windows.Forms.ComboBox();
|
||||
fileDownloadQualityLbl = new System.Windows.Forms.Label();
|
||||
|
|
@ -281,10 +283,10 @@
|
|||
// stripAudibleBrandingCbox
|
||||
//
|
||||
stripAudibleBrandingCbox.AutoSize = true;
|
||||
stripAudibleBrandingCbox.Location = new System.Drawing.Point(13, 72);
|
||||
stripAudibleBrandingCbox.Location = new System.Drawing.Point(13, 70);
|
||||
stripAudibleBrandingCbox.Name = "stripAudibleBrandingCbox";
|
||||
stripAudibleBrandingCbox.Size = new System.Drawing.Size(143, 34);
|
||||
stripAudibleBrandingCbox.TabIndex = 13;
|
||||
stripAudibleBrandingCbox.TabIndex = 14;
|
||||
stripAudibleBrandingCbox.Text = "[StripAudibleBranding\r\ndesc]";
|
||||
stripAudibleBrandingCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
|
|
@ -294,7 +296,7 @@
|
|||
splitFilesByChapterCbox.Location = new System.Drawing.Point(13, 22);
|
||||
splitFilesByChapterCbox.Name = "splitFilesByChapterCbox";
|
||||
splitFilesByChapterCbox.Size = new System.Drawing.Size(162, 19);
|
||||
splitFilesByChapterCbox.TabIndex = 13;
|
||||
splitFilesByChapterCbox.TabIndex = 12;
|
||||
splitFilesByChapterCbox.Text = "[SplitFilesByChapter desc]";
|
||||
splitFilesByChapterCbox.UseVisualStyleBackColor = true;
|
||||
splitFilesByChapterCbox.CheckedChanged += splitFilesByChapterCbox_CheckedChanged;
|
||||
|
|
@ -304,10 +306,10 @@
|
|||
allowLibationFixupCbox.AutoSize = true;
|
||||
allowLibationFixupCbox.Checked = true;
|
||||
allowLibationFixupCbox.CheckState = System.Windows.Forms.CheckState.Checked;
|
||||
allowLibationFixupCbox.Location = new System.Drawing.Point(19, 181);
|
||||
allowLibationFixupCbox.Location = new System.Drawing.Point(19, 205);
|
||||
allowLibationFixupCbox.Name = "allowLibationFixupCbox";
|
||||
allowLibationFixupCbox.Size = new System.Drawing.Size(162, 19);
|
||||
allowLibationFixupCbox.TabIndex = 10;
|
||||
allowLibationFixupCbox.TabIndex = 11;
|
||||
allowLibationFixupCbox.Text = "[AllowLibationFixup desc]";
|
||||
allowLibationFixupCbox.UseVisualStyleBackColor = true;
|
||||
allowLibationFixupCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged;
|
||||
|
|
@ -318,7 +320,7 @@
|
|||
convertLossyRb.Location = new System.Drawing.Point(438, 53);
|
||||
convertLossyRb.Name = "convertLossyRb";
|
||||
convertLossyRb.Size = new System.Drawing.Size(329, 19);
|
||||
convertLossyRb.TabIndex = 12;
|
||||
convertLossyRb.TabIndex = 27;
|
||||
convertLossyRb.Text = "Download my books as .MP3 files (transcode if necessary)";
|
||||
convertLossyRb.UseVisualStyleBackColor = true;
|
||||
convertLossyRb.CheckedChanged += convertFormatRb_CheckedChanged;
|
||||
|
|
@ -330,7 +332,7 @@
|
|||
convertLosslessRb.Location = new System.Drawing.Point(438, 6);
|
||||
convertLosslessRb.Name = "convertLosslessRb";
|
||||
convertLosslessRb.Size = new System.Drawing.Size(335, 19);
|
||||
convertLosslessRb.TabIndex = 11;
|
||||
convertLosslessRb.TabIndex = 25;
|
||||
convertLosslessRb.TabStop = true;
|
||||
convertLosslessRb.Text = "Download my books in the original audio format (Lossless)";
|
||||
convertLosslessRb.UseVisualStyleBackColor = true;
|
||||
|
|
@ -770,6 +772,8 @@
|
|||
// tab4AudioFileOptions
|
||||
//
|
||||
tab4AudioFileOptions.AutoScroll = true;
|
||||
tab4AudioFileOptions.Controls.Add(spatialAudioCodecCb);
|
||||
tab4AudioFileOptions.Controls.Add(spatialCodecLbl);
|
||||
tab4AudioFileOptions.Controls.Add(moveMoovAtomCbox);
|
||||
tab4AudioFileOptions.Controls.Add(fileDownloadQualityCb);
|
||||
tab4AudioFileOptions.Controls.Add(fileDownloadQualityLbl);
|
||||
|
|
@ -794,13 +798,32 @@
|
|||
tab4AudioFileOptions.Text = "Audio File Options";
|
||||
tab4AudioFileOptions.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// spatialAudioCodecCb
|
||||
//
|
||||
spatialAudioCodecCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
spatialAudioCodecCb.FormattingEnabled = true;
|
||||
spatialAudioCodecCb.Location = new System.Drawing.Point(249, 35);
|
||||
spatialAudioCodecCb.Margin = new System.Windows.Forms.Padding(3, 3, 5, 3);
|
||||
spatialAudioCodecCb.Name = "spatialAudioCodecCb";
|
||||
spatialAudioCodecCb.Size = new System.Drawing.Size(173, 23);
|
||||
spatialAudioCodecCb.TabIndex = 2;
|
||||
//
|
||||
// spatialCodecLbl
|
||||
//
|
||||
spatialCodecLbl.AutoSize = true;
|
||||
spatialCodecLbl.Location = new System.Drawing.Point(19, 37);
|
||||
spatialCodecLbl.Name = "spatialCodecLbl";
|
||||
spatialCodecLbl.Size = new System.Drawing.Size(143, 15);
|
||||
spatialCodecLbl.TabIndex = 24;
|
||||
spatialCodecLbl.Text = "[SpatialAudioCodec desc]";
|
||||
//
|
||||
// moveMoovAtomCbox
|
||||
//
|
||||
moveMoovAtomCbox.AutoSize = true;
|
||||
moveMoovAtomCbox.Location = new System.Drawing.Point(448, 28);
|
||||
moveMoovAtomCbox.Name = "moveMoovAtomCbox";
|
||||
moveMoovAtomCbox.Size = new System.Drawing.Size(189, 19);
|
||||
moveMoovAtomCbox.TabIndex = 14;
|
||||
moveMoovAtomCbox.TabIndex = 26;
|
||||
moveMoovAtomCbox.Text = "[MoveMoovToBeginning desc]";
|
||||
moveMoovAtomCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
|
|
@ -808,11 +831,12 @@
|
|||
//
|
||||
fileDownloadQualityCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
fileDownloadQualityCb.FormattingEnabled = true;
|
||||
fileDownloadQualityCb.Location = new System.Drawing.Point(264, 8);
|
||||
fileDownloadQualityCb.Location = new System.Drawing.Point(292, 6);
|
||||
fileDownloadQualityCb.Margin = new System.Windows.Forms.Padding(3, 3, 5, 3);
|
||||
fileDownloadQualityCb.Name = "fileDownloadQualityCb";
|
||||
fileDownloadQualityCb.Size = new System.Drawing.Size(88, 23);
|
||||
fileDownloadQualityCb.TabIndex = 23;
|
||||
fileDownloadQualityCb.Size = new System.Drawing.Size(130, 23);
|
||||
fileDownloadQualityCb.TabIndex = 1;
|
||||
fileDownloadQualityCb.SelectedIndexChanged += fileDownloadQualityCb_SelectedIndexChanged;
|
||||
//
|
||||
// fileDownloadQualityLbl
|
||||
//
|
||||
|
|
@ -827,10 +851,10 @@
|
|||
// combineNestedChapterTitlesCbox
|
||||
//
|
||||
combineNestedChapterTitlesCbox.AutoSize = true;
|
||||
combineNestedChapterTitlesCbox.Location = new System.Drawing.Point(19, 157);
|
||||
combineNestedChapterTitlesCbox.Location = new System.Drawing.Point(19, 181);
|
||||
combineNestedChapterTitlesCbox.Name = "combineNestedChapterTitlesCbox";
|
||||
combineNestedChapterTitlesCbox.Size = new System.Drawing.Size(217, 19);
|
||||
combineNestedChapterTitlesCbox.TabIndex = 13;
|
||||
combineNestedChapterTitlesCbox.TabIndex = 10;
|
||||
combineNestedChapterTitlesCbox.Text = "[CombineNestedChapterTitles desc]";
|
||||
combineNestedChapterTitlesCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
|
|
@ -838,18 +862,18 @@
|
|||
//
|
||||
clipsBookmarksFormatCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
clipsBookmarksFormatCb.FormattingEnabled = true;
|
||||
clipsBookmarksFormatCb.Location = new System.Drawing.Point(285, 81);
|
||||
clipsBookmarksFormatCb.Location = new System.Drawing.Point(285, 107);
|
||||
clipsBookmarksFormatCb.Name = "clipsBookmarksFormatCb";
|
||||
clipsBookmarksFormatCb.Size = new System.Drawing.Size(67, 23);
|
||||
clipsBookmarksFormatCb.TabIndex = 21;
|
||||
clipsBookmarksFormatCb.TabIndex = 6;
|
||||
//
|
||||
// downloadClipsBookmarksCbox
|
||||
//
|
||||
downloadClipsBookmarksCbox.AutoSize = true;
|
||||
downloadClipsBookmarksCbox.Location = new System.Drawing.Point(19, 82);
|
||||
downloadClipsBookmarksCbox.Location = new System.Drawing.Point(19, 109);
|
||||
downloadClipsBookmarksCbox.Name = "downloadClipsBookmarksCbox";
|
||||
downloadClipsBookmarksCbox.Size = new System.Drawing.Size(248, 19);
|
||||
downloadClipsBookmarksCbox.TabIndex = 20;
|
||||
downloadClipsBookmarksCbox.TabIndex = 5;
|
||||
downloadClipsBookmarksCbox.Text = "Download Clips, Notes, and Bookmarks as";
|
||||
downloadClipsBookmarksCbox.UseVisualStyleBackColor = true;
|
||||
downloadClipsBookmarksCbox.CheckedChanged += downloadClipsBookmarksCbox_CheckedChanged;
|
||||
|
|
@ -859,9 +883,9 @@
|
|||
audiobookFixupsGb.Controls.Add(splitFilesByChapterCbox);
|
||||
audiobookFixupsGb.Controls.Add(stripUnabridgedCbox);
|
||||
audiobookFixupsGb.Controls.Add(stripAudibleBrandingCbox);
|
||||
audiobookFixupsGb.Location = new System.Drawing.Point(6, 200);
|
||||
audiobookFixupsGb.Location = new System.Drawing.Point(6, 229);
|
||||
audiobookFixupsGb.Name = "audiobookFixupsGb";
|
||||
audiobookFixupsGb.Size = new System.Drawing.Size(416, 116);
|
||||
audiobookFixupsGb.Size = new System.Drawing.Size(416, 114);
|
||||
audiobookFixupsGb.TabIndex = 19;
|
||||
audiobookFixupsGb.TabStop = false;
|
||||
audiobookFixupsGb.Text = "Audiobook Fix-ups";
|
||||
|
|
@ -869,7 +893,7 @@
|
|||
// stripUnabridgedCbox
|
||||
//
|
||||
stripUnabridgedCbox.AutoSize = true;
|
||||
stripUnabridgedCbox.Location = new System.Drawing.Point(13, 47);
|
||||
stripUnabridgedCbox.Location = new System.Drawing.Point(13, 46);
|
||||
stripUnabridgedCbox.Name = "stripUnabridgedCbox";
|
||||
stripUnabridgedCbox.Size = new System.Drawing.Size(147, 19);
|
||||
stripUnabridgedCbox.TabIndex = 13;
|
||||
|
|
@ -894,7 +918,7 @@
|
|||
chapterTitleTemplateBtn.Location = new System.Drawing.Point(769, 22);
|
||||
chapterTitleTemplateBtn.Name = "chapterTitleTemplateBtn";
|
||||
chapterTitleTemplateBtn.Size = new System.Drawing.Size(75, 23);
|
||||
chapterTitleTemplateBtn.TabIndex = 17;
|
||||
chapterTitleTemplateBtn.TabIndex = 15;
|
||||
chapterTitleTemplateBtn.Text = "Edit...";
|
||||
chapterTitleTemplateBtn.UseVisualStyleBackColor = true;
|
||||
chapterTitleTemplateBtn.Click += chapterTitleTemplateBtn_Click;
|
||||
|
|
@ -954,7 +978,7 @@
|
|||
encoderQualityCb.Location = new System.Drawing.Point(327, 72);
|
||||
encoderQualityCb.Name = "encoderQualityCb";
|
||||
encoderQualityCb.Size = new System.Drawing.Size(79, 23);
|
||||
encoderQualityCb.TabIndex = 2;
|
||||
encoderQualityCb.TabIndex = 32;
|
||||
//
|
||||
// maxSampleRateCb
|
||||
//
|
||||
|
|
@ -964,7 +988,7 @@
|
|||
maxSampleRateCb.Location = new System.Drawing.Point(113, 72);
|
||||
maxSampleRateCb.Name = "maxSampleRateCb";
|
||||
maxSampleRateCb.Size = new System.Drawing.Size(75, 23);
|
||||
maxSampleRateCb.TabIndex = 2;
|
||||
maxSampleRateCb.TabIndex = 31;
|
||||
//
|
||||
// lameDownsampleMonoCbox
|
||||
//
|
||||
|
|
@ -972,7 +996,7 @@
|
|||
lameDownsampleMonoCbox.Location = new System.Drawing.Point(209, 29);
|
||||
lameDownsampleMonoCbox.Name = "lameDownsampleMonoCbox";
|
||||
lameDownsampleMonoCbox.Size = new System.Drawing.Size(197, 34);
|
||||
lameDownsampleMonoCbox.TabIndex = 1;
|
||||
lameDownsampleMonoCbox.TabIndex = 30;
|
||||
lameDownsampleMonoCbox.Text = "Downsample stereo to mono?\r\n(Recommended)\r\n";
|
||||
lameDownsampleMonoCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
|
|
@ -1002,7 +1026,7 @@
|
|||
LameMatchSourceBRCbox.Location = new System.Drawing.Point(254, 65);
|
||||
LameMatchSourceBRCbox.Name = "LameMatchSourceBRCbox";
|
||||
LameMatchSourceBRCbox.Size = new System.Drawing.Size(140, 19);
|
||||
LameMatchSourceBRCbox.TabIndex = 3;
|
||||
LameMatchSourceBRCbox.TabIndex = 35;
|
||||
LameMatchSourceBRCbox.Text = "Match source bitrate?";
|
||||
LameMatchSourceBRCbox.UseVisualStyleBackColor = true;
|
||||
LameMatchSourceBRCbox.CheckedChanged += LameMatchSourceBRCbox_CheckedChanged;
|
||||
|
|
@ -1013,7 +1037,7 @@
|
|||
lameConstantBitrateCbox.Location = new System.Drawing.Point(10, 65);
|
||||
lameConstantBitrateCbox.Name = "lameConstantBitrateCbox";
|
||||
lameConstantBitrateCbox.Size = new System.Drawing.Size(216, 19);
|
||||
lameConstantBitrateCbox.TabIndex = 2;
|
||||
lameConstantBitrateCbox.TabIndex = 34;
|
||||
lameConstantBitrateCbox.Text = "Restrict encoder to constant bitrate?";
|
||||
lameConstantBitrateCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
|
|
@ -1093,7 +1117,7 @@
|
|||
lameBitrateTb.Name = "lameBitrateTb";
|
||||
lameBitrateTb.Size = new System.Drawing.Size(388, 45);
|
||||
lameBitrateTb.SmallChange = 8;
|
||||
lameBitrateTb.TabIndex = 0;
|
||||
lameBitrateTb.TabIndex = 33;
|
||||
lameBitrateTb.TickFrequency = 16;
|
||||
lameBitrateTb.Value = 64;
|
||||
//
|
||||
|
|
@ -1246,7 +1270,7 @@
|
|||
lameVBRQualityTb.Maximum = 9;
|
||||
lameVBRQualityTb.Name = "lameVBRQualityTb";
|
||||
lameVBRQualityTb.Size = new System.Drawing.Size(388, 45);
|
||||
lameVBRQualityTb.TabIndex = 0;
|
||||
lameVBRQualityTb.TabIndex = 36;
|
||||
lameVBRQualityTb.Value = 9;
|
||||
//
|
||||
// groupBox2
|
||||
|
|
@ -1268,7 +1292,7 @@
|
|||
lameTargetQualityRb.Location = new System.Drawing.Point(104, 18);
|
||||
lameTargetQualityRb.Name = "lameTargetQualityRb";
|
||||
lameTargetQualityRb.Size = new System.Drawing.Size(63, 19);
|
||||
lameTargetQualityRb.TabIndex = 0;
|
||||
lameTargetQualityRb.TabIndex = 29;
|
||||
lameTargetQualityRb.TabStop = true;
|
||||
lameTargetQualityRb.Text = "Quality";
|
||||
lameTargetQualityRb.UseVisualStyleBackColor = true;
|
||||
|
|
@ -1280,7 +1304,7 @@
|
|||
lameTargetBitrateRb.Location = new System.Drawing.Point(14, 18);
|
||||
lameTargetBitrateRb.Name = "lameTargetBitrateRb";
|
||||
lameTargetBitrateRb.Size = new System.Drawing.Size(59, 19);
|
||||
lameTargetBitrateRb.TabIndex = 0;
|
||||
lameTargetBitrateRb.TabIndex = 28;
|
||||
lameTargetBitrateRb.TabStop = true;
|
||||
lameTargetBitrateRb.Text = "Bitrate";
|
||||
lameTargetBitrateRb.UseVisualStyleBackColor = true;
|
||||
|
|
@ -1300,20 +1324,20 @@
|
|||
// mergeOpeningEndCreditsCbox
|
||||
//
|
||||
mergeOpeningEndCreditsCbox.AutoSize = true;
|
||||
mergeOpeningEndCreditsCbox.Location = new System.Drawing.Point(19, 133);
|
||||
mergeOpeningEndCreditsCbox.Location = new System.Drawing.Point(19, 157);
|
||||
mergeOpeningEndCreditsCbox.Name = "mergeOpeningEndCreditsCbox";
|
||||
mergeOpeningEndCreditsCbox.Size = new System.Drawing.Size(198, 19);
|
||||
mergeOpeningEndCreditsCbox.TabIndex = 13;
|
||||
mergeOpeningEndCreditsCbox.TabIndex = 9;
|
||||
mergeOpeningEndCreditsCbox.Text = "[MergeOpeningEndCredits desc]";
|
||||
mergeOpeningEndCreditsCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// retainAaxFileCbox
|
||||
//
|
||||
retainAaxFileCbox.AutoSize = true;
|
||||
retainAaxFileCbox.Location = new System.Drawing.Point(19, 107);
|
||||
retainAaxFileCbox.Location = new System.Drawing.Point(19, 133);
|
||||
retainAaxFileCbox.Name = "retainAaxFileCbox";
|
||||
retainAaxFileCbox.Size = new System.Drawing.Size(131, 19);
|
||||
retainAaxFileCbox.TabIndex = 10;
|
||||
retainAaxFileCbox.TabIndex = 8;
|
||||
retainAaxFileCbox.Text = "[RetainAaxFile desc]";
|
||||
retainAaxFileCbox.UseVisualStyleBackColor = true;
|
||||
retainAaxFileCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged;
|
||||
|
|
@ -1323,10 +1347,10 @@
|
|||
downloadCoverArtCbox.AutoSize = true;
|
||||
downloadCoverArtCbox.Checked = true;
|
||||
downloadCoverArtCbox.CheckState = System.Windows.Forms.CheckState.Checked;
|
||||
downloadCoverArtCbox.Location = new System.Drawing.Point(19, 58);
|
||||
downloadCoverArtCbox.Location = new System.Drawing.Point(19, 85);
|
||||
downloadCoverArtCbox.Name = "downloadCoverArtCbox";
|
||||
downloadCoverArtCbox.Size = new System.Drawing.Size(162, 19);
|
||||
downloadCoverArtCbox.TabIndex = 10;
|
||||
downloadCoverArtCbox.TabIndex = 4;
|
||||
downloadCoverArtCbox.Text = "[DownloadCoverArt desc]";
|
||||
downloadCoverArtCbox.UseVisualStyleBackColor = true;
|
||||
downloadCoverArtCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged;
|
||||
|
|
@ -1336,10 +1360,10 @@
|
|||
createCueSheetCbox.AutoSize = true;
|
||||
createCueSheetCbox.Checked = true;
|
||||
createCueSheetCbox.CheckState = System.Windows.Forms.CheckState.Checked;
|
||||
createCueSheetCbox.Location = new System.Drawing.Point(19, 32);
|
||||
createCueSheetCbox.Location = new System.Drawing.Point(19, 61);
|
||||
createCueSheetCbox.Name = "createCueSheetCbox";
|
||||
createCueSheetCbox.Size = new System.Drawing.Size(145, 19);
|
||||
createCueSheetCbox.TabIndex = 10;
|
||||
createCueSheetCbox.TabIndex = 3;
|
||||
createCueSheetCbox.Text = "[CreateCueSheet desc]";
|
||||
createCueSheetCbox.UseVisualStyleBackColor = true;
|
||||
createCueSheetCbox.CheckedChanged += allowLibationFixupCbox_CheckedChanged;
|
||||
|
|
@ -1505,5 +1529,7 @@
|
|||
private System.Windows.Forms.Label gridFontScaleFactorLbl;
|
||||
private System.Windows.Forms.GroupBox groupBox1;
|
||||
private System.Windows.Forms.Button applyDisplaySettingsBtn;
|
||||
private System.Windows.Forms.ComboBox spatialAudioCodecCb;
|
||||
private System.Windows.Forms.Label spatialCodecLbl;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ namespace LibationWinForms.Dialogs
|
|||
gridScaleFactorLbl.Text = desc(nameof(config.GridScaleFactor));
|
||||
gridFontScaleFactorLbl.Text = desc(nameof(config.GridFontScaleFactor));
|
||||
|
||||
var dateTimeSources = Enum.GetValues<Configuration.DateTimeSource>().Select(v => new EnumDiaplay<Configuration.DateTimeSource>(v)).ToArray();
|
||||
var dateTimeSources = Enum.GetValues<Configuration.DateTimeSource>().Select(v => new EnumDisplay<Configuration.DateTimeSource>(v)).ToArray();
|
||||
creationTimeCb.Items.AddRange(dateTimeSources);
|
||||
lastWriteTimeCb.Items.AddRange(dateTimeSources);
|
||||
|
||||
|
|
@ -92,8 +92,8 @@ namespace LibationWinForms.Dialogs
|
|||
config.OverwriteExisting = overwriteExistingCbox.Checked;
|
||||
|
||||
|
||||
config.CreationTime = ((EnumDiaplay<Configuration.DateTimeSource>)creationTimeCb.SelectedItem).Value;
|
||||
config.LastWriteTime = ((EnumDiaplay<Configuration.DateTimeSource>)lastWriteTimeCb.SelectedItem).Value;
|
||||
config.CreationTime = ((EnumDisplay<Configuration.DateTimeSource>)creationTimeCb.SelectedItem).Value;
|
||||
config.LastWriteTime = ((EnumDisplay<Configuration.DateTimeSource>)lastWriteTimeCb.SelectedItem).Value;
|
||||
}
|
||||
|
||||
private static int scaleFactorToLinearRange(float scaleFactor)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ namespace AccountsTests
|
|||
#pragma warning disable CS8981 // The type name only contains lower-cased ascii characters. Such names may become reserved for the language.
|
||||
public class AccountsTestBase
|
||||
{
|
||||
protected string EMPTY_FILE { get; } = "{\r\n \"Accounts\": []\r\n}".Replace("\r\n", Environment.NewLine);
|
||||
protected string EMPTY_FILE { get; } = "{\r\n \"Accounts\": [],\r\n \"Cdm\": null\r\n}".Replace("\r\n", Environment.NewLine);
|
||||
|
||||
protected string TestFile;
|
||||
protected Locale usLocale => Localization.Get("us");
|
||||
|
|
|
|||
|
|
@ -346,8 +346,8 @@ namespace FileLiberator.Tests
|
|||
}
|
||||
};
|
||||
|
||||
var flatChapters = DownloadDecryptBook.flattenChapters(HierarchicalChapters);
|
||||
DownloadDecryptBook.combineCredits(flatChapters);
|
||||
var flatChapters = DownloadOptions.flattenChapters(HierarchicalChapters);
|
||||
DownloadOptions.combineCredits(flatChapters);
|
||||
checkChapters(flatChapters, expected);
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +429,7 @@ namespace FileLiberator.Tests
|
|||
}
|
||||
};
|
||||
|
||||
var flatChapters = DownloadDecryptBook.flattenChapters(HierarchicalChapters);
|
||||
var flatChapters = DownloadOptions.flattenChapters(HierarchicalChapters);
|
||||
|
||||
checkChapters(flatChapters, expected);
|
||||
}
|
||||
|
|
@ -525,7 +525,7 @@ namespace FileLiberator.Tests
|
|||
}
|
||||
};
|
||||
|
||||
var flatChapters = DownloadDecryptBook.flattenChapters(HierarchicalChapters_LongerParents);
|
||||
var flatChapters = DownloadOptions.flattenChapters(HierarchicalChapters_LongerParents);
|
||||
|
||||
checkChapters(flatChapters, expected);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue