From 3982edd0f121ad4c23f430750777648c3887ea91 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 2 May 2025 11:20:58 -0600 Subject: [PATCH] Add codec tag and use real bitrate/samplerate (#1227) --- Source/FileLiberator/AudioFileStorageExt.cs | 10 +++- Source/FileLiberator/DownloadDecryptBook.cs | 48 ++++++++--------- .../FileLiberator/DownloadOptions.Factory.cs | 53 ++++++++++++++++++- Source/FileLiberator/UtilityExtensions.cs | 3 -- .../Templates/LibraryBookDto.cs | 7 +-- .../Templates/TemplateTags.cs | 7 +-- .../Templates/Templates.cs | 22 +++++--- 7 files changed, 106 insertions(+), 44 deletions(-) diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs index ff47fd59..c0ce9bd0 100644 --- a/Source/FileLiberator/AudioFileStorageExt.cs +++ b/Source/FileLiberator/AudioFileStorageExt.cs @@ -39,14 +39,20 @@ namespace FileLiberator /// Path: in progress directory. /// File name: final file name. /// - public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) - => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true); + public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension) + => Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true); /// /// PDF: audio file does not exist /// public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension); + + /// + /// PDF: audio file does not exist + /// + public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension) + => Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension); /// /// PDF: audio file already exists diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index b6de8021..0f92ce8c 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -47,13 +47,18 @@ namespace FileLiberator if (libraryBook.Book.Audio_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; try { FilePathCache.Inserted += FilePathCache_Inserted; FilePathCache.Removed += FilePathCache_Removed; - success = await downloadAudiobookAsync(libraryBook); + success = await downloadAudiobookAsync(api, config, downloadOptions); } finally { @@ -78,12 +83,12 @@ namespace FileLiberator var finalStorageDir = getDestinationDirectory(libraryBook); var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries)); - Task[] finalTasks = new[] - { - Task.Run(() => downloadCoverArt(libraryBook)), + Task[] finalTasks = + [ + Task.Run(() => downloadCoverArt(downloadOptions)), moveFilesTask, Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir)) - }; + ]; try { @@ -116,16 +121,11 @@ namespace FileLiberator } } - private async Task downloadAudiobookAsync(LibraryBook libraryBook) + + + private async Task downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions) { - var config = Configuration.Instance; - - 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 outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower()); var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine) @@ -149,7 +149,7 @@ namespace FileLiberator abDownloader.RetrievedAuthors += OnAuthorsDiscovered; abDownloader.RetrievedNarrators += OnNarratorsDiscovered; abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; - abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path); + abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path); // REAL WORK DONE HERE 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 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.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference)); File.WriteAllText(metadataFile, item.SourceJson.ToString()); - OnFileCreated(libraryBook, metadataFile); + OnFileCreated(dlOptions.LibraryBook, metadataFile); } return success; } @@ -173,7 +173,7 @@ namespace FileLiberator if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options) return; - tags.Title ??= options.LibraryBookDto.TitleWithSubtitle; + tags.Title ??= options.LibraryBookDto.TitleWithSubtitle; tags.Album ??= tags.Title; tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); tags.AlbumArtists ??= tags.Artist; @@ -280,7 +280,7 @@ namespace FileLiberator private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable entries) => entries.FirstOrDefault(f => f.FileType == FileType.Audio); - private static void downloadCoverArt(LibraryBook libraryBook) + private static void downloadCoverArt(DownloadOptions options) { if (!Configuration.Instance.DownloadCoverArt) return; @@ -288,24 +288,24 @@ namespace FileLiberator try { - var destinationDir = getDestinationDirectory(libraryBook); - coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg"); + var destinationDir = getDestinationDirectory(options.LibraryBook); + coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg"); coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath)); if (File.Exists(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) { File.WriteAllBytes(coverPath, picBytes); - SetFileTime(libraryBook, coverPath); + SetFileTime(options.LibraryBook, coverPath); } } catch (Exception ex) { //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."); } } } diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index cfccd55c..63db1f4f 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; +using System.Text.RegularExpressions; using System.Threading.Tasks; #nullable enable @@ -23,7 +24,7 @@ public partial class DownloadOptions /// /// Initiate an audiobook download from the audible api. /// - public static async Task InitiateDownloadAsync(Api api, LibraryBook libraryBook, Configuration config) + public static async Task InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook) { var license = await ChooseContent(api, libraryBook, config); var options = BuildDownloadOptions(libraryBook, config, license); @@ -172,6 +173,14 @@ public partial class DownloadOptions 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) @@ -198,6 +207,43 @@ public partial class DownloadOptions 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() @@ -355,4 +401,9 @@ public partial class DownloadOptions 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(); } diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 6a920990..08f11a50 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -55,9 +55,6 @@ namespace FileLiberator IsPodcastParent = 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 }; } diff --git a/Source/LibationFileManager/Templates/LibraryBookDto.cs b/Source/LibationFileManager/Templates/LibraryBookDto.cs index be26c989..dc32fa9c 100644 --- a/Source/LibationFileManager/Templates/LibraryBookDto.cs +++ b/Source/LibationFileManager/Templates/LibraryBookDto.cs @@ -27,9 +27,10 @@ public class BookDto public bool IsPodcastParent { get; set; } public bool IsPodcast { get; set; } - public int BitRate { get; set; } - public int SampleRate { get; set; } - public int Channels { get; set; } + public int? BitRate { get; set; } + public int? SampleRate { get; set; } + public int? Channels { get; set; } + public string? Codec { get; set; } public DateTime FileDate { get; set; } = DateTime.Now; public DateTime? DatePublished { get; set; } public string? Language { get; set; } diff --git a/Source/LibationFileManager/Templates/TemplateTags.cs b/Source/LibationFileManager/Templates/TemplateTags.cs index d5bead6d..e8cdb1df 100644 --- a/Source/LibationFileManager/Templates/TemplateTags.cs +++ b/Source/LibationFileManager/Templates/TemplateTags.cs @@ -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 FirstSeries { get; } = new TemplateTags("first series", "First series"); public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for "); - public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "File's orig. bitrate"); - public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate"); - public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels"); + public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Audiobook's source bitrate"); + public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Audiobook's source sample rate"); + 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 AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book"); public static TemplateTags Locale { get; } = new("locale", "Region/country"); diff --git a/Source/LibationFileManager/Templates/Templates.cs b/Source/LibationFileManager/Templates/Templates.cs index c054459d..bfba1468 100644 --- a/Source/LibationFileManager/Templates/Templates.cs +++ b/Source/LibationFileManager/Templates/Templates.cs @@ -271,9 +271,6 @@ namespace LibationFileManager.Templates { TemplateTags.Language, lb => lb.Language }, //Don't allow formatting of LanguageShort { 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.AccountNickname, lb => lb.AccountNickname }, { TemplateTags.Locale, lb => lb.Locale }, @@ -281,7 +278,16 @@ namespace LibationFileManager.Templates { TemplateTags.DatePublished, lb => lb.DatePublished }, { TemplateTags.DateAdded, lb => lb.DateAdded }, { TemplateTags.FileDate, lb => lb.FileDate }, - }; + }; + + private static readonly PropertyTagCollection 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 chapterPropertyTags = new() { @@ -376,8 +382,7 @@ namespace LibationFileManager.Templates public static string Name { get; } = "Folder Template"; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? ""; public static string DefaultTemplate { get; } = " [<id>]"; - public static IEnumerable<TagCollection> TagCollections - => new TagCollection[] { filePropertyTags, conditionalTags, folderConditionalTags }; + public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, conditionalTags, folderConditionalTags]; public override IEnumerable<string> 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 Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? ""; 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 @@ -404,7 +409,8 @@ namespace LibationFileManager.Templates public static string Name { get; } = "Chapter File Template"; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? ""; 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 => NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))