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; 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, CancellationToken token) { var license = await ChooseContent(api, libraryBook, config, token); token.ThrowIfCancellationRequested(); //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; token.ThrowIfCancellationRequested(); return BuildDownloadOptions(libraryBook, config, license); } private class LicenseInfo { public DrmType DrmType { get; } public ContentMetadata ContentMetadata { get; set; } public KeyData[]? DecryptionKeys { get; } public LicenseInfo(ContentLicense license, IEnumerable? keys = null) { DrmType = license.DrmType; ContentMetadata = license.ContentMetadata; DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher); } private static KeyData[]? ToKeys(VoucherDtoV10? voucher) => voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)]; } private static async Task ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token) { var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) { token.ThrowIfCancellationRequested(); var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); return new LicenseInfo(license); } token.ThrowIfCancellationRequested(); try { //try to request a widevine content license using the user's spatial audio settings var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec; var contentLic = await api.GetDownloadLicenseAsync( libraryBook.Book.AudibleProductId, dlQuality, ChapterTitlesType.Tree, DrmType.Widevine, config.RequestSpatial, codecChoice); if (contentLic.DrmType is not DrmType.Widevine) return new LicenseInfo(contentLic); using var client = new HttpClient(); using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token); var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token)); if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri)) throw new InvalidDataException("Failed to get mpeg-dash content download url."); contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() }; using var session = cdm.OpenSession(); var challenge = session.GetLicenseChallenge(dash); var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge); var keys = session.ParseLicense(licenseMessage); return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key))); } catch (Exception ex) { 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, LicenseInfo licInfo) { long chapterStartMs = config.StripAudibleBrandAudio ? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0; var dlOptions = new DownloadOptions(config, libraryBook, licInfo) { ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)), RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), }; var titleConcat = config.CombineNestedChapterTitles ? ": " : null; var chapters = flattenChapters(licInfo.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 -= licInfo.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, 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]); } } }