363 lines
12 KiB
C#
363 lines
12 KiB
C#
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);
|
|
}
|