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";
///
/// Initiate an audiobook download from the audible api.
///
public static async Task 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 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 flattenChapters(IList chapters, string? titleConcat = ": ")
{
List 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 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);
}