Add spatial audio support
This commit is contained in:
parent
bffaea6026
commit
ece48eb6d7
32 changed files with 15993 additions and 351 deletions
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue