Add codec tag and use real bitrate/samplerate (#1227)

This commit is contained in:
Michael Bucari-Tovo 2025-05-02 11:20:58 -06:00
parent f4dafac28f
commit 3982edd0f1
7 changed files with 106 additions and 44 deletions

View File

@ -39,8 +39,8 @@ namespace FileLiberator
/// Path: in progress directory. /// Path: in progress directory.
/// File name: final file name. /// File name: final file name.
/// </summary> /// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true); => Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
/// <summary> /// <summary>
/// PDF: audio file does not exist /// PDF: audio file does not exist
@ -48,6 +48,12 @@ namespace FileLiberator
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension); => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
/// <summary> /// <summary>
/// PDF: audio file already exists /// PDF: audio file already exists
/// </summary> /// </summary>

View File

@ -47,13 +47,18 @@ namespace FileLiberator
if (libraryBook.Book.Audio_Exists()) if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var config = Configuration.Instance;
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook);
bool success = false; bool success = false;
try try
{ {
FilePathCache.Inserted += FilePathCache_Inserted; FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed; FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(libraryBook); success = await downloadAudiobookAsync(api, config, downloadOptions);
} }
finally finally
{ {
@ -78,12 +83,12 @@ namespace FileLiberator
var finalStorageDir = getDestinationDirectory(libraryBook); var finalStorageDir = getDestinationDirectory(libraryBook);
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries)); var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
Task[] finalTasks = new[] Task[] finalTasks =
{ [
Task.Run(() => downloadCoverArt(libraryBook)), Task.Run(() => downloadCoverArt(downloadOptions)),
moveFilesTask, moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir)) Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
}; ];
try try
{ {
@ -116,16 +121,11 @@ namespace FileLiberator
} }
} }
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
{ {
var config = Configuration.Instance; var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
using var dlOptions = await DownloadOptions.InitiateDownloadAsync(api, libraryBook, config);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine) if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
@ -149,7 +149,7 @@ namespace FileLiberator
abDownloader.RetrievedAuthors += OnAuthorsDiscovered; abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered; abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path); abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path);
// REAL WORK DONE HERE // REAL WORK DONE HERE
var success = await abDownloader.RunAsync(); var success = await abDownloader.RunAsync();
@ -158,12 +158,12 @@ namespace FileLiberator
{ {
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json"); var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo)); item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference)); item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString()); File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(libraryBook, metadataFile); OnFileCreated(dlOptions.LibraryBook, metadataFile);
} }
return success; return success;
} }
@ -280,7 +280,7 @@ namespace FileLiberator
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries) private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio); => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private static void downloadCoverArt(LibraryBook libraryBook) private static void downloadCoverArt(DownloadOptions options)
{ {
if (!Configuration.Instance.DownloadCoverArt) return; if (!Configuration.Instance.DownloadCoverArt) return;
@ -288,24 +288,24 @@ namespace FileLiberator
try try
{ {
var destinationDir = getDestinationDirectory(libraryBook); var destinationDir = getDestinationDirectory(options.LibraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg"); coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath)); coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
if (File.Exists(coverPath)) if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath); FileUtility.SaferDelete(coverPath);
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native)); var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native));
if (picBytes.Length > 0) if (picBytes.Length > 0)
{ {
File.WriteAllBytes(coverPath, picBytes); File.WriteAllBytes(coverPath, picBytes);
SetFileTime(libraryBook, coverPath); SetFileTime(options.LibraryBook, coverPath);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
//Failure to download cover art should not be considered a failure to download the book //Failure to download cover art should not be considered a failure to download the book
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product."); Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
} }
} }
} }

View File

@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable #nullable enable
@ -23,7 +24,7 @@ public partial class DownloadOptions
/// <summary> /// <summary>
/// Initiate an audiobook download from the audible api. /// Initiate an audiobook download from the audible api.
/// </summary> /// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, LibraryBook libraryBook, Configuration config) public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook)
{ {
var license = await ChooseContent(api, libraryBook, config); var license = await ChooseContent(api, libraryBook, config);
var options = BuildDownloadOptions(libraryBook, config, license); var options = BuildDownloadOptions(libraryBook, config, license);
@ -172,6 +173,14 @@ public partial class DownloadOptions
RuntimeLength = TimeSpan.FromMilliseconds(contentLic.ContentMetadata.ChapterInfo.RuntimeLengthMs), 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 titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters var chapters
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat) = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
@ -198,6 +207,43 @@ public partial class DownloadOptions
return dlOptions; return dlOptions;
} }
/// <summary>
/// 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.
/// </summary>
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) public static LameConfig GetLameOptions(Configuration config)
{ {
LameConfig lameConfig = new() LameConfig lameConfig = new()
@ -355,4 +401,9 @@ public partial class DownloadOptions
static double RelativePercentDifference(long num1, long num2) static double RelativePercentDifference(long num1, long num2)
=> Math.Abs(num1 - num2) / (double)(num1 + 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();
} }

View File

@ -55,9 +55,6 @@ namespace FileLiberator
IsPodcastParent = libraryBook.Book.IsEpisodeParent(), IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
Channels = libraryBook.Book.AudioFormat.Channels,
Language = libraryBook.Book.Language Language = libraryBook.Book.Language
}; };
} }

View File

@ -27,9 +27,10 @@ public class BookDto
public bool IsPodcastParent { get; set; } public bool IsPodcastParent { get; set; }
public bool IsPodcast { get; set; } public bool IsPodcast { get; set; }
public int BitRate { get; set; } public int? BitRate { get; set; }
public int SampleRate { get; set; } public int? SampleRate { get; set; }
public int Channels { get; set; } public int? Channels { get; set; }
public string? Codec { get; set; }
public DateTime FileDate { get; set; } = DateTime.Now; public DateTime FileDate { get; set; } = DateTime.Now;
public DateTime? DatePublished { get; set; } public DateTime? DatePublished { get; set; }
public string? Language { get; set; } public string? Language { get; set; }

View File

@ -36,9 +36,10 @@ namespace LibationFileManager.Templates
public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)"); public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)");
public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series"); public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series");
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>"); public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>");
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "File's orig. bitrate"); public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Audiobook's source bitrate");
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate"); public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Audiobook's source sample rate");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels"); public static TemplateTags Channels { get; } = new TemplateTags("channels", "Audiobook's source audio channel count");
public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audiobook's source codec");
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book"); public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book"); public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
public static TemplateTags Locale { get; } = new("locale", "Region/country"); public static TemplateTags Locale { get; } = new("locale", "Region/country");

View File

@ -271,9 +271,6 @@ namespace LibationFileManager.Templates
{ TemplateTags.Language, lb => lb.Language }, { TemplateTags.Language, lb => lb.Language },
//Don't allow formatting of LanguageShort //Don't allow formatting of LanguageShort
{ TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort }, { TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort },
{ TemplateTags.Bitrate, lb => (int?)(lb.IsPodcastParent ? null : lb.BitRate) },
{ TemplateTags.SampleRate, lb => (int?)(lb.IsPodcastParent ? null : lb.SampleRate) },
{ TemplateTags.Channels, lb => (int?)(lb.IsPodcastParent ? null : lb.Channels) },
{ TemplateTags.Account, lb => lb.Account }, { TemplateTags.Account, lb => lb.Account },
{ TemplateTags.AccountNickname, lb => lb.AccountNickname }, { TemplateTags.AccountNickname, lb => lb.AccountNickname },
{ TemplateTags.Locale, lb => lb.Locale }, { TemplateTags.Locale, lb => lb.Locale },
@ -283,6 +280,15 @@ namespace LibationFileManager.Templates
{ TemplateTags.FileDate, lb => lb.FileDate }, { TemplateTags.FileDate, lb => lb.FileDate },
}; };
private static readonly PropertyTagCollection<LibraryBookDto> audioFilePropertyTags =
new(caseSensative: true, StringFormatter, IntegerFormatter)
{
{ TemplateTags.Bitrate, lb => lb.BitRate },
{ TemplateTags.SampleRate, lb => lb.SampleRate },
{ TemplateTags.Channels, lb => lb.Channels },
{ TemplateTags.Codec, lb => lb.Codec },
};
private static readonly List<TagCollection> chapterPropertyTags = new() private static readonly List<TagCollection> chapterPropertyTags = new()
{ {
new PropertyTagCollection<LibraryBookDto>(caseSensative: true, StringFormatter) new PropertyTagCollection<LibraryBookDto>(caseSensative: true, StringFormatter)
@ -376,8 +382,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "Folder Template"; public static string Name { get; } = "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? ""; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title short> [<id>]"; public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, conditionalTags, folderConditionalTags];
=> new TagCollection[] { filePropertyTags, conditionalTags, folderConditionalTags };
public override IEnumerable<string> Errors public override IEnumerable<string> Errors
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
@ -396,7 +401,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "File Template"; public static string Name { get; } = "File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? ""; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>]"; public static string DefaultTemplate { get; } = "<title> [<id>]";
public static IEnumerable<TagCollection> TagCollections { get; } = new TagCollection[] { filePropertyTags, conditionalTags }; public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags];
} }
public class ChapterFileTemplate : Templates, ITemplate public class ChapterFileTemplate : Templates, ITemplate
@ -404,7 +409,8 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "Chapter File Template"; public static string Name { get; } = "Chapter File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? ""; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags); public static IEnumerable<TagCollection> TagCollections { get; }
= chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags);
public override IEnumerable<string> Warnings public override IEnumerable<string> Warnings
=> NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName)) => NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))