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.Text.RegularExpressions; 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, Configuration config, LibraryBook libraryBook) { var license = await ChooseContent(api, libraryBook, config); //Some audiobooks will have incorrect chapters in the metadata returned from the license request, //but the metadata returned by the content metadata endpoint will be correct. Call the content //metadata endpoint and use its chapters. Only replace the license request chapters if the total //lengths match (defensive against different audio formats having slightly different lengths). var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId); if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs) license.ContentMetadata.ChapterInfo = metadata.ChapterInfo; var options = BuildDownloadOptions(libraryBook, config, license); return options; } private static async Task ChooseContent(Api api, LibraryBook libraryBook, Configuration config) { var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) return await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); try { //try to request a widevine content license using the user's spatial audio settings var codecChoice = config.SpatialAudioCodec switch { Configuration.SpatialCodec.EC_3 => Ec3Codec, Configuration.SpatialCodec.AC_4 => Ac4Codec, _ => throw new NotSupportedException($"Unknown value for {nameof(config.SpatialAudioCodec)}") }; var contentLic = await api.GetDownloadLicenseAsync( libraryBook.Book.AudibleProductId, dlQuality, ChapterTitlesType.Tree, DrmType.Widevine, config.RequestSpatial, codecChoice); using var client = new HttpClient(); using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse); var dash = new MpegDash(mpdResponse.Content.ReadAsStream()); if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri)) throw new InvalidDataException("Failed to get mpeg-dash content download url."); contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() }; using var session = cdm.OpenSession(); var challenge = session.GetLicenseChallenge(dash); var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge); var keys = session.ParseLicense(licenseMessage); contentLic.Voucher = new VoucherDtoV10() { Key = Convert.ToHexStringLower(keys[0].Kid.ToByteArray()), Iv = Convert.ToHexStringLower(keys[0].Key) }; return contentLic; } catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license."); //We failed to get a widevine content license. Depending on the //failure reason, users can potentially still download this audiobook //by disabling the "Use Widevine DRM" feature. throw; } } 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; 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), }; dlOptions.LibraryBookDto.Codec = contentLic.ContentMetadata.ContentReference.Codec; if (TryGetAudioInfo(contentLic.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels)) { dlOptions.LibraryBookDto.BitRate = bitrate; dlOptions.LibraryBookDto.SampleRate = sampleRate; dlOptions.LibraryBookDto.Channels = channels; } 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; } /// /// The most reliable way to get these audio file properties is from the filename itself. /// Using AAXClean to read the metadata works well for everything except AC-4 bitrate. /// private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels) { bitrate = sampleRate = channels = null; if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri)) return false; var file = Path.GetFileName(uri.LocalPath); var match = AdrmAudioProperties().Match(file); if (match.Success) { bitrate = int.Parse(match.Groups[1].Value); sampleRate = int.Parse(match.Groups[2].Value); channels = int.Parse(match.Groups[3].Value); return true; } else if ((match = WidevineAudioProperties().Match(file)).Success) { bitrate = int.Parse(match.Groups[2].Value); sampleRate = int.Parse(match.Groups[1].Value) * 1000; channels = match.Groups[3].Value switch { "ec3" => 6, "ac4" => 3, _ => null }; return true; } return false; } 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, titleConcat)); } 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, titleConcat); 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); [GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)] private static partial Regex WidevineAudioProperties(); [GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)] private static partial Regex AdrmAudioProperties(); }